Page Menu
Home
Software Heritage
Search
Configure Global Search
Log In
Files
F9340124
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
20 KB
Subscribers
None
View Options
diff --git a/swh/web/ui/renderers.py b/swh/web/ui/renderers.py
index 9b0bfca91..1f17547b3 100644
--- a/swh/web/ui/renderers.py
+++ b/swh/web/ui/renderers.py
@@ -1,230 +1,257 @@
# Copyright (C) 2015-2016 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
import re
import yaml
import json
from docutils.core import publish_parts
from docutils.writers.html4css1 import Writer, HTMLTranslator
from inspect import cleandoc
from jinja2 import Markup
from flask import request, Response, render_template
from flask import g
from pygments import highlight
from pygments.lexers import guess_lexer
from pygments.formatters import HtmlFormatter
from swh.web.ui import utils
class SWHFilterEnricher():
"""Global filter on fields.
"""
@classmethod
def filter_by_fields(cls, data):
"""Extract a request parameter 'fields' if it exists to permit the
filtering on the data dict's keys.
If such field is not provided, returns the data as is.
"""
fields = request.args.get('fields')
if fields:
fields = set(fields.split(','))
data = utils.filter_field_keys(data, fields)
return data
-class SWHAddLinkHeaderEnricher:
+class SWHComputeLinkHeader:
"""Add link header to response.
Mixin intended to be used for example in SWHMultiResponse
"""
@classmethod
- def add_link_header(cls, rv, options):
+ def compute_link_header(cls, rv, options):
"""Add Link header in returned value results.
+ Expects rv to be a dict with 'results' and 'headers' key:
+ 'results': the returned value expected to be shown
+ 'headers': dictionary with link-next and link-prev
+
Args:
rv (dict): with keys:
- 'headers': potential headers with 'link-next'
and 'link-prev' keys
- 'results': containing the result to return
Returns:
tuple rv, options:
If link-headers are present, rv is the returned value
present in the 'results' key. Also, options is updated
with headers 'Link' containing the 'link-next' and
'link-prev' headers.
Otherwise, rv, options stays the same as the input.
"""
link_headers = []
if 'headers' not in rv:
- return rv, options
+ return options
rv_headers = rv['headers']
if 'link-next' in rv_headers:
link_headers.append('<%s>; rel="next"' % (
rv_headers['link-next']))
if 'link-prev' in rv_headers:
link_headers.append('<%s>; rel="previous"' % (
rv_headers['link-prev']))
if link_headers:
link_header_str = ','.join(link_headers)
headers = options.get('headers', {})
headers.update({
'Link': link_header_str
})
options['headers'] = headers
+ return options
+
+ return options
+
+
+class SWHTransformProcessor:
+ """Transform an eventual returned value with multiple layer of
+ information with only what's necessary.
+
+ If the returned value rv contains the 'results' key, this is the
+ associated value which is returned.
+
+ Otherwise, return the initial dict without the potential 'headers'
+ key.
+
+ """
+ @classmethod
+ def transform(cls, rv):
+ if 'results' in rv:
+ return rv['results']
+
+ if 'headers' in rv:
rv.pop('headers')
- return rv['results'], options
- return rv, options
+ return rv
-class SWHMultiResponse(Response, SWHFilterEnricher, SWHAddLinkHeaderEnricher):
+class SWHMultiResponse(Response, SWHFilterEnricher,
+ SWHComputeLinkHeader, SWHTransformProcessor):
"""
A Flask Response subclass.
Override force_type to transform dict/list responses into callable Flask
response objects whose mimetype matches the request's Accept header: HTML
template render, YAML dump or default to a JSON dump.
"""
@classmethod
def make_response_from_mimetype(cls, rv, options={}):
if not (isinstance(rv, list) or isinstance(rv, dict)):
return rv
def wants_html(best_match):
return best_match == 'text/html' and \
request.accept_mimetypes[best_match] > \
request.accept_mimetypes['application/json']
def wants_yaml(best_match):
return best_match == 'application/yaml' and \
request.accept_mimetypes[best_match] > \
request.accept_mimetypes['application/json']
rv = cls.filter_by_fields(rv)
acc_mime = ['application/json', 'application/yaml', 'text/html']
best_match = request.accept_mimetypes.best_match(acc_mime)
- rv, options = cls.add_link_header(rv, options)
+ options = cls.compute_link_header(rv, options)
+ rv = cls.transform(rv)
if wants_html(best_match):
data = json.dumps(rv, sort_keys=True,
indent=4, separators=(',', ': '))
env = g.get('doc_env', {})
env['response_data'] = data
env['request'] = request
rv = Response(render_template('apidoc.html', **env),
content_type='text/html',
**options)
elif wants_yaml(best_match):
rv = Response(
yaml.dump(rv),
content_type='application/yaml',
**options)
else:
# jsonify is unhappy with lists in Flask 0.10.1, use json.dumps
rv = Response(
json.dumps(rv),
content_type='application/json',
**options)
return rv
@classmethod
def force_type(cls, rv, environ=None):
if isinstance(rv, dict) or isinstance(rv, list):
rv = cls.make_response_from_mimetype(rv)
return super().force_type(rv, environ)
def error_response(error_code, error):
"""Private function to create a custom error response.
"""
error_opts = {'status': error_code}
error_data = {'error': str(error)}
return SWHMultiResponse.make_response_from_mimetype(error_data,
options=error_opts)
def urlize_api_links(content):
"""Utility function for decorating api links in browsable api."""
return re.sub(r'"(/api/.*|/browse/.*)"', r'"<a href="\1">\1</a>"', content)
class NoHeaderHTMLTranslator(HTMLTranslator):
"""
Docutils translator subclass to customize the generation of HTML
from reST-formatted docstrings
"""
def __init__(self, document):
super().__init__(document)
self.body_prefix = []
self.body_suffix = []
def visit_bullet_list(self, node):
self.context.append((self.compact_simple, self.compact_p))
self.compact_p = None
self.compact_simple = self.is_compactable(node)
self.body.append(self.starttag(node, 'ul', CLASS='docstring'))
DOCSTRING_WRITER = Writer()
DOCSTRING_WRITER.translator_class = NoHeaderHTMLTranslator
def safe_docstring_display(docstring):
"""
Utility function to htmlize reST-formatted documentation in browsable
api.
"""
docstring = cleandoc(docstring)
return publish_parts(docstring, writer=DOCSTRING_WRITER)['html_body']
def revision_id_from_url(url):
"""Utility function to obtain a revision's ID from its browsing URL."""
return re.sub(r'/browse/revision/([0-9a-f]{40}|[0-9a-f]{64})/.*',
r'\1', url)
def highlight_source(source_code_as_text):
"""Leverage pygments to guess and highlight source code.
Args
source_code_as_text (str): source code in plain text
Returns:
Highlighted text if possible or plain text otherwise
"""
try:
maybe_lexer = guess_lexer(source_code_as_text)
if maybe_lexer:
r = highlight(
source_code_as_text, maybe_lexer,
HtmlFormatter(linenos=True,
lineanchors='l',
anchorlinenos=True))
else:
r = '<pre>%s</pre>' % source_code_as_text
except:
r = '<pre>%s</pre>' % source_code_as_text
return Markup(r)
diff --git a/swh/web/ui/tests/test_renderers.py b/swh/web/ui/tests/test_renderers.py
index a7bb278a0..0db3ccb83 100644
--- a/swh/web/ui/tests/test_renderers.py
+++ b/swh/web/ui/tests/test_renderers.py
@@ -1,281 +1,310 @@
-# Copyright (C) 2015 The Software Heritage developers
+# Copyright (C) 2015-2016 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
import json
import unittest
import yaml
from flask import Response
from nose.tools import istest
from unittest.mock import patch, MagicMock
from swh.web.ui import renderers
-class SWHAddLinkHeaderEnricherTest(unittest.TestCase):
+class SWHComputeLinkHeaderTest(unittest.TestCase):
@istest
- def add_link_header(self):
+ def compute_link_header(self):
rv = {
'headers': {'link-next': 'foo', 'link-prev': 'bar'},
'results': [1, 2, 3]
}
options = {}
# when
- _rv, _options = renderers.SWHAddLinkHeaderEnricher.add_link_header(
+ _options = renderers.SWHComputeLinkHeader.compute_link_header(
rv, options)
- self.assertEquals(_rv, [1, 2, 3])
self.assertEquals(_options, {'headers': {
'Link': '<foo>; rel="next",<bar>; rel="previous"',
}})
@istest
- def add_link_header_nothing_changed(self):
+ def compute_link_header_nothing_changed(self):
rv = {}
options = {}
# when
- _rv, _options = renderers.SWHAddLinkHeaderEnricher.add_link_header(
+ _options = renderers.SWHComputeLinkHeader.compute_link_header(
rv, options)
- self.assertEquals(_rv, {})
self.assertEquals(_options, {})
@istest
- def add_link_header_nothing_changed_2(self):
+ def compute_link_header_nothing_changed_2(self):
rv = {'headers': {}}
options = {}
# when
- _rv, _options = renderers.SWHAddLinkHeaderEnricher.add_link_header(
+ _options = renderers.SWHComputeLinkHeader.compute_link_header(
rv, options)
- self.assertEquals(_rv, {'headers': {}})
self.assertEquals(_options, {})
+class SWHTransformProcessorTest(unittest.TestCase):
+ @istest
+ def transform_only_return_results_1(self):
+ rv = {'results': {'some-key': 'some-value'}}
+
+ self.assertEquals(renderers.SWHTransformProcessor.transform(rv),
+ {'some-key': 'some-value'})
+
+ @istest
+ def transform_only_return_results_2(self):
+ rv = {'headers': {'something': 'do changes'},
+ 'results': {'some-key': 'some-value'}}
+
+ self.assertEquals(renderers.SWHTransformProcessor.transform(rv),
+ {'some-key': 'some-value'})
+
+ @istest
+ def transform_do_remove_headers(self):
+ rv = {'headers': {'something': 'do changes'},
+ 'some-key': 'some-value'}
+
+ self.assertEquals(renderers.SWHTransformProcessor.transform(rv),
+ {'some-key': 'some-value'})
+
+ @istest
+ def transform_do_nothing(self):
+ rv = {'some-key': 'some-value'}
+
+ self.assertEquals(renderers.SWHTransformProcessor.transform(rv),
+ {'some-key': 'some-value'})
+
+
class RendererTestCase(unittest.TestCase):
@patch('swh.web.ui.renderers.g')
@patch('swh.web.ui.renderers.json')
@patch('swh.web.ui.renderers.request')
@patch('swh.web.ui.renderers.render_template')
@patch('swh.web.ui.renderers.SWHMultiResponse.filter_by_fields')
@istest
def swh_multi_response_mimetype_html(self, mock_filter,
mock_render, mock_request, mock_json,
mock_g):
# given
data = {
'data': [12, 34],
'id': 'adc83b19e793491b1c6ea0fd8b46cd9f32e592fc'
}
mock_g.get.return_value = {'my_key': 'my_display_value'}
# mock_enricher.return_value = (data, {})
mock_filter.return_value = data
expected_env = {
'my_key': 'my_display_value',
'response_data': json.dumps(data),
'request': mock_request
}
def mock_mimetypes(key):
mimetypes = {
'text/html': 10,
'application/json': 0.1,
'application/yaml': 0.1
}
return mimetypes[key]
accept_mimetypes = MagicMock()
accept_mimetypes.__getitem__.side_effect = mock_mimetypes
accept_mimetypes.best_match = MagicMock(return_value='text/html')
mock_request.accept_mimetypes = accept_mimetypes
mock_json.dumps.return_value = json.dumps(data)
# when
rv = renderers.SWHMultiResponse.make_response_from_mimetype(data)
# then
# mock_enricher.assert_called_once_with(data, {})
mock_filter.assert_called_once_with(data)
mock_render.assert_called_with('apidoc.html', **expected_env)
self.assertEqual(rv.status_code, 200)
self.assertEqual(rv.mimetype, 'text/html')
@patch('swh.web.ui.renderers.g')
@patch('swh.web.ui.renderers.yaml')
@patch('swh.web.ui.renderers.request')
@patch('swh.web.ui.renderers.SWHMultiResponse.filter_by_fields')
@istest
def swh_multi_response_mimetype_yaml(self, mock_filter,
mock_request, mock_yaml, mock_g):
# given
data = {'data': [12, 34],
'id': 'adc83b19e793491b1c6ea0fd8b46cd9f32e592fc'}
def mock_mimetypes(key):
mimetypes = {
'application/yaml': 10,
'application/json': 0.1,
'text/html': 0.1
}
return mimetypes[key]
accept_mimetypes = MagicMock()
accept_mimetypes.__getitem__.side_effect = mock_mimetypes
accept_mimetypes.best_match = MagicMock(
return_value='application/yaml')
mock_request.accept_mimetypes = accept_mimetypes
mock_yaml.dump.return_value = yaml.dump(data)
mock_filter.return_value = data
# when
rv = renderers.SWHMultiResponse.make_response_from_mimetype(data)
# then
mock_filter.assert_called_once_with(data)
mock_yaml.dump.assert_called_once_with(data)
self.assertEqual(rv.status_code, 200)
self.assertEqual(rv.mimetype, 'application/yaml')
self.assertEqual(data, yaml.load(rv.data.decode('utf-8')))
@patch('swh.web.ui.renderers.g')
@patch('swh.web.ui.renderers.json')
@patch('swh.web.ui.renderers.request')
@patch('swh.web.ui.renderers.SWHMultiResponse.filter_by_fields')
@istest
def swh_multi_response_mimetype_json(self, mock_filter,
mock_request, mock_json, mock_g):
# given
data = {'data': [12, 34],
'id': 'adc83b19e793491b1c6ea0fd8b46cd9f32e592fc'}
def mock_mimetypes(key):
mimetypes = {
'application/json': 10,
'text/html': 0.1,
'application/yaml': 0.1
}
return mimetypes[key]
accept_mimetypes = MagicMock()
accept_mimetypes.__getitem__.side_effect = mock_mimetypes
accept_mimetypes.best_match = MagicMock(
return_value='application/json')
mock_request.accept_mimetypes = accept_mimetypes
mock_json.dumps.return_value = json.dumps(data)
mock_filter.return_value = data
# when
rv = renderers.SWHMultiResponse.make_response_from_mimetype(data)
# then
mock_filter.assert_called_once_with(data)
mock_json.dumps.assert_called_once_with(data)
self.assertEqual(rv.status_code, 200)
self.assertEqual(rv.mimetype, 'application/json')
self.assertEqual(data, json.loads(rv.data.decode('utf-8')))
@patch('swh.web.ui.renderers.request')
@istest
def swh_multi_response_make_response_not_list_dict(self, mock_request):
# given
incoming = Response()
# when
rv = renderers.SWHMultiResponse.make_response_from_mimetype(incoming)
# then
self.assertEqual(rv, incoming)
@patch('swh.web.ui.renderers.request')
@istest
def swh_filter_renderer_do_nothing(self, mock_request):
# given
mock_request.args = {}
swh_filter_renderer = renderers.SWHFilterEnricher()
input_data = {'a': 'some-data'}
# when
actual_data = swh_filter_renderer.filter_by_fields(input_data)
# then
self.assertEquals(actual_data, input_data)
@patch('swh.web.ui.renderers.utils')
@patch('swh.web.ui.renderers.request')
@istest
def swh_filter_renderer_do_filter(self, mock_request, mock_utils):
# given
mock_request.args = {'fields': 'a,c'}
mock_utils.filter_field_keys.return_value = {'a': 'some-data'}
swh_filter_user = renderers.SWHMultiResponse()
input_data = {'a': 'some-data',
'b': 'some-other-data'}
# when
actual_data = swh_filter_user.filter_by_fields(input_data)
# then
self.assertEquals(actual_data, {'a': 'some-data'})
mock_utils.filter_field_keys.assert_called_once_with(input_data,
{'a', 'c'})
@istest
def urlize_api_links(self):
# update api link with html links content with links
content = '{"url": "/api/1/abc/"}'
expected_content = '{"url": "<a href=\"/api/1/abc/\">/api/1/abc/</a>"}'
self.assertEquals(renderers.urlize_api_links(content),
expected_content)
# update /browse link with html links content with links
content = '{"url": "/browse/def/"}'
expected_content = '{"url": "<a href=\"/browse/def/\">' \
'/browse/def/</a>"}'
self.assertEquals(renderers.urlize_api_links(content),
expected_content)
# will do nothing since it's not an api url
other_content = '{"url": "/something/api/1/other"}'
self.assertEquals(renderers.urlize_api_links(other_content),
other_content)
@istest
def revision_id_from_url(self):
url = ('/browse/revision/9ba4bcb645898d562498ea66a0df958ef0e7a68c/'
'prev/9ba4bcb645898d562498ea66a0df958ef0e7aaaa/')
expected_id = '9ba4bcb645898d562498ea66a0df958ef0e7a68c'
self.assertEqual(renderers.revision_id_from_url(url), expected_id)
@istest
def safe_docstring_display(self):
# update api link with html links content with links
docstring = """This is my list header:
- Here is item 1, with a continuation
line right here
- Here is item 2
Here is something that is not part of the list"""
expected_docstring = """<p>This is my list header:</p>
<ul class="docstring">
<li>Here is item 1, with a continuation
line right here</li>
<li>Here is item 2</li>
</ul>
<p>Here is something that is not part of the list</p>
"""
self.assertEquals(renderers.safe_docstring_display(docstring),
expected_docstring)
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Jul 4 2025, 10:16 AM (4 w, 6 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3452520
Attached To
R65 Staging repository
Event Timeline
Log In to Comment