From 7ba54500fcedce47f7cbe29732d246cc84fd2462 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Mon, 8 Jan 2018 11:59:50 +0900 Subject: [PATCH 1/3] Fix #4183: doctest: ``:pyversion:`` option also follows PEP-440 specification --- CHANGES | 7 ++++++- doc/ext/doctest.rst | 18 +++++++++++++++--- setup.py | 1 + sphinx/ext/doctest.py | 39 ++++++++++++++++----------------------- tests/test_ext_doctest.py | 39 +++++++++++++++++++++++++-------------- 5 files changed, 63 insertions(+), 41 deletions(-) diff --git a/CHANGES b/CHANGES index 252804ef3..0133d40de 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,11 @@ Release 1.7 (in development) ============================ +Dependencies +------------ + +* Add ``packaging`` package + Incompatible changes -------------------- @@ -57,7 +62,7 @@ Features added * #947: autodoc now supports ignore-module-all to ignore a module's ``__all__`` * #4332: Let LaTeX obey :confval:`math_numfig` for equation numbering * #4093: sphinx-build creates empty directories for unknown targets/builders - +* #4183: doctest: ``:pyversion:`` option also follows PEP-440 specification Features removed ---------------- diff --git a/doc/ext/doctest.rst b/doc/ext/doctest.rst index d1cb3c31d..62221bf04 100644 --- a/doc/ext/doctest.rst +++ b/doc/ext/doctest.rst @@ -80,12 +80,24 @@ a comma-separated list of group names. .. doctest:: :pyversion: > 3.3 - The supported operands are ``<``, ``<=``, ``==``, ``>=``, ``>``, and - comparison is performed by `distutils.version.LooseVersion - `__. + The following operands are supported: + + * ``~=``: Compatible release clause + * ``==``: Version matching clause + * ``!=``: Version exclusion clause + * ``<=``, ``>=``: Inclusive ordered comparison clause + * ``<``, ``>``: Exclusive ordered comparison clause + * ``===``: Arbitrary equality clause. + + ``pyversion`` option is followed `PEP-440: Version Specifiers + `__. .. versionadded:: 1.6 + .. versionchanged:: 1.7 + + Supported PEP-440 operands and notations + Note that like with standard doctests, you have to use ```` to signal a blank line in the expected output. The ```` is removed when building presentation output (HTML, LaTeX etc.). diff --git a/setup.py b/setup.py index 6b7de9129..f35e5f88d 100644 --- a/setup.py +++ b/setup.py @@ -26,6 +26,7 @@ requires = [ 'imagesize', 'requests>=2.0.0', 'setuptools', + 'packaging', 'sphinxcontrib-websupport', ] diff --git a/sphinx/ext/doctest.py b/sphinx/ext/doctest.py index e0ce050f7..948ddfec8 100644 --- a/sphinx/ext/doctest.py +++ b/sphinx/ext/doctest.py @@ -20,7 +20,8 @@ from os import path import doctest from six import itervalues, StringIO, binary_type, text_type, PY2 -from distutils.version import LooseVersion +from packaging.specifiers import SpecifierSet, InvalidSpecifier +from packaging.version import Version from docutils import nodes from docutils.parsers.rst import Directive, directives @@ -57,28 +58,23 @@ else: return text -def compare_version(ver1, ver2, operand): - # type: (unicode, unicode, unicode) -> bool - """Compare `ver1` to `ver2`, relying on `operand`. +def is_allowed_version(spec, version): + # type: (unicode, unicode) -> bool + """Check `spec` satisfies `version` or not. + + This obeys PEP-440 specifiers: + https://www.python.org/dev/peps/pep-0440/#version-specifiers Some examples: - >>> compare_version('3.3', '3.5', '<=') + >>> is_allowed_version('3.3', '<=3.5') True - >>> compare_version('3.3', '3.2', '<=') + >>> is_allowed_version('3.3', '<=3.2') False - >>> compare_version('3.3a0', '3.3', '<=') + >>> is_allowed_version('3.3', '>3.2, <4.0') True """ - if operand not in ('<=', '<', '==', '>=', '>'): - raise ValueError("'%s' is not a valid operand.") - v1 = LooseVersion(ver1) - v2 = LooseVersion(ver2) - return ((operand == '<=' and (v1 <= v2)) or - (operand == '<' and (v1 < v2)) or - (operand == '==' and (v1 == v2)) or - (operand == '>=' and (v1 >= v2)) or - (operand == '>' and (v1 > v2))) + return Version(version) in SpecifierSet(spec) # set up the necessary directives @@ -143,16 +139,13 @@ class TestDirective(Directive): node['options'][flag] = (option[0] == '+') if self.name == 'doctest' and 'pyversion' in self.options: try: - option = self.options['pyversion'] - # :pyversion: >= 3.6 --> operand='>=', option_version='3.6' - operand, option_version = [item.strip() for item in option.split()] - running_version = platform.python_version() - if not compare_version(running_version, option_version, operand): + spec = self.options['pyversion'] + if not is_allowed_version(spec, platform.python_version()): flag = doctest.OPTIONFLAGS_BY_NAME['SKIP'] node['options'][flag] = True # Skip the test - except ValueError: + except InvalidSpecifier: self.state.document.reporter.warning( - _("'%s' is not a valid pyversion option") % option, + _("'%s' is not a valid pyversion option") % spec, line=self.lineno) return [node] diff --git a/tests/test_ext_doctest.py b/tests/test_ext_doctest.py index 020357879..7d907d086 100644 --- a/tests/test_ext_doctest.py +++ b/tests/test_ext_doctest.py @@ -9,7 +9,9 @@ :license: BSD, see LICENSE for details. """ import pytest -from sphinx.ext.doctest import compare_version +from sphinx.ext.doctest import is_allowed_version +from packaging.version import InvalidVersion +from packaging.specifiers import InvalidSpecifier cleanup_called = 0 @@ -26,19 +28,28 @@ def test_build(app, status, warning): assert cleanup_called == 3, 'testcleanup did not get executed enough times' -def test_compare_version(): - assert compare_version('3.3', '3.4', '<') is True - assert compare_version('3.3', '3.2', '<') is False - assert compare_version('3.3', '3.4', '<=') is True - assert compare_version('3.3', '3.2', '<=') is False - assert compare_version('3.3', '3.3', '==') is True - assert compare_version('3.3', '3.4', '==') is False - assert compare_version('3.3', '3.2', '>=') is True - assert compare_version('3.3', '3.4', '>=') is False - assert compare_version('3.3', '3.2', '>') is True - assert compare_version('3.3', '3.4', '>') is False - with pytest.raises(ValueError): - compare_version('3.3', '3.4', '+') +def test_is_allowed_version(): + assert is_allowed_version('<3.4', '3.3') is True + assert is_allowed_version('<3.4', '3.3') is True + assert is_allowed_version('<3.2', '3.3') is False + assert is_allowed_version('<=3.4', '3.3') is True + assert is_allowed_version('<=3.2', '3.3') is False + assert is_allowed_version('==3.3', '3.3') is True + assert is_allowed_version('==3.4', '3.3') is False + assert is_allowed_version('>=3.2', '3.3') is True + assert is_allowed_version('>=3.4', '3.3') is False + assert is_allowed_version('>3.2', '3.3') is True + assert is_allowed_version('>3.4', '3.3') is False + assert is_allowed_version('~=3.4', '3.4.5') is True + assert is_allowed_version('~=3.4', '3.5.0') is True + + # invalid spec + with pytest.raises(InvalidSpecifier): + is_allowed_version('&3.4', '3.5') + + # invalid version + with pytest.raises(InvalidVersion): + is_allowed_version('>3.4', 'Sphinx') def cleanup_call(): From 90f7c7ef3fd18b5ceff5eef1361f5f71f68209ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20Beaupr=C3=A9?= Date: Thu, 11 Jan 2018 13:20:26 -0500 Subject: [PATCH 2/3] add link to manpages in HTML builder It is useful to have the HTML documentation builder actually link to real rendered versions of HTML manpages in its output. That way people can click on manpages to get the full documentation. There are a few services offering this online, so we do not explicitly enable one by default, but the Debian manpages repository has a lot of the manpages pre-rendered, so it is used as an example in the documentation. The parsing work is done by a transformer class that parses manpage objects and extract name/section elements. Those then can be used by writers to cross-reference to actual sites. An implementation is done in the two HTML writers, but could also apply to ePUB/PDF writers as well in the future. This is not enabled by default: the `manpages_url` configuration item needs to be enabled to point to the chosen site. The `page`, `section` and `path` parameters are expanded through Python string formatting in the URL on output. Unit tests are fairly limited, but should cover most common use-cases. --- doc/config.rst | 18 ++++++++++++++++++ doc/markup/inline.rst | 3 ++- sphinx/config.py | 1 + sphinx/io.py | 6 +++--- sphinx/transforms/__init__.py | 20 ++++++++++++++++++++ sphinx/writers/html.py | 6 ++++++ sphinx/writers/html5.py | 6 ++++++ tests/roots/test-manpage_url/conf.py | 5 +++++ tests/roots/test-manpage_url/index.rst | 3 +++ tests/test_build_html.py | 13 +++++++++++++ 10 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 tests/roots/test-manpage_url/conf.py create mode 100644 tests/roots/test-manpage_url/index.rst diff --git a/doc/config.rst b/doc/config.rst index 9bdd283a9..587d5785b 100644 --- a/doc/config.rst +++ b/doc/config.rst @@ -293,6 +293,24 @@ General configuration .. versionadded:: 1.3 +.. confval:: manpages_url + + A URL to cross-reference :rst:role:`manpage` directives. If this is + defined to ``https://manpages.debian.org/{path}``, the + :literal:`:manpage:`man(1)`` role will like to + . The patterns available are: + + * ``page`` - the manual page (``man``) + * ``section`` - the manual section (``1``) + * ``path`` - the original manual page and section specified (``man(1)``) + + This also supports manpages specified as ``man.1``. + + .. note:: This currently affects only HTML writers but could be + expanded in the future. + + .. versionadded:: 1.7 + .. confval:: nitpicky If true, Sphinx will warn about *all* references where the target cannot be diff --git a/doc/markup/inline.rst b/doc/markup/inline.rst index 4d14a653d..c8dfb6ff7 100644 --- a/doc/markup/inline.rst +++ b/doc/markup/inline.rst @@ -355,7 +355,8 @@ in a different style: .. rst:role:: manpage A reference to a Unix manual page including the section, - e.g. ``:manpage:`ls(1)```. + e.g. ``:manpage:`ls(1)```. Creates a hyperlink to an external site + rendering the manpage if :confval:`manpages_url` is defined. .. rst:role:: menuselection diff --git a/sphinx/config.py b/sphinx/config.py index c6bf1cc3c..1b3f51a6e 100644 --- a/sphinx/config.py +++ b/sphinx/config.py @@ -125,6 +125,7 @@ class Config(object): primary_domain = ('py', 'env', [NoneType]), needs_sphinx = (None, None, string_classes), needs_extensions = ({}, None), + manpages_url = (None, 'env'), nitpicky = (False, None), nitpick_ignore = ([], None), numfig = (False, 'env'), diff --git a/sphinx/io.py b/sphinx/io.py index 66ba8334e..3c32c167c 100644 --- a/sphinx/io.py +++ b/sphinx/io.py @@ -23,7 +23,7 @@ from sphinx.transforms import ( ApplySourceWorkaround, ExtraTranslatableNodes, CitationReferences, DefaultSubstitutions, MoveModuleTargets, HandleCodeBlocks, SortIds, AutoNumbering, AutoIndexUpgrader, FilterSystemMessages, - UnreferencedFootnotesDetector, SphinxSmartQuotes + UnreferencedFootnotesDetector, SphinxSmartQuotes, ManpageLink ) from sphinx.transforms.compact_bullet_list import RefOnlyBulletListTransform from sphinx.transforms.i18n import ( @@ -80,7 +80,7 @@ class SphinxStandaloneReader(SphinxBaseReader): Locale, CitationReferences, DefaultSubstitutions, MoveModuleTargets, HandleCodeBlocks, AutoNumbering, AutoIndexUpgrader, SortIds, RemoveTranslatableInline, PreserveTranslatableMessages, FilterSystemMessages, - RefOnlyBulletListTransform, UnreferencedFootnotesDetector + RefOnlyBulletListTransform, UnreferencedFootnotesDetector, ManpageLink ] # type: List[Transform] def __init__(self, app, *args, **kwargs): @@ -110,7 +110,7 @@ class SphinxI18nReader(SphinxBaseReader): DefaultSubstitutions, MoveModuleTargets, HandleCodeBlocks, AutoNumbering, SortIds, RemoveTranslatableInline, FilterSystemMessages, RefOnlyBulletListTransform, - UnreferencedFootnotesDetector] + UnreferencedFootnotesDetector, ManpageLink] def set_lineno_for_reporter(self, lineno): # type: (int) -> None diff --git a/sphinx/transforms/__init__.py b/sphinx/transforms/__init__.py index acfff6a4d..ceb8de364 100644 --- a/sphinx/transforms/__init__.py +++ b/sphinx/transforms/__init__.py @@ -9,6 +9,8 @@ :license: BSD, see LICENSE for details. """ +import re + from docutils import nodes from docutils.transforms import Transform, Transformer from docutils.transforms.parts import ContentsFilter @@ -348,3 +350,21 @@ class SphinxSmartQuotes(SmartQuotes): for txtnode in txtnodes: notsmartquotable = not is_smartquotable(txtnode) yield (texttype[notsmartquotable], txtnode.astext()) + + +class ManpageLink(SphinxTransform): + """Find manpage section numbers and names""" + default_priority = 999 + + def apply(self): + for node in self.document.traverse(addnodes.manpage): + manpage = ' '.join([str(x) for x in node.children + if isinstance(x, nodes.Text)]) + pattern = r'^(?P(?P.+)[\(\.](?P
[1-9]\w*)?\)?)$' # noqa + info = {'path': manpage, + 'page': manpage, + 'section': ''} + r = re.match(pattern, manpage) + if r: + info = r.groupdict() + node.attributes.update(info) diff --git a/sphinx/writers/html.py b/sphinx/writers/html.py index b3d27e31a..84e7bfbc9 100644 --- a/sphinx/writers/html.py +++ b/sphinx/writers/html.py @@ -79,6 +79,7 @@ class HTMLTranslator(BaseTranslator): self.highlightopts = builder.config.highlight_options self.highlightlinenothreshold = sys.maxsize self.docnames = [builder.current_docname] # for singlehtml builder + self.manpages_url = builder.config.manpages_url self.protect_literal_text = 0 self.permalink_text = builder.config.html_add_permalinks # support backwards-compatible setting to a bool @@ -816,9 +817,14 @@ class HTMLTranslator(BaseTranslator): def visit_manpage(self, node): # type: (nodes.Node) -> None self.visit_literal_emphasis(node) + if self.manpages_url: + node['refuri'] = self.manpages_url.format(**node.attributes) + self.visit_reference(node) def depart_manpage(self, node): # type: (nodes.Node) -> None + if self.manpages_url: + self.depart_reference(node) self.depart_literal_emphasis(node) # overwritten to add even/odd classes diff --git a/sphinx/writers/html5.py b/sphinx/writers/html5.py index a47fee77e..50bf2ea8c 100644 --- a/sphinx/writers/html5.py +++ b/sphinx/writers/html5.py @@ -49,6 +49,7 @@ class HTML5Translator(BaseTranslator): self.highlightopts = builder.config.highlight_options self.highlightlinenothreshold = sys.maxsize self.docnames = [builder.current_docname] # for singlehtml builder + self.manpages_url = builder.config.manpages_url self.protect_literal_text = 0 self.permalink_text = builder.config.html_add_permalinks # support backwards-compatible setting to a bool @@ -758,9 +759,14 @@ class HTML5Translator(BaseTranslator): def visit_manpage(self, node): # type: (nodes.Node) -> None self.visit_literal_emphasis(node) + if self.manpages_url: + node['refuri'] = self.manpages_url.format(**dict(node)) + self.visit_reference(node) def depart_manpage(self, node): # type: (nodes.Node) -> None + if self.manpages_url: + self.depart_reference(node) self.depart_literal_emphasis(node) # overwritten to add even/odd classes diff --git a/tests/roots/test-manpage_url/conf.py b/tests/roots/test-manpage_url/conf.py new file mode 100644 index 000000000..c46e40773 --- /dev/null +++ b/tests/roots/test-manpage_url/conf.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- + +master_doc = 'index' +html_theme = 'classic' +exclude_patterns = ['_build'] diff --git a/tests/roots/test-manpage_url/index.rst b/tests/roots/test-manpage_url/index.rst new file mode 100644 index 000000000..50d3b042e --- /dev/null +++ b/tests/roots/test-manpage_url/index.rst @@ -0,0 +1,3 @@ + * :manpage:`man(1)` + * :manpage:`ls.1` + * :manpage:`sphinx` diff --git a/tests/test_build_html.py b/tests/test_build_html.py index 8265c8471..153ff5165 100644 --- a/tests/test_build_html.py +++ b/tests/test_build_html.py @@ -1243,3 +1243,16 @@ def test_html_sidebar(app, status, warning): assert '

Related Topics

' not in result assert '

This Page

' not in result assert '

Quick search

' not in result + + +@pytest.mark.parametrize('fname,expect', flat_dict({ + 'index.html': [(".//em/a[@href='https://example.com/man.1']", "", True), + (".//em/a[@href='https://example.com/ls.1']", "", True), + (".//em/a[@href='https://example.com/sphinx.']", "", True)] + })) +@pytest.mark.sphinx('html', testroot='manpage_url', confoverrides={ + 'manpages_url': 'https://example.com/{page}.{section}'}) +@pytest.mark.test_params(shared_result='test_build_html_manpage_url') +def test_html_manpage(app, cached_etree_parse, fname, expect): + app.build() + check_xpath(cached_etree_parse(app.outdir / fname), fname, *expect) From 2324dee1432b757abe106851484356e4703d4005 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Sat, 13 Jan 2018 14:29:54 +0900 Subject: [PATCH 3/3] Update CHANGES for PR #4235 --- CHANGES | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES b/CHANGES index 19c56f35c..9a435bfd4 100644 --- a/CHANGES +++ b/CHANGES @@ -73,6 +73,7 @@ Features added * Add ``top-classes`` option for the ``sphinx.ext.inheritance_diagram`` extension to limit the scope of inheritance graphs. * #4183: doctest: ``:pyversion:`` option also follows PEP-440 specification +* #4235: html: Add :confval:`manpages_url` to make manpage roles to hyperlinks Features removed ----------------