diff --git a/PKG-INFO b/PKG-INFO index 65c56a3d..c54f91a9 100644 --- a/PKG-INFO +++ b/PKG-INFO @@ -1,10 +1,10 @@ Metadata-Version: 1.0 Name: swh.web.ui -Version: 0.0.72 +Version: 0.0.73 Summary: Software Heritage Web UI Home-page: https://forge.softwareheritage.org/diffusion/DWUI/ Author: Software Heritage developers Author-email: swh-devel@inria.fr License: UNKNOWN Description: UNKNOWN Platform: UNKNOWN diff --git a/swh.web.ui.egg-info/PKG-INFO b/swh.web.ui.egg-info/PKG-INFO index 65c56a3d..c54f91a9 100644 --- a/swh.web.ui.egg-info/PKG-INFO +++ b/swh.web.ui.egg-info/PKG-INFO @@ -1,10 +1,10 @@ Metadata-Version: 1.0 Name: swh.web.ui -Version: 0.0.72 +Version: 0.0.73 Summary: Software Heritage Web UI Home-page: https://forge.softwareheritage.org/diffusion/DWUI/ Author: Software Heritage developers Author-email: swh-devel@inria.fr License: UNKNOWN Description: UNKNOWN Platform: UNKNOWN diff --git a/swh.web.ui.egg-info/SOURCES.txt b/swh.web.ui.egg-info/SOURCES.txt index cb7c2c76..17874802 100644 --- a/swh.web.ui.egg-info/SOURCES.txt +++ b/swh.web.ui.egg-info/SOURCES.txt @@ -1,101 +1,106 @@ .gitignore AUTHORS LICENSE MANIFEST.in Makefile Makefile.local README README-dev.md README-uri-scheme.md requirements.txt setup.py version.txt bin/swh-web-ui-dev debian/changelog debian/compat debian/control debian/copyright debian/rules debian/source/format docs/Makefile docs/source/conf.py docs/source/index.rst docs/source/modules.rst docs/source/swh.web.ui.rst docs/source/_static/dependencies.dot docs/source/_static/dependencies.png resources/test/webapp.yml swh.web.ui.egg-info/PKG-INFO swh.web.ui.egg-info/SOURCES.txt swh.web.ui.egg-info/dependency_links.txt swh.web.ui.egg-info/requires.txt swh.web.ui.egg-info/top_level.txt swh/web/ui/__init__.py swh/web/ui/apidoc.py swh/web/ui/backend.py swh/web/ui/converters.py swh/web/ui/exc.py swh/web/ui/main.py swh/web/ui/query.py swh/web/ui/renderers.py swh/web/ui/service.py swh/web/ui/utils.py swh/web/ui/static/robots.txt swh/web/ui/static/css/bootstrap-responsive.min.css swh/web/ui/static/css/jquery.dataTables.min.css swh/web/ui/static/css/pygment.css swh/web/ui/static/css/style.css swh/web/ui/static/img/arrow-up-small.png -swh/web/ui/static/img/swh-logo.png +swh/web/ui/static/img/swh-logo-archive.png +swh/web/ui/static/img/swh-logo-archive.svg +swh/web/ui/static/img/icons/swh-logo-32x32.png +swh/web/ui/static/img/icons/swh-logo-archive-180x180.png +swh/web/ui/static/img/icons/swh-logo-archive-192x192.png +swh/web/ui/static/img/icons/swh-logo-archive-270x270.png swh/web/ui/static/js/calendar.js swh/web/ui/static/js/search.js swh/web/ui/static/lib/README swh/web/ui/static/lib/jquery.dataTables.min.js swh/web/ui/static/lib/jquery.flot.min.js swh/web/ui/static/lib/jquery.flot.selection.min.js swh/web/ui/static/lib/jquery.flot.time.min.js swh/web/ui/templates/about.html swh/web/ui/templates/api-endpoints.html swh/web/ui/templates/api.html swh/web/ui/templates/apidoc.html swh/web/ui/templates/browse.html swh/web/ui/templates/content-with-origin.html swh/web/ui/templates/content.html swh/web/ui/templates/directory.html swh/web/ui/templates/entity.html swh/web/ui/templates/layout.html swh/web/ui/templates/origin.html swh/web/ui/templates/person.html swh/web/ui/templates/release.html swh/web/ui/templates/revision-directory.html swh/web/ui/templates/revision-log.html swh/web/ui/templates/revision.html swh/web/ui/templates/search.html swh/web/ui/templates/symbols.html swh/web/ui/templates/includes/apidoc-header-toc.html swh/web/ui/templates/includes/apidoc-header.html swh/web/ui/templates/includes/apidoc-header.md swh/web/ui/templates/includes/home-content.html swh/web/ui/templates/includes/home-directory.html swh/web/ui/templates/includes/home-origin.html swh/web/ui/templates/includes/home-revision.html swh/web/ui/templates/includes/home-search-symbol.html swh/web/ui/templates/includes/search-form.html swh/web/ui/tests/__init__.py swh/web/ui/tests/test_apidoc.py swh/web/ui/tests/test_app.py swh/web/ui/tests/test_backend.py swh/web/ui/tests/test_converters.py swh/web/ui/tests/test_query.py swh/web/ui/tests/test_renderers.py swh/web/ui/tests/test_service.py swh/web/ui/tests/test_utils.py swh/web/ui/tests/views/__init__.py swh/web/ui/tests/views/test_api.py swh/web/ui/tests/views/test_browse.py swh/web/ui/tests/views/test_main.py swh/web/ui/views/__init__.py swh/web/ui/views/api.py swh/web/ui/views/browse.py swh/web/ui/views/errorhandler.py swh/web/ui/views/main.py \ No newline at end of file diff --git a/swh/web/ui/apidoc.py b/swh/web/ui/apidoc.py index 5f795907..a0358a0f 100644 --- a/swh/web/ui/apidoc.py +++ b/swh/web/ui/apidoc.py @@ -1,423 +1,425 @@ # Copyright (C) 2015-2017 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 from functools import wraps from enum import Enum from flask import request, render_template, url_for from flask import g from swh.web.ui.main import app class argtypes(Enum): # noqa: N801 """Class for centralizing argument type descriptions """ ts = 'timestamp' int = 'integer' str = 'string' path = 'path' sha1 = 'sha1' uuid = 'uuid' sha1_git = 'sha1_git' algo_and_hash = 'hash_type:hash' class rettypes(Enum): # noqa: N801 """Class for centralizing return type descriptions """ octet_stream = 'octet stream' list = 'list' dict = 'dict' class excs(Enum): # noqa: N801 """Class for centralizing exception type descriptions """ badinput = 'BadInputExc' notfound = 'NotFoundExc' class APIUrls(object): """ Class to manage API documentation URLs. * Indexes all routes documented using apidoc's decorators. * Tracks endpoint/request processing method relationships for use in generating related urls in API documentation Relies on the load_controllers logic in main.py for initialization. """ apidoc_routes = {} method_endpoints = {} @classmethod def get_app_endpoints(cls): return cls.apidoc_routes @classmethod def get_method_endpoints(cls, fname): if len(cls.method_endpoints) == 0: cls.method_endpoints = cls.group_routes_by_method() return cls.method_endpoints[fname] @classmethod def group_routes_by_method(cls): """ Group URL endpoints according to their processing method. Returns: A dict where keys are the processing method names, and values are the routes that are bound to the key method. """ endpoints = {} for rule in app.url_map.iter_rules(): rule_dict = {'rule': rule.rule, 'methods': rule.methods} if rule.endpoint not in endpoints: endpoints[rule.endpoint] = [rule_dict] else: endpoints[rule.endpoint].append(rule_dict) return endpoints @classmethod def index_add_route(cls, route, docstring, **kwargs): """ Add a route to the self-documenting API reference """ if route not in cls.apidoc_routes: d = {'docstring': docstring} for k, v in kwargs.items(): d[k] = v cls.apidoc_routes[route] = d class APIDocException(Exception): """ Custom exception to signal errors in the use of the APIDoc decorators """ class APIDocBase(object): """ The API documentation decorator base class, responsible for the operations that link the decorator stack together: * manages the _inner_dec property, which represents the decorator directly below self in the decorator tower * contains the logic used to return appropriately if self is the last decorator to be applied to the API function """ def __init__(self): self._inner_dec = None @property def inner_dec(self): return self._inner_dec @inner_dec.setter def inner_dec(self, instance): self._inner_dec = instance @property def data(self): raise NotImplementedError def process_rv(self, f, args, kwargs): """ From the arguments f has, determine whether or not it is the last decorator in the stack, and return the appropriate call to f. """ rv = None if 'outer_decorator' in f.__code__.co_varnames: rv = f(*args, **kwargs) else: nargs = {k: v for k, v in kwargs.items() if k != 'outer_decorator'} try: rv = f(*args, **nargs) except (TypeError, KeyError): # documentation call rv = None return rv def maintain_stack(self, f, args, kwargs): """ From the arguments f is called with, determine whether or not the stack link was made by @apidoc.route, and maintain the linking for the next call to f. """ if 'outer_decorator' not in kwargs: raise APIDocException('Apidoc %s: expected an apidoc' ' route decorator first' % self.__class__.__name__) kwargs['outer_decorator'].inner_dec = self kwargs['outer_decorator'] = self return self.process_rv(f, args, kwargs) class route(APIDocBase): # noqa: N801 """Decorate an API method to register it in the API doc route index and create the corresponding Flask route. This decorator is responsible for bootstrapping the linking of subsequent decorators, as well as traversing the decorator stack to obtain the documentation data from it. Args: route: documentation page's route noargs: set to True if the route has no arguments, and its result should be displayed anytime its documentation is requested. Default to False hidden: set to True to remove the endpoint from being listed in the /api endpoints. Default to False. tags: Further information on api endpoints. Two values are possibly expected: - hidden: remove the entry points from the listing - upcoming: display the entry point but it is not followable """ def __init__(self, route, noargs=False, tags=[]): super().__init__() self.route = route self.noargs = noargs self.tags = set(tags) def __call__(self, f): options = {} if 'hidden' not in self.tags: if self.tags: options['tags'] = self.tags APIUrls.index_add_route(self.route, f.__doc__, **options) @wraps(f) def doc_func(*args, **kwargs): kwargs['outer_decorator'] = self rv = self.process_rv(f, args, kwargs) return self.compute_return(f, rv) if not self.noargs: app.add_url_rule(self.route, f.__name__, doc_func) return doc_func def filter_api_url(self, endpoint, route_re, noargs): doc_methods = {'GET', 'HEAD', 'OPTIONS'} if re.match(route_re, endpoint['rule']): if endpoint['methods'] == doc_methods and not noargs: return False return True def build_examples(self, f, urls, args): """Build example documentation. Args: f: function urls: information relative to url for that function args: information relative to arguments for that function Yields: example based on default parameter value if any """ s = set() r = [] for data_url in urls: url = data_url['rule'] defaults = {arg['name']: arg['default'] for arg in args if arg['name'] in url} if defaults: url = url_for(f.__name__, **defaults) if url in s: continue s.add(url) r.append(url) return r def compute_return(self, f, rv): """Build documentation""" data = self.data if not f.__doc__: raise APIDocException('Apidoc %s: expected a docstring' ' for function %s' % (self.__class__.__name__, f.__name__)) data['docstring'] = f.__doc__ route_re = re.compile('.*%s$' % data['route']) endpoint_list = APIUrls.get_method_endpoints(f.__name__) data['urls'] = [url for url in endpoint_list if self.filter_api_url(url, route_re, data['noargs'])] if 'args' in data: data['examples'] = self.build_examples( f, data['urls'], data['args']) + data['heading'] = '%s Documentation' % data['route'] + # Prepare and send to mimetype selector if it's not a doc request if re.match(route_re, request.url) and not data['noargs'] \ and request.method == 'GET': return app.response_class( render_template('apidoc.html', **data), content_type='text/html') g.doc_env = data # Store for response processing return rv @property def data(self): data = {'route': self.route, 'noargs': self.noargs} doc_instance = self.inner_dec while doc_instance: if isinstance(doc_instance, arg): if 'args' not in data: data['args'] = [] data['args'].append(doc_instance.data) elif isinstance(doc_instance, raises): if 'excs' not in data: data['excs'] = [] data['excs'].append(doc_instance.data) elif isinstance(doc_instance, returns): data['return'] = doc_instance.data elif isinstance(doc_instance, header): if 'headers' not in data: data['headers'] = [] data['headers'].append(doc_instance.data) elif isinstance(doc_instance, param): if 'params' not in data: data['params'] = [] data['params'].append(doc_instance.data) else: raise APIDocException('Unknown API documentation decorator') doc_instance = doc_instance.inner_dec return data class BaseDescribeDocBase(APIDocBase): """Base description of optional input/output setup for a route. """ def __init__(self): self.doc_data = None self.inner_dec = None def __call__(self, f): @wraps(f) def arg_fun(*args, outer_decorator=None, **kwargs): kwargs['outer_decorator'] = outer_decorator return self.maintain_stack(f, args, kwargs) return arg_fun @property def data(self): return self.doc_data class arg(BaseDescribeDocBase): # noqa: N801 """ Decorate an API method to display an argument's information on the doc page specified by @route above. Args: name: the argument's name. MUST match the method argument's name to create the example request URL. default: the argument's default value argtype: the argument's type as an Enum value from apidoc.argtypes argdoc: the argument's documentation string """ def __init__(self, name, default, argtype, argdoc): super().__init__() self.doc_data = { 'name': name, 'type': argtype.value, 'doc': argdoc, 'default': default } class header(BaseDescribeDocBase): # noqa: N801 """ Decorate an API method to display header information the api can potentially return in the response. Args: name: the header name doc: the information about that header """ def __init__(self, name, doc): super().__init__() self.doc_data = { 'name': name, 'doc': doc, } class param(BaseDescribeDocBase): # noqa: N801 """Decorate an API method to display query parameter information the api can potentially accept. Args: name: parameter's name default: parameter's default value argtype: parameter's type as an Enum value from apidoc.argtypes doc: the information about that header """ def __init__(self, name, default, argtype, doc): super().__init__() self.doc_data = { 'name': name, 'type': argtype.value, 'default': default, 'doc': doc, } class raises(BaseDescribeDocBase): # noqa: N801 """Decorate an API method to display information pertaining to an exception that can be raised by this method. Args: exc: the exception name as an Enum value from apidoc.excs doc: the exception's documentation string """ def __init__(self, exc, doc): super().__init__() self.doc_data = { 'exc': exc.value, 'doc': doc } class returns(BaseDescribeDocBase): # noqa: N801 """Decorate an API method to display information about its return value. Args: rettype: the return value's type as an Enum value from apidoc.rettypes retdoc: the return value's documentation string """ def __init__(self, rettype=None, retdoc=None): super().__init__() self.doc_data = { 'type': rettype.value, 'doc': retdoc } diff --git a/swh/web/ui/renderers.py b/swh/web/ui/renderers.py index c4f36b3f..aee3cb46 100644 --- a/swh/web/ui/renderers.py +++ b/swh/web/ui/renderers.py @@ -1,286 +1,286 @@ # Copyright (C) 2015-2017 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 escape, 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 SWHComputeLinkHeader: """Add link header to response. Mixin intended to be used for example in SWHMultiResponse """ @classmethod 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 options (dict): the initial dict to update with result if any Returns: Dict with optional keys 'link-next' and 'link-prev'. """ link_headers = [] if 'headers' not in rv: return {} 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 }) return headers return {} 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 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={}): options = options.copy() 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'] acc_mime = ['application/json', 'application/yaml', 'text/html'] best_match = request.accept_mimetypes.best_match(acc_mime) options['headers'] = cls.compute_link_header(rv, options) rv = cls.transform(rv) rv = cls.filter_by_fields(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['headers_data'] = None if options and 'headers' in options: env['headers_data'] = options['headers'] env['request'] = request - env['short_path'] = utils.shorten_path(str(request.path)) + env['heading'] = utils.shorten_path(str(request.path)) env['status_code'] = options.get('status', 200) 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(text): """Utility function for decorating api links in browsable api. Args: text: whose content matching links should be transformed into contextual API or Browse html links. Returns The text transformed if any link is found. The text as is otherwise. """ return re.sub(r'(/api/.*/|/browse/.*/)', r'\1', str(escape(text))) def urlize_header_links(text): """Utility function for decorating headers links in browsable api. Args text: Text whose content contains Link header value Returns: The text transformed with html link if any link is found. The text as is otherwise. """ return re.sub(r'<(/api/.*|/browse/.*)>', r'<\1>', text) 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 = '
%s
' % source_code_as_text except: r = '
%s
' % source_code_as_text return Markup(r) diff --git a/swh/web/ui/static/css/style.css b/swh/web/ui/static/css/style.css index ab09e63b..5a69b391 100644 --- a/swh/web/ui/static/css/style.css +++ b/swh/web/ui/static/css/style.css @@ -1,305 +1,316 @@ /* version: 0.1 date: 21/09/15 author: swh email: swh website: softwareheritage.org version history: /style.css */ @import url(https://fonts.googleapis.com/css?family=Alegreya:400,400italic,700,700italic); @import url(https://fonts.googleapis.com/css?family=Alegreya+Sans:400,400italic,500,500italic,700,700italic,100,300,100italic,300italic); +html { + height: 100%; +} + body { font-family: 'Alegreya Sans', sans-serif; font-size: 1.7rem; line-height: 1.5; color: rgba(0, 0, 0, 0.55); - padding-top: 70px; /* avoid fixed bootstrap navbar covers content */ + padding-top: 80px; /* avoid fixed bootstrap navbar covers content */ + padding-bottom: 120px; + min-height: 100%; + margin: 0; + position: relative; } .heading { font-family: 'Alegreya', serif; } .shell, .text { font-size: 0.7em; } -img.swh-logo { - max-height: 45px; +.logo img { + max-height: 40px; +} +.logo .navbar-brand { + padding: 5px; +} +.logo .sitename { + padding: 15px 5px; } .jumbotron { padding: 0; background-color: rgba(0, 0, 0, 0); position: fixed; top: 0; width: 100%; } #swh-navbar-collapse { border-top-style: none; border-left-style: none; border-right-style: none; border-bottom: 5px solid; border-image: linear-gradient(to right, rgb(226, 0, 38) 0%, rgb(254, 205, 27) 100%) 1 1 1 1; width: 100%; - padding-bottom: 10px; + padding: 5px; } .nav-horizontal { float: right; } h3[id], h4[id], a[id] { /* avoid in-page links covered by navbar */ padding-top: 80px; margin-top: -70px; } h1, h2, h3, h4 { margin: 0; color: #e20026; padding-bottom: 10px; } h1 { font-size: 1.8em; } h2 { font-size: 1.2em; } h3 { font-size: 1.1em; } a { color: rgba(0, 0, 0, 0.75); border-bottom-style: dotted; border-bottom-width: 1px; border-bottom-color: rgb(91, 94, 111); } a:hover { color: black; } -ul.dropdown-menu > li > a, -ul.dropdown-menu > li > ul > li > a, -.navbar-header > a, -ul.navbar-nav > li > a { /* No decoration on links in dropdown menu */ +ul.dropdown-menu a, +.navbar-header a, +ul.navbar-nav a { /* No decoration on links in dropdown menu */ border-bottom-style: none; -} - -.navbar-header > a, -ul.navbar-nav > li > a { color: #323232; font-weight: 700; } -.navbar-header > a:hover, -ul.navbar-nav > li > a:hover { +.navbar-header a:hover, +ul.navbar-nav a:hover { color: #8f8f8f; } .sitename .first-word, .sitename .second-word { + color: rgba(0, 0, 0, 0.75); font-weight: normal; font-size: 1.8rem; } .sitename .first-word { font-family: 'Alegreya Sans', sans-serif; } .sitename .second-word { font-family: 'Alegreya', serif; } ul.dropdown-menu > li, ul.dropdown-menu > li > ul > li { /* No decoration on bullet points in dropdown menu */ list-style-type: none; } .page { margin: 2em auto; width: 35em; border: 5px solid #ccc; padding: 0.8em; background: white; } .entries { list-style: none; margin: 0; padding: 0; } .entries li { margin: 0.8em 1.2em; } .entries li h2 { margin-left: -1em; } .add-entry { font-size: 0.9em; border-bottom: 1px solid #ccc; } .add-entry dl { font-weight: bold; } .metanav { text-align: right; font-size: 0.8em; padding: 0.3em; margin-bottom: 1em; background: #fafafa; } .flash { background: #cee5F5; padding: 0.5em; border: 1px solid #aacbe2; } .error { background: #f0d6d6; padding: 0.5em; } .file-found { color: #23BA49; } .file-notfound { color: #FF4747; } /* Bootstrap custom styling to correctly render multiple * form-controls in an input-group: * github.com/twbs/bootstrap/issues/12732 */ .input-group-field { display: table-cell; vertical-align: middle; border-radius:4px; min-width:1%; white-space: nowrap; } .input-group-field .form-control { border-radius: inherit !important; } .input-group-field:not(:first-child):not(:last-child) { border-radius:0; } .input-group-field:not(:first-child):not(:last-child) .form-control { border-left-width: 0; border-right-width: 0; } .input-group-field:last-child { border-top-left-radius:0; border-bottom-left-radius:0; } .input-group > span:not(:last-child) > button { border-radius: 0; } .multi-input-group > .input-group-btn { vertical-align: bottom; padding: 0; } .dataTables_filter { margin-top: 15px; } .dataTables_filter input { width: 70%; float: right; } tr.api-doc-route-upcoming > td, tr.api-doc-route-upcoming > td > a { font-size: 90%; } tr.api-doc-route-deprecated > td, tr.api-doc-route-deprecated > td > a { color: red; } #back-to-top { display: initial; position: fixed; bottom: 30px; right: 30px; z-index: 10; } #back-to-top a img { display: block; width: 32px; height: 32px; background-size: 32px 32px; text-indent: -999px; overflow: hidden; } .table > thead > tr > th { border-bottom: 1px solid #e20026; } .table > tbody > tr > td { border-style: none; } pre { - border: 1px dashed black; - border-radius: 10px; background-color: hsl(47, 99%, 75%); } .dataTables_wrapper { position: static; } /* breadcrumbs */ .bread-crumbs{ display: inline-block; margin: 0 0 15px; overflow: hidden; color: rgba(0, 0, 0, 0.55); font-size: 1.2rem; } bread-crumbs ul { list-style-type: none; } .bread-crumbs li { float: left; margin-right: 10px; list-style-type: none; } .bread-crumbs a { color: rgba(0, 0, 0, 0.75); border-bottom-style: none; } .bread-crumbs a:hover { color: rgba(0, 0, 0, 0.85); text-decoration: underline; } .title-small .bread-crumbs{ margin: -30px 0 25px; } #footer { background-color: #262626; color: hsl(0, 0%, 100%); font-size: 1.2rem; text-align: center; - margin-top: 50px; padding-top: 20px; padding-bottom: 20px; + position: absolute; + bottom: 0; + left: 0; + right: 0; } +#footer a, #footer a:visited { color: hsl(0, 0%, 100%); } #footer a:hover { - color: white; text-decoration: underline; } diff --git a/swh/web/ui/static/img/icons/swh-logo-32x32.png b/swh/web/ui/static/img/icons/swh-logo-32x32.png new file mode 100644 index 00000000..4b3420ad Binary files /dev/null and b/swh/web/ui/static/img/icons/swh-logo-32x32.png differ diff --git a/swh/web/ui/static/img/icons/swh-logo-archive-180x180.png b/swh/web/ui/static/img/icons/swh-logo-archive-180x180.png new file mode 100644 index 00000000..245cf63a Binary files /dev/null and b/swh/web/ui/static/img/icons/swh-logo-archive-180x180.png differ diff --git a/swh/web/ui/static/img/icons/swh-logo-archive-192x192.png b/swh/web/ui/static/img/icons/swh-logo-archive-192x192.png new file mode 100644 index 00000000..c1e03093 Binary files /dev/null and b/swh/web/ui/static/img/icons/swh-logo-archive-192x192.png differ diff --git a/swh/web/ui/static/img/icons/swh-logo-archive-270x270.png b/swh/web/ui/static/img/icons/swh-logo-archive-270x270.png new file mode 100644 index 00000000..5a58314b Binary files /dev/null and b/swh/web/ui/static/img/icons/swh-logo-archive-270x270.png differ diff --git a/swh/web/ui/static/img/swh-logo-archive.png b/swh/web/ui/static/img/swh-logo-archive.png new file mode 100644 index 00000000..a4764de0 Binary files /dev/null and b/swh/web/ui/static/img/swh-logo-archive.png differ diff --git a/swh/web/ui/static/img/swh-logo-archive.svg b/swh/web/ui/static/img/swh-logo-archive.svg new file mode 100644 index 00000000..e6c02a23 --- /dev/null +++ b/swh/web/ui/static/img/swh-logo-archive.svg @@ -0,0 +1,160 @@ + + + +image/svg+xml + + + + +archive + \ No newline at end of file diff --git a/swh/web/ui/static/img/swh-logo.png b/swh/web/ui/static/img/swh-logo.png deleted file mode 100644 index 29ed8213..00000000 Binary files a/swh/web/ui/static/img/swh-logo.png and /dev/null differ diff --git a/swh/web/ui/templates/apidoc.html b/swh/web/ui/templates/apidoc.html index 48d874de..5688ca1a 100644 --- a/swh/web/ui/templates/apidoc.html +++ b/swh/web/ui/templates/apidoc.html @@ -1,128 +1,128 @@ {% extends "layout.html" %} -{% block title %}{{ short_path }} – Software Heritage API {% endblock %} +{% block title %}{{ heading }} – Software Heritage API {% endblock %} {% block content %} {% if docstring %}

Description

{{ docstring | safe_docstring_display | safe }}
{% endif %} {% if response_data and response_data is not none %}

Request

{{ request.method }} {{ request.url }}

Response

{% if status_code != 200 %}

Status Code

{{ status_code }}
{% endif %} {% if headers_data and headers_data is not none %}

Headers

{% for header_name, header_value in headers_data.items() %}
{{ header_name }} {{ header_value | urlize_header_links | safe }}
{% endfor %} {% endif %}

Body

{{ response_data | urlize_api_links | safe }}
{% endif %}
{% for url in urls %} {% endfor %}
URL Allowed Methods
{{ url['rule'] }} {{ url['methods'] | sort | join(', ') }}

{% if args and args|length > 0 %}

Args

{% for arg in args %}
{{ arg['name'] }}: {{ arg['type'] }}
{{ arg['doc'] | safe_docstring_display | safe }}
{% endfor %}
{% endif %} {% if params and params|length > 0 %}

Params

{% for param in params %}
{{ param['name'] }}: {{ param['type'] }}
{{ param['doc'] | safe_docstring_display | safe }}
{% endfor %}
{% endif %} {% if excs and excs|length > 0 %}

Raises

{% for exc in excs %}
{{ exc['exc'] }}
{{ exc['doc'] | safe_docstring_display | safe }}
{% endfor %}
{% endif %} {% if headers %}

Headers

{% for header in headers %}
{{ header['name'] }}: string
{{ header['doc'] | safe_docstring_display | safe }}
{% endfor %}
{% endif %} {% if return %}

Returns

{{ return['type'] }}
{{ return['doc'] | safe_docstring_display | safe }}
{% endif %} {% if examples %}

Examples

{% for example in examples %}
{{ example }}
{% endfor %}
{% endif %} {% endblock %} diff --git a/swh/web/ui/templates/layout.html b/swh/web/ui/templates/layout.html index 815747ca..b5ccd1f7 100644 --- a/swh/web/ui/templates/layout.html +++ b/swh/web/ui/templates/layout.html @@ -1,76 +1,82 @@ {% block title %}{% endblock %} + + + +

{{ self.title() }}

{% with messages = get_flashed_messages(with_categories=true) %} {% if messages %}
{% for category, message in messages %} {% endfor %}
{% endif %} {% endwith %}
{% block content %}{% endblock %}
back to top
diff --git a/swh/web/ui/tests/test_renderers.py b/swh/web/ui/tests/test_renderers.py index c21a8625..2a6b8543 100644 --- a/swh/web/ui/tests/test_renderers.py +++ b/swh/web/ui/tests/test_renderers.py @@ -1,326 +1,326 @@ # Copyright (C) 2015-2017 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 SWHComputeLinkHeaderTest(unittest.TestCase): @istest def compute_link_header(self): rv = { 'headers': {'link-next': 'foo', 'link-prev': 'bar'}, 'results': [1, 2, 3] } options = {} # when headers = renderers.SWHComputeLinkHeader.compute_link_header( rv, options) self.assertEquals(headers, { 'Link': '; rel="next",; rel="previous"', }) @istest def compute_link_header_nothing_changed(self): rv = {} options = {} # when headers = renderers.SWHComputeLinkHeader.compute_link_header( rv, options) self.assertEquals(headers, {}) @istest def compute_link_header_nothing_changed_2(self): rv = {'headers': {}} options = {} # when headers = renderers.SWHComputeLinkHeader.compute_link_header( rv, options) self.assertEquals(headers, {}) 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') @patch('swh.web.ui.utils.shorten_path') @istest def swh_multi_response_mimetype_html(self, mock_shorten_path, 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 mock_shorten_path.return_value = 'my_short_path' expected_env = { 'my_key': 'my_display_value', 'response_data': json.dumps(data), 'request': mock_request, 'headers_data': {}, - 'short_path': 'my_short_path', + 'heading': 'my_short_path', 'status_code': 200, } 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_api(self): # update api link with html links content with links content = '{"url": "/api/1/abc/"}' expected_content = ('{"url": ' '"/api/1/abc/"}') self.assertEquals(renderers.urlize_api_links(content), expected_content) @istest def urlize_api_links_browse(self): # update /browse link with html links content with links content = '{"url": "/browse/def/"}' expected_content = ('{"url": ' '"' '/browse/def/"}') self.assertEquals(renderers.urlize_api_links(content), expected_content) @istest def urlize_header_links(self): # update api link with html links content with links content = """; rel="next" ; rel="prev" """ expected_content = """</api/1/abc/>; rel="next" </api/1/def/>; rel="prev" """ self.assertEquals(renderers.urlize_header_links(content), expected_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 = """

This is my list header:

Here is something that is not part of the list

""" self.assertEquals(renderers.safe_docstring_display(docstring), expected_docstring) diff --git a/version.txt b/version.txt index f87d81fc..a06922cd 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v0.0.72-0-gc2e9f5c \ No newline at end of file +v0.0.73-0-g791a368 \ No newline at end of file