From 36691a19ef6474e0a1c7846a436f57cc4745f7ca Mon Sep 17 00:00:00 2001 From: Jag_k Date: Mon, 7 Aug 2023 23:59:48 +0400 Subject: [PATCH] Add `redoc` directive --- .editorconfig | 10 ++ docs/conf.py | 4 +- docs/index.rst | 12 ++ docs/redoc_directive.rst | 116 +++++++++++++++++ setup.py | 3 + sphinxcontrib/redoc.j2 | 33 ++--- sphinxcontrib/redoc.py | 222 +++++++++++++++++++++++++++++---- sphinxcontrib/redoc_content.j2 | 27 ++++ tests/test_integration.py | 4 +- 9 files changed, 390 insertions(+), 41 deletions(-) create mode 100644 .editorconfig create mode 100644 docs/redoc_directive.rst create mode 100644 sphinxcontrib/redoc_content.j2 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6aafdd8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +[*] +charset = utf-8 +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.py] +indent_size = 4 +max_line_length = 79 diff --git a/docs/conf.py b/docs/conf.py index 43ae451..ac46088 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,8 +13,8 @@ exclude_patterns = ['_build'] pygments_style = 'sphinx' extlinks = { - 'issue': ('https://github.com/ikalnytskyi/sphinxcontrib-redoc/issues/%s', '#'), - 'pr': ('https://github.com/ikalnytskyi/sphinxcontrib-redoc/pull/%s', 'PR #'), + 'issue': ('https://github.com/ikalnytskyi/sphinxcontrib-redoc/issues/%s', '#%s'), + 'pr': ('https://github.com/ikalnytskyi/sphinxcontrib-redoc/pull/%s', 'PR #%s'), } redoc = [ { diff --git a/docs/index.rst b/docs/index.rst index c45d048..4e1a302 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -23,6 +23,13 @@ small first step: Usage ----- +.. note:: + + For info about ``redoc`` directive, please visit a :ref:`redoc_directive_doc`. + + .. versionadded:: 1.7.0 + + The whole configuration is done via Sphinx's ``conf.py``. All you have to do is to: @@ -168,3 +175,8 @@ Links .. _ReDoc: https://github.com/Rebilly/ReDoc .. _the proof: api/github/ .. _sphinxcontrib-openapi: https://sphinxcontrib-openapi.readthedocs.io/ + +.. toctree:: + :hidden: + + redoc_directive diff --git a/docs/redoc_directive.rst b/docs/redoc_directive.rst new file mode 100644 index 0000000..826f1d7 --- /dev/null +++ b/docs/redoc_directive.rst @@ -0,0 +1,116 @@ +.. _redoc_directive_doc: + +ReDoc Directive +=============== + +.. versionadded:: 1.7.0 + +API +--- + +.. rst:directive:: redoc + + .. rst:directive:option:: name: + + An API (human readable) name that will be used as page title. + + .. rst:directive:option:: spec: + + A path to the OpenAPI spec file. The path is relative to the + document where the directive is used. + + .. rst:directive:option:: embed + + *Disabled by default* + + If set, the ``spec`` will be embedded into the rendered HTML page. + Useful for cases when a browsable API ready to be used without any web + server is needed. + The ``spec`` must be an ``UTF-8`` encoded JSON on YAML OpenAPI spec; + embedding an external ``spec`` is currently not supported. + + .. rst:directive:option:: template: + + Path to non-default template to use to render ReDoc HTML page. Must be either + passed, or omitted. You can also write template in directive body. + + .. warning:: + + When custom template is used, settings such as ``name``, ``embed`` or + ``opts`` may not work if they are not supported by the template. Use + custom templates with caution. + + .. rst:directive:option:: opt.lazy-rendering + + *Disabled by default* + + If set, enables lazy rendering mode which is useful for APIs with big + number of operations (e.g. > 50). In this mode ReDoc shows initial + screen ASAP and then renders the rest operations asynchronously while + showing progress bar on the top. + + .. rst:directive:option:: opt.suppress-warnings + + *Disabled by default* + + If set, no warnings are rendered at the top of the document. + + .. rst:directive:option:: opt.hide-hostname + + *Disabled by default* + + If set, both protocol ans hostname are not shown in the operational + definition. + + .. rst:directive:option:: opt.required-props-firs + + *Disabled by default* + + If set, ReDoc shows required properties first in the same order as in + ``required`` array. Please note, it may be slow. + + .. rst:directive:option:: opt.expand-responses + + *Empty by default* + + A list of response codes to be expanded by default, separated by comma ``,``. + + Example: ``:opt.expand-responses: 200, 201, 204`` + + .. rst:directive:option:: opt.hide-loading + + *Disabled by default* + + Do not show loading animation. Useful for small OpenAPI specs. + + .. rst:directive:option:: opt.native-scrollbars + + *Disabled by default* + + Use native scrollbar for sidemenu instead of perfect-scroll. May + dramatically improve performance on big OpenAPI specs. + + .. rst:directive:option:: opt.untrusted-spec + + *Disabled by default* + + If set, the spec is considered untrusted and all HTML/markdown is + sanitized to prevent XSS. + + + +Example +------- + +.. code:: rst + + .. redoc:: + :name: GitHub API + :spec: _specs/github.yml + :opt.lazy-rendering: true + +.. redoc:: + :name: GitHub API + :spec: _specs/github.yml + :opt.lazy-rendering: + :opt.hide-hostname: diff --git a/setup.py b/setup.py index 1c177b7..10d7425 100644 --- a/setup.py +++ b/setup.py @@ -47,6 +47,9 @@ 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', ], namespace_packages=['sphinxcontrib'], ) diff --git a/sphinxcontrib/redoc.j2 b/sphinxcontrib/redoc.j2 index 5ae493c..ba6999d 100644 --- a/sphinxcontrib/redoc.j2 +++ b/sphinxcontrib/redoc.j2 @@ -9,32 +9,33 @@ - + {% if embed %} {% endif %} + diff --git a/sphinxcontrib/redoc.py b/sphinxcontrib/redoc.py index 6e1c194..a5f8313 100644 --- a/sphinxcontrib/redoc.py +++ b/sphinxcontrib/redoc.py @@ -12,15 +12,21 @@ import io import os import json +import posixpath import jinja2 import jsonschema import pkg_resources import yaml + +from docutils import nodes +from sphinx.util.docutils import SphinxDirective +from docutils.parsers.rst import directives from six.moves import urllib +from sphinx.builders.html import StandaloneHTMLBuilder from sphinx.util.fileutil import copy_asset -from sphinx.util.osutil import copyfile, ensuredir +from sphinx.util.osutil import copyfile, ensuredir, relative_uri _HERE = os.path.abspath(os.path.dirname(__file__)) @@ -60,29 +66,39 @@ } -def render(app): - try: - # Settings set in Sphinx's conf.py may contain improper configuration - # or typos. In order to prevent misbehaviour or failures deep down the - # code, we want to ensure that all required settings are passed and - # optional settings has proper type and/or value. - jsonschema.validate(app.config.redoc, schema=_REDOC_CONF_SCHEMA) - except jsonschema.ValidationError as exc: - raise ValueError( - 'Improper configuration for sphinxcontrib-redoc at %s: %s' % ( - '.'.join((str(part) for part in exc.path)), - exc.message, +def render(app, context=None): + app_content = False + if context is None: + context = app.config.redoc + app_content = True + if app_content: + try: + # Settings set in Sphinx's conf.py may contain improper + # configuration or typos. In order to prevent misbehaviour or + # failures deep down the code, we want to ensure that all required + # settings are passed and optional settings has proper type and/or + # value. + jsonschema.validate(context, schema=_REDOC_CONF_SCHEMA) + except jsonschema.ValidationError as exc: + raise ValueError( + 'Improper configuration for sphinxcontrib-redoc at %s: %s' % ( + '.'.join((str(part) for part in exc.path)), + exc.message, + ) ) - ) - for ctx in app.config.redoc: + for ctx in context: + template = None + template_path = os.path.join(_HERE, 'redoc.j2') if 'template' in ctx: - template_path = os.path.join(app.confdir, ctx['template']) - else: - template_path = os.path.join(_HERE, 'redoc.j2') + if isinstance(ctx['template'], jinja2.Template): + template = ctx['template'] + else: + template_path = os.path.join(app.confdir, ctx['template']) - with io.open(template_path, encoding='utf-8') as f: - template = jinja2.Template(f.read()) + if template is None: + with io.open(template_path, encoding='utf-8') as f: + template = jinja2.Template(f.read()) # In embed mode, we are going to embed the whole OpenAPI spec into # produced HTML. The rationale is very simple: we want to produce @@ -114,7 +130,8 @@ def render(app): # base URI which is a path of directory with conf.py in # our case. os.path.join(app.confdir, ctx['spec']), - os.path.join(specpath, specname)) + os.path.join(specpath, specname) + ) # The link inside the rendered document must refer to a new # location, the place where it has been copied to. @@ -137,7 +154,8 @@ def assets(app, exception): if not exception: copy_asset( os.path.join(_HERE, 'redoc.js'), - os.path.join(app.builder.outdir, '_static')) + os.path.join(app.builder.outdir, '_static'), + ) # It's hard to keep up with ReDoc releases, especially when you don't # watch them closely. Hence, there should be a way to override built-in @@ -148,6 +166,163 @@ def assets(app, exception): os.path.join(app.builder.outdir, '_static', 'redoc.js')) +def boolean_directive(argument): + return argument.strip().lower() in ('true', 'yes', 'on', 'y', '1') + + +class HTMLBuilder(StandaloneHTMLBuilder): + # clone of StandaloneHTMLBuilder.handle_page with a few changes + def render_page( + self, + pagename: str, + addctx: dict, + templatename: str = 'page.html', + ) -> str: + ctx = self.globalcontext.copy() + # current_page_name is backwards compatibility + ctx['pagename'] = ctx['current_page_name'] = pagename + ctx['encoding'] = self.config.html_output_encoding + default_baseuri = self.get_target_uri(pagename) + # in the singlehtml builder, default_baseuri still contains an #anchor + # part, which relative_uri doesn't really like... + default_baseuri = default_baseuri.rsplit('#', 1)[0] + + if self.config.html_baseurl: + ctx['pageurl'] = posixpath.join(self.config.html_baseurl, + pagename + self.out_suffix) + else: + ctx['pageurl'] = None + + def pathto( + otheruri: str, + resource: bool = False, + baseuri: str = default_baseuri, + ) -> str: + if resource and '://' in otheruri: + # allow non-local resources given by scheme + return otheruri + elif not resource: + otheruri = self.get_target_uri(otheruri) + uri = relative_uri(baseuri, otheruri) or '#' + if uri == '#' and not self.allow_sharp_as_current_path: + uri = baseuri + return uri + ctx['pathto'] = pathto + + def hasdoc(name: str) -> bool: + if name in self.env.all_docs: + return True + if name == 'search' and self.search: + return True + if name == 'genindex' and self.get_builder_config( + 'use_index', + 'html', + ): + return True + return False + ctx['hasdoc'] = hasdoc + + ctx['toctree'] = lambda **kwargs: self._get_local_toctree( + pagename, + **kwargs, + ) + self.add_sidebars(pagename, ctx) + ctx.update(addctx) + + # revert script_files and css_files + self.script_files[:] = self._script_files + self.css_files[:] = self._css_files + + self.update_page_context(pagename, templatename, ctx, None) + + # sort JS/CSS before rendering HTML + try: + # Convert script_files to list to support non-list script_files + # (refs: #8889) + ctx['script_files'] = sorted( + ctx['script_files'], + key=lambda js: js.priority + ) + except AttributeError: + # Skip sorting if users modifies script_files directly + # (maybe via `html_context`). + # refs: #8885 + # + # Note: priority sorting feature will not work in this case. + pass + + try: + ctx['css_files'] = sorted( + ctx['css_files'], + key=lambda css: css.priority + ) + except AttributeError: + pass + + return self.templates.render(templatename, ctx) + + +class RedocDirective(SphinxDirective): + _app = None + + required_arguments = 0 + optional_arguments = 0 + final_argument_whitespace = True + option_spec = { + 'name': directives.unchanged_required, + 'spec': directives.unchanged, + 'embed': directives.flag, + 'template': directives.path, + # options + 'opt.lazy-rendering': directives.flag, + 'opt.suppress-warnings': directives.flag, + 'opt.hide-hostname': directives.flag, + 'opt.required-props-first': directives.flag, + 'opt.no-auto-auth': directives.flag, + 'opt.path-in-middle-panel': directives.flag, + 'opt.hide-loading': directives.flag, + 'opt.native-scrollbars': directives.flag, + 'opt.untrusted-spec': directives.flag, + } + + has_content = True + + @property + def builder(self): + return self._app.builder + + def run(self): + # this is the HTML you would put in your .rst file + # redoc container + page = self.get_source_info()[0] + relative_path = os.path.relpath( + page, self._app.confdir + ).rsplit('.', 1)[0] + context = { + "page": relative_path, + "template": os.path.join(_HERE, 'redoc_content.j2') + } + + for k, v in self.options.items(): + if k.startswith('opt.'): + context.setdefault('opts', {})[k[4:]] = v + else: + context[k] = v + + if self.content: + context['template'] = jinja2.Template('\n'.join(self.content)) + + builder = HTMLBuilder(self._app, self.env) + builder.init() + builder.prepare_writing([]) + # using ReDoc to generate the HTML + for i in render(self._app, [context]): + html = builder.render_page(i[0], i[1], i[2]) + break + + return [nodes.raw('', html, format='html')] + + def setup(app): app.add_config_value('redoc', [], 'html') app.add_config_value('redoc_uri', None, 'html') @@ -155,5 +330,8 @@ def setup(app): app.connect('html-collect-pages', render) app.connect('build-finished', assets) + RedocDirective._app = app + app.add_directive('redoc', RedocDirective) + version = pkg_resources.get_distribution('sphinxcontrib-redoc').version return {'version': version, 'parallel_read_safe': True} diff --git a/sphinxcontrib/redoc_content.j2 b/sphinxcontrib/redoc_content.j2 new file mode 100644 index 0000000..1b71432 --- /dev/null +++ b/sphinxcontrib/redoc_content.j2 @@ -0,0 +1,27 @@ + + + + +{% if embed %} + +{% endif %} + diff --git a/tests/test_integration.py b/tests/test_integration.py index e6788e1..98abf3d 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -151,7 +151,9 @@ def test_redocjs_page_is_generated(run_sphinx, tmpdir, options, attributes): soup = bs4.BeautifulSoup(html, 'html.parser') assert soup.title.string == 'Github API (v3)' - assert soup.redoc.attrs == attributes + attrs = soup.redoc.attrs + attrs.pop('id') + assert attrs == attributes assert soup.script.attrs['src'] == os.path.join( '..', '..', '_static', 'redoc.js')