From fbed1cbbde47a777732dcc394ed93ead4de7fefd Mon Sep 17 00:00:00 2001 From: Arthur Volant Date: Mon, 19 Apr 2021 12:02:38 +0200 Subject: [PATCH 01/18] Remove `div.doctest` * Remove `div.doctest` so that the selecting feature in snippets code are not just available for doctests snippets --- sphinx/themes/basic/static/basic.css_t | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx/themes/basic/static/basic.css_t b/sphinx/themes/basic/static/basic.css_t index db43499ad..45815bac0 100644 --- a/sphinx/themes/basic/static/basic.css_t +++ b/sphinx/themes/basic/static/basic.css_t @@ -819,7 +819,7 @@ div.code-block-caption code { table.highlighttable td.linenos, span.linenos, -div.doctest > div.highlight span.gp { /* gp: Generic.Prompt */ +div.highlight span.gp { /* gp: Generic.Prompt */ user-select: none; -webkit-user-select: text; /* Safari fallback only */ -webkit-user-select: none; /* Chrome/Safari */ From 1978c4a0cb59acc1980f8bf91abcec581b23c5e5 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Thu, 22 Apr 2021 01:38:31 +0900 Subject: [PATCH 02/18] Support docutils-0.17 --- .github/workflows/main.yml | 2 +- CHANGES | 4 ++++ setup.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f49117e1a..f32468576 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -21,7 +21,7 @@ jobs: docutils: du16 - name: py39 python: 3.9 - docutils: du16 + docutils: du17 coverage: "--cov ./ --cov-append --cov-config setup.cfg" # - name: py310-dev # python: 3.10-dev diff --git a/CHANGES b/CHANGES index 5edc80e38..936280abb 100644 --- a/CHANGES +++ b/CHANGES @@ -4,6 +4,10 @@ Release 4.0.0 beta2 (in development) Dependencies ------------ +* Support docutils-0.17. Please notice it changes the output of HTML builder. + Some themes do not support it, and you need to update your custom CSS to + upgrade it. + Incompatible changes -------------------- diff --git a/setup.py b/setup.py index 1c14ff1eb..b669afc00 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ install_requires = [ 'sphinxcontrib-qthelp', 'Jinja2>=2.3', 'Pygments>=2.0', - 'docutils>=0.14,<0.17', + 'docutils>=0.14,<0.18', 'snowballstemmer>=1.1', 'babel>=1.3', 'alabaster>=0.7,<0.8', From 4582d5a396d378abc2de0c83a7ed6028678eeebf Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Sat, 27 Feb 2021 18:45:07 +0900 Subject: [PATCH 03/18] refactor: Add testcase for mathjax3_config (refs: #9094) --- sphinx/builders/html/__init__.py | 6 +++++- tests/test_ext_math.py | 16 ++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py index a78d54a16..87b9c5c45 100644 --- a/sphinx/builders/html/__init__.py +++ b/sphinx/builders/html/__init__.py @@ -1166,7 +1166,11 @@ def setup_js_tag_helper(app: Sphinx, pagename: str, templatename: str, else: # str value (old styled) attrs.append('src="%s"' % pathto(js, resource=True)) - return '' % (' '.join(attrs), body) + + if attrs: + return '' % (' '.join(attrs), body) + else: + return '' % body context['js_tag'] = js_tag diff --git a/tests/test_ext_math.py b/tests/test_ext_math.py index bd124c8c6..ebe2c0f38 100644 --- a/tests/test_ext_math.py +++ b/tests/test_ext_math.py @@ -215,11 +215,23 @@ def test_math_compat(app, status, warning): @pytest.mark.sphinx('html', testroot='ext-math', confoverrides={'extensions': ['sphinx.ext.mathjax'], - 'mathjax_config': {'extensions': ['tex2jax.js']}}) -def test_mathjax_config(app, status, warning): + 'mathjax3_config': {'extensions': ['tex2jax.js']}}) +def test_mathjax3_config(app, status, warning): app.builder.build_all() content = (app.outdir / 'index.html').read_text() + assert MATHJAX_URL in content + assert ('' in content) + + +@pytest.mark.sphinx('html', testroot='ext-math', + confoverrides={'extensions': ['sphinx.ext.mathjax'], + 'mathjax2_config': {'extensions': ['tex2jax.js']}}) +def test_mathjax2_config(app, status, warning): + app.builder.build_all() + + content = (app.outdir / 'index.html').read_text() + assert MATHJAX_URL in content assert ('' in content) From d8fa0675834cf2e8066d3068a1a1c90d9b5db01d Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Wed, 28 Apr 2021 02:16:51 +0900 Subject: [PATCH 04/18] Add doc and testcase for program directive (refs: #9137) There is no docs and testcases for "None" argument of the program directive. It has been implemented since very old version. But it's not documented and tested long. --- doc/usage/restructuredtext/domains.rst | 3 +++ tests/test_domain_std.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/doc/usage/restructuredtext/domains.rst b/doc/usage/restructuredtext/domains.rst index 65a32b6c8..dfd347327 100644 --- a/doc/usage/restructuredtext/domains.rst +++ b/doc/usage/restructuredtext/domains.rst @@ -1680,6 +1680,9 @@ There is a set of directives allowing documenting command-line programs: then ``:option:`rm -r``` would refer to the first option, while ``:option:`svn -r``` would refer to the second one. + If ``None`` is passed to the argument, the directive will reset the + current program name. + The program name may contain spaces (in case you want to document subcommands like ``svn add`` and ``svn commit`` separately). diff --git a/tests/test_domain_std.py b/tests/test_domain_std.py index 9d9e27bd0..011c82f6a 100644 --- a/tests/test_domain_std.py +++ b/tests/test_domain_std.py @@ -324,6 +324,23 @@ def test_cmdoption(app): assert domain.progoptions[('ls', '-l')] == ('index', 'cmdoption-ls-l') +def test_cmdoption_for_None(app): + text = (".. program:: ls\n" + ".. program:: None\n" + "\n" + ".. option:: -l\n") + domain = app.env.get_domain('std') + doctree = restructuredtext.parse(app, text) + assert_node(doctree, (addnodes.index, + [desc, ([desc_signature, ([desc_name, "-l"], + [desc_addname, ()])], + [desc_content, ()])])) + assert_node(doctree[0], addnodes.index, + entries=[('pair', 'command line option; -l', 'cmdoption-l', '', None)]) + assert (None, '-l') in domain.progoptions + assert domain.progoptions[(None, '-l')] == ('index', 'cmdoption-l') + + def test_multiple_cmdoptions(app): text = (".. program:: cmd\n" "\n" From e8ab9ad2604aa9303a389b80df545972e5fd9824 Mon Sep 17 00:00:00 2001 From: Oz N Tiram Date: Tue, 27 Apr 2021 22:46:46 +0200 Subject: [PATCH 05/18] Add unicode multiplication Since checkmark already exists, it makes sense to have this too. Both signs are used as "yes" or "no" replacements in text UIs. --- sphinx/util/texescape.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sphinx/util/texescape.py b/sphinx/util/texescape.py index 417a963a7..8dcc08a9b 100644 --- a/sphinx/util/texescape.py +++ b/sphinx/util/texescape.py @@ -29,6 +29,8 @@ tex_replacements = [ # map special Unicode characters to TeX commands ('✓', r'\(\checkmark\)'), ('✔', r'\(\pmb{\checkmark}\)'), + ('✕', r'\(\times\)'), + ('✖', r'\(\pmb{\times}\)'), # used to separate -- in options ('', r'{}'), # map some special Unicode characters to similar ASCII ones From 07e84752c74c15297464c4db744137594894609f Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Thu, 29 Apr 2021 15:03:44 +0900 Subject: [PATCH 06/18] Bump to 4.0.0 beta2 --- CHANGES | 28 ++-------------------------- sphinx/__init__.py | 4 ++-- 2 files changed, 4 insertions(+), 28 deletions(-) diff --git a/CHANGES b/CHANGES index 16fda6295..9ab353796 100644 --- a/CHANGES +++ b/CHANGES @@ -1,5 +1,5 @@ -Release 4.0.0 beta2 (in development) -==================================== +Release 4.0.0 beta2 (released Apr 29, 2021) +=========================================== Dependencies ------------ @@ -14,9 +14,6 @@ Incompatible changes * #9023: Change the CSS classes on :rst:role:`cpp:expr` and :rst:role:`cpp:texpr`. -Deprecated ----------- - Features added -------------- @@ -39,9 +36,6 @@ Bugs fixed * C, C++, fix ``KeyError`` when an ``alias`` directive is the first C/C++ directive in a file with another C/C++ directive later. -Testing --------- - Release 4.0.0 beta1 (released Apr 12, 2021) =========================================== @@ -180,24 +174,6 @@ Bugs fixed Release 3.5.5 (in development) ============================== -Dependencies ------------- - -Incompatible changes --------------------- - -Deprecated ----------- - -Features added --------------- - -Bugs fixed ----------- - -Testing --------- - Release 3.5.4 (released Apr 11, 2021) ===================================== diff --git a/sphinx/__init__.py b/sphinx/__init__.py index 8f1bd963c..478579226 100644 --- a/sphinx/__init__.py +++ b/sphinx/__init__.py @@ -27,8 +27,8 @@ if 'PYTHONWARNINGS' not in os.environ: warnings.filterwarnings('ignore', "'U' mode is deprecated", DeprecationWarning, module='docutils.io') -__version__ = '4.0.0+' -__released__ = '4.0.0' # used when Sphinx builds its own docs +__version__ = '4.0.0b2' +__released__ = '4.0.0b2' # used when Sphinx builds its own docs #: Version info for better programmatic use. #: From 742a2e0c0a39f42d1264975d1ac33d2cce5eedf5 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Thu, 29 Apr 2021 15:04:53 +0900 Subject: [PATCH 07/18] Bump version --- CHANGES | 21 +++++++++++++++++++++ sphinx/__init__.py | 6 +++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/CHANGES b/CHANGES index 9ab353796..e710d1da2 100644 --- a/CHANGES +++ b/CHANGES @@ -1,3 +1,24 @@ +Release 4.0.0 beta3 (in development) +==================================== + +Dependencies +------------ + +Incompatible changes +-------------------- + +Deprecated +---------- + +Features added +-------------- + +Bugs fixed +---------- + +Testing +-------- + Release 4.0.0 beta2 (released Apr 29, 2021) =========================================== diff --git a/sphinx/__init__.py b/sphinx/__init__.py index 478579226..4e679bd21 100644 --- a/sphinx/__init__.py +++ b/sphinx/__init__.py @@ -27,8 +27,8 @@ if 'PYTHONWARNINGS' not in os.environ: warnings.filterwarnings('ignore', "'U' mode is deprecated", DeprecationWarning, module='docutils.io') -__version__ = '4.0.0b2' -__released__ = '4.0.0b2' # used when Sphinx builds its own docs +__version__ = '4.0.0+' +__released__ = '4.0.0' # used when Sphinx builds its own docs #: Version info for better programmatic use. #: @@ -38,7 +38,7 @@ __released__ = '4.0.0b2' # used when Sphinx builds its own docs #: #: .. versionadded:: 1.2 #: Before version 1.2, check the string ``sphinx.__version__``. -version_info = (4, 0, 0, 'beta', 2) +version_info = (4, 0, 0, 'beta', 3) package_dir = path.abspath(path.dirname(__file__)) From a7d3e9684d85eed31b5d3a0a5a2398dfc7d9aa27 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Thu, 29 Apr 2021 15:48:45 +0900 Subject: [PATCH 08/18] refactor: linkcheck: Use attributes of CheckResult in process_result() --- sphinx/builders/linkcheck.py | 66 ++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/sphinx/builders/linkcheck.py b/sphinx/builders/linkcheck.py index 05e12c173..6ece70825 100644 --- a/sphinx/builders/linkcheck.py +++ b/sphinx/builders/linkcheck.py @@ -129,7 +129,7 @@ class CheckExternalLinksBuilder(DummyBuilder): # create queues and worker threads self._wqueue: PriorityQueue[CheckRequestType] = PriorityQueue() - self._rqueue: Queue = Queue() + self._rqueue: Queue[CheckResult] = Queue() @property def anchors_ignore(self) -> List[Pattern]: @@ -228,43 +228,42 @@ class CheckExternalLinksBuilder(DummyBuilder): ) return self._wqueue - def process_result(self, result: Tuple[str, str, int, str, str, int]) -> None: - uri, docname, lineno, status, info, code = result - - filename = self.env.doc2path(docname, None) - linkstat = dict(filename=filename, lineno=lineno, - status=status, code=code, uri=uri, - info=info) - if status == 'unchecked': + def process_result(self, result: CheckResult) -> None: + filename = self.env.doc2path(result.docname, None) + linkstat = dict(filename=filename, lineno=result.lineno, + status=result.status, code=result.code, uri=result.uri, + info=result.message) + if result.status == 'unchecked': self.write_linkstat(linkstat) return - if status == 'working' and info == 'old': + if result.status == 'working' and result.message == 'old': self.write_linkstat(linkstat) return - if lineno: - logger.info('(%16s: line %4d) ', docname, lineno, nonl=True) - if status == 'ignored': - if info: - logger.info(darkgray('-ignored- ') + uri + ': ' + info) + if result.lineno: + logger.info('(%16s: line %4d) ', result.docname, result.lineno, nonl=True) + if result.status == 'ignored': + if result.message: + logger.info(darkgray('-ignored- ') + result.uri + ': ' + result.message) else: - logger.info(darkgray('-ignored- ') + uri) + logger.info(darkgray('-ignored- ') + result.uri) self.write_linkstat(linkstat) - elif status == 'local': - logger.info(darkgray('-local- ') + uri) - self.write_entry('local', docname, filename, lineno, uri) + elif result.status == 'local': + logger.info(darkgray('-local- ') + result.uri) + self.write_entry('local', result.docname, filename, result.lineno, result.uri) self.write_linkstat(linkstat) - elif status == 'working': - logger.info(darkgreen('ok ') + uri + info) + elif result.status == 'working': + logger.info(darkgreen('ok ') + result.uri + result.message) self.write_linkstat(linkstat) - elif status == 'broken': + elif result.status == 'broken': if self.app.quiet or self.app.warningiserror: - logger.warning(__('broken link: %s (%s)'), uri, info, - location=(filename, lineno)) + logger.warning(__('broken link: %s (%s)'), result.uri, result.message, + location=(filename, result.lineno)) else: - logger.info(red('broken ') + uri + red(' - ' + info)) - self.write_entry('broken', docname, filename, lineno, uri + ': ' + info) + logger.info(red('broken ') + result.uri + red(' - ' + result.message)) + self.write_entry('broken', result.docname, filename, result.lineno, + result.uri + ': ' + result.message) self.write_linkstat(linkstat) - elif status == 'redirected': + elif result.status == 'redirected': try: text, color = { 301: ('permanently', purple), @@ -272,16 +271,17 @@ class CheckExternalLinksBuilder(DummyBuilder): 303: ('with See Other', purple), 307: ('temporarily', turquoise), 308: ('permanently', purple), - }[code] + }[result.code] except KeyError: text, color = ('with unknown code', purple) linkstat['text'] = text - logger.info(color('redirect ') + uri + color(' - ' + text + ' to ' + info)) - self.write_entry('redirected ' + text, docname, filename, - lineno, uri + ' to ' + info) + logger.info(color('redirect ') + result.uri + + color(' - ' + text + ' to ' + result.message)) + self.write_entry('redirected ' + text, result.docname, filename, + result.lineno, result.uri + ' to ' + result.message) self.write_linkstat(linkstat) else: - raise ValueError("Unknown status %s." % status) + raise ValueError("Unknown status %s." % result.status) def write_entry(self, what: str, docname: str, filename: str, line: int, uri: str) -> None: @@ -576,7 +576,7 @@ class HyperlinkAvailabilityCheckWorker(Thread): if status == 'rate-limited': logger.info(darkgray('-rate limited- ') + uri + darkgray(' | sleeping...')) else: - self.rqueue.put((uri, docname, lineno, status, info, code)) + self.rqueue.put(CheckResult(uri, docname, lineno, status, info, code)) self.wqueue.task_done() def limit_rate(self, response: Response) -> Optional[float]: From 29038c9d4ca95af969ed646db6b18eda8d2cbcf1 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Thu, 29 Apr 2021 18:19:36 +0900 Subject: [PATCH 09/18] refactor: linkcheck: Call write_linkstat() at the top of process_result() --- sphinx/builders/linkcheck.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/sphinx/builders/linkcheck.py b/sphinx/builders/linkcheck.py index 6ece70825..a46b80c08 100644 --- a/sphinx/builders/linkcheck.py +++ b/sphinx/builders/linkcheck.py @@ -230,14 +230,15 @@ class CheckExternalLinksBuilder(DummyBuilder): def process_result(self, result: CheckResult) -> None: filename = self.env.doc2path(result.docname, None) + linkstat = dict(filename=filename, lineno=result.lineno, status=result.status, code=result.code, uri=result.uri, info=result.message) + self.write_linkstat(linkstat) + if result.status == 'unchecked': - self.write_linkstat(linkstat) return if result.status == 'working' and result.message == 'old': - self.write_linkstat(linkstat) return if result.lineno: logger.info('(%16s: line %4d) ', result.docname, result.lineno, nonl=True) @@ -246,14 +247,11 @@ class CheckExternalLinksBuilder(DummyBuilder): logger.info(darkgray('-ignored- ') + result.uri + ': ' + result.message) else: logger.info(darkgray('-ignored- ') + result.uri) - self.write_linkstat(linkstat) elif result.status == 'local': logger.info(darkgray('-local- ') + result.uri) self.write_entry('local', result.docname, filename, result.lineno, result.uri) - self.write_linkstat(linkstat) elif result.status == 'working': logger.info(darkgreen('ok ') + result.uri + result.message) - self.write_linkstat(linkstat) elif result.status == 'broken': if self.app.quiet or self.app.warningiserror: logger.warning(__('broken link: %s (%s)'), result.uri, result.message, @@ -262,7 +260,6 @@ class CheckExternalLinksBuilder(DummyBuilder): logger.info(red('broken ') + result.uri + red(' - ' + result.message)) self.write_entry('broken', result.docname, filename, result.lineno, result.uri + ': ' + result.message) - self.write_linkstat(linkstat) elif result.status == 'redirected': try: text, color = { @@ -279,7 +276,6 @@ class CheckExternalLinksBuilder(DummyBuilder): color(' - ' + text + ' to ' + result.message)) self.write_entry('redirected ' + text, result.docname, filename, result.lineno, result.uri + ' to ' + result.message) - self.write_linkstat(linkstat) else: raise ValueError("Unknown status %s." % result.status) From 6e90d8a3abb81a93bb19640d8060718c78408d94 Mon Sep 17 00:00:00 2001 From: Sviatoslav Sydorenko Date: Thu, 29 Apr 2021 23:50:25 +0200 Subject: [PATCH 10/18] Mention that aiohttp uses its own custom theme --- EXAMPLES | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EXAMPLES b/EXAMPLES index 040637e96..598f41b4f 100644 --- a/EXAMPLES +++ b/EXAMPLES @@ -12,7 +12,6 @@ interesting examples. Documentation using the alabaster theme --------------------------------------- -* `AIOHTTP `__ * `Alabaster `__ * `Blinker `__ * `Calibre `__ @@ -311,6 +310,7 @@ Documentation using sphinx_bootstrap_theme Documentation using a custom theme or integrated in a website ------------------------------------------------------------- +* `AIOHTTP `__ * `Apache Cassandra `__ * `Astropy `__ * `Bokeh `__ From d8a9f243e29ebb818d739a054c8b3afc6c81cd88 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Sat, 1 May 2021 14:50:46 +0900 Subject: [PATCH 11/18] Close #8107: autodoc: Add class-doc-from option to autoclass directive Add `class-doc-from` option to the `autoclass` directive to control the content of the specific class. It takes `class`, `init`, and `both` like `autoclass_content`. --- CHANGES | 3 ++ doc/usage/extensions/autodoc.rst | 9 +++++- sphinx/ext/autodoc/__init__.py | 15 +++++++-- sphinx/ext/autodoc/directive.py | 2 +- tests/test_ext_autodoc_autoclass.py | 47 +++++++++++++++++++++++++++++ 5 files changed, 71 insertions(+), 5 deletions(-) diff --git a/CHANGES b/CHANGES index 3a5750418..6be49a67b 100644 --- a/CHANGES +++ b/CHANGES @@ -13,6 +13,9 @@ Deprecated Features added -------------- +* #8107: autodoc: Add ``class-doc-from`` option to :rst:dir:`autoclass` + directive to control the content of the specific class like + :confval:`autoclass_content` * #9129: html search: Show search summaries when html_copy_source = False * #9097: Optimize the paralell build diff --git a/doc/usage/extensions/autodoc.rst b/doc/usage/extensions/autodoc.rst index da0ff7c99..13a2b3010 100644 --- a/doc/usage/extensions/autodoc.rst +++ b/doc/usage/extensions/autodoc.rst @@ -343,6 +343,10 @@ inserting them into the page source under a suitable :rst:dir:`py:module`, .. autoclass:: module.name::Noodle + * :rst:dir:`autoclass` also recognizes the ``class-doc-from`` option that + can be used to override the global value of :confval:`autoclass_content`. + + .. versionadded:: 4.1 .. rst:directive:: autofunction autodecorator @@ -507,7 +511,7 @@ There are also config values that you can set: The supported options are ``'members'``, ``'member-order'``, ``'undoc-members'``, ``'private-members'``, ``'special-members'``, ``'inherited-members'``, ``'show-inheritance'``, ``'ignore-module-all'``, - ``'imported-members'`` and ``'exclude-members'``. + ``'imported-members'``, ``'exclude-members'`` and ``'class-doc-from'``. .. versionadded:: 1.8 @@ -517,6 +521,9 @@ There are also config values that you can set: .. versionchanged:: 2.1 Added ``'imported-members'``. + .. versionchanged:: 4.1 + Added ``'class-doc-from'``. + .. confval:: autodoc_docstring_signature Functions imported from C modules cannot be introspected, and therefore the diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index c92709deb..6ccfd9c7f 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -129,6 +129,14 @@ def member_order_option(arg: Any) -> Optional[str]: raise ValueError(__('invalid value for member-order option: %s') % arg) +def class_doc_from_option(arg: Any) -> Optional[str]: + """Used to convert the :class-doc-from: option to autoclass directives.""" + if arg in ('both', 'class', 'init'): + return arg + else: + raise ValueError(__('invalid value for class-doc-from option: %s') % arg) + + SUPPRESS = object() @@ -1417,6 +1425,7 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type: 'show-inheritance': bool_option, 'member-order': member_order_option, 'exclude-members': exclude_members_option, 'private-members': members_option, 'special-members': members_option, + 'class-doc-from': class_doc_from_option, } _signature_class: Any = None @@ -1651,7 +1660,7 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type: if lines is not None: return lines - content = self.config.autoclass_content + classdoc_from = self.options.get('class-doc-from', self.config.autoclass_content) docstrings = [] attrdocstring = self.get_attr(self.object, '__doc__', None) @@ -1660,7 +1669,7 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type: # for classes, what the "docstring" is can be controlled via a # config value; the default is only the class docstring - if content in ('both', 'init'): + if classdoc_from in ('both', 'init'): __init__ = self.get_attr(self.object, '__init__', None) initdocstring = getdoc(__init__, self.get_attr, self.config.autodoc_inherit_docstrings, @@ -1682,7 +1691,7 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type: initdocstring.strip() == object.__new__.__doc__)): # for !pypy initdocstring = None if initdocstring: - if content == 'init': + if classdoc_from == 'init': docstrings = [initdocstring] else: docstrings.append(initdocstring) diff --git a/sphinx/ext/autodoc/directive.py b/sphinx/ext/autodoc/directive.py index c58d0c411..a554adf68 100644 --- a/sphinx/ext/autodoc/directive.py +++ b/sphinx/ext/autodoc/directive.py @@ -30,7 +30,7 @@ logger = logging.getLogger(__name__) AUTODOC_DEFAULT_OPTIONS = ['members', 'undoc-members', 'inherited-members', 'show-inheritance', 'private-members', 'special-members', 'ignore-module-all', 'exclude-members', 'member-order', - 'imported-members'] + 'imported-members', 'class-doc-from'] AUTODOC_EXTENDABLE_OPTIONS = ['members', 'private-members', 'special-members', 'exclude-members'] diff --git a/tests/test_ext_autodoc_autoclass.py b/tests/test_ext_autodoc_autoclass.py index d879f8e14..096dc9397 100644 --- a/tests/test_ext_autodoc_autoclass.py +++ b/tests/test_ext_autodoc_autoclass.py @@ -264,6 +264,53 @@ def test_show_inheritance_for_subclass_of_generic_type(app): ] +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_class_doc_from_class(app): + options = {"members": None, + "class-doc-from": "class"} + actual = do_autodoc(app, 'class', 'target.autoclass_content.C', options) + assert list(actual) == [ + '', + '.. py:class:: C()', + ' :module: target.autoclass_content', + '', + ' A class having __init__, no __new__', + '', + ] + + +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_class_doc_from_init(app): + options = {"members": None, + "class-doc-from": "init"} + actual = do_autodoc(app, 'class', 'target.autoclass_content.C', options) + assert list(actual) == [ + '', + '.. py:class:: C()', + ' :module: target.autoclass_content', + '', + ' __init__ docstring', + '', + ] + + +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_class_doc_from_both(app): + options = {"members": None, + "class-doc-from": "both"} + actual = do_autodoc(app, 'class', 'target.autoclass_content.C', options) + assert list(actual) == [ + '', + '.. py:class:: C()', + ' :module: target.autoclass_content', + '', + ' A class having __init__, no __new__', + '', + ' __init__ docstring', + '', + ] + + def test_class_alias(app): def autodoc_process_docstring(*args): """A handler always raises an error. From 30237c004d3faebbcddbf12fc319b9c02e593b65 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Sun, 2 May 2021 18:32:02 +0900 Subject: [PATCH 12/18] Update CHANGES for PR #9120 --- CHANGES | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES b/CHANGES index 3a5750418..7028c9e68 100644 --- a/CHANGES +++ b/CHANGES @@ -14,6 +14,8 @@ Features added -------------- * #9129: html search: Show search summaries when html_copy_source = False +* #9120: html theme: Eliminate prompt characters of code-block from copyable + text * #9097: Optimize the paralell build From 469def56b64e0a4b09c892dc4eaa0f0565841789 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Sun, 2 May 2021 22:44:44 +0900 Subject: [PATCH 13/18] Fix #8597: autodoc: metadata only docstring is treated as undocumented The metadata in docstring is invisible content. Therefore docstring having only metadata should be treated as undocumented. --- CHANGES | 5 +++ doc/extdev/deprecated.rst | 5 +++ sphinx/ext/autodoc/__init__.py | 8 ++-- sphinx/util/docstrings.py | 23 +++++++--- .../roots/test-ext-autodoc/target/metadata.py | 2 + tests/test_ext_autodoc.py | 28 ++++++++++++ tests/test_util_docstrings.py | 45 +++++++++++++------ 7 files changed, 93 insertions(+), 23 deletions(-) create mode 100644 tests/roots/test-ext-autodoc/target/metadata.py diff --git a/CHANGES b/CHANGES index 7028c9e68..d6b4135b9 100644 --- a/CHANGES +++ b/CHANGES @@ -10,6 +10,8 @@ Incompatible changes Deprecated ---------- +* ``sphinx.util.docstrings.extract_metadata()`` + Features added -------------- @@ -22,6 +24,9 @@ Features added Bugs fixed ---------- +* #8597: autodoc: a docsting having metadata only should be treated as + undocumented + Testing -------- diff --git a/doc/extdev/deprecated.rst b/doc/extdev/deprecated.rst index 9e17b9fb4..514a80541 100644 --- a/doc/extdev/deprecated.rst +++ b/doc/extdev/deprecated.rst @@ -22,6 +22,11 @@ The following is a list of deprecated interfaces. - (will be) Removed - Alternatives + * - ``sphinx.util.docstrings.extract_metadata()`` + - 4.1 + - 6.0 + - ``sphinx.util.docstrings.separate_metadata()`` + * - ``favicon`` variable in HTML templates - 4.0 - TBD diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index c92709deb..962ba2ad0 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -30,7 +30,7 @@ from sphinx.ext.autodoc.mock import ismock, mock, undecorate from sphinx.locale import _, __ from sphinx.pycode import ModuleAnalyzer, PycodeError from sphinx.util import inspect, logging -from sphinx.util.docstrings import extract_metadata, prepare_docstring +from sphinx.util.docstrings import prepare_docstring, separate_metadata from sphinx.util.inspect import (evaluate_signature, getdoc, object_description, safe_getattr, stringify_signature) from sphinx.util.typing import OptionSpec, get_type_hints, restify @@ -722,9 +722,9 @@ class Documenter: # hack for ClassDocumenter to inject docstring via ObjectMember doc = obj.docstring + doc, metadata = separate_metadata(doc) has_doc = bool(doc) - metadata = extract_metadata(doc) if 'private' in metadata: # consider a member private if docstring has "private" metadata isprivate = True @@ -1918,7 +1918,7 @@ class DataDocumenter(GenericAliasMixin, NewTypeMixin, TypeVarMixin, return True else: doc = self.get_doc() - metadata = extract_metadata('\n'.join(sum(doc, []))) + docstring, metadata = separate_metadata('\n'.join(sum(doc, []))) if 'hide-value' in metadata: return True @@ -2456,7 +2456,7 @@ class AttributeDocumenter(GenericAliasMixin, NewTypeMixin, SlotsMixin, # type: else: doc = self.get_doc() if doc: - metadata = extract_metadata('\n'.join(sum(doc, []))) + docstring, metadata = separate_metadata('\n'.join(sum(doc, []))) if 'hide-value' in metadata: return True diff --git a/sphinx/util/docstrings.py b/sphinx/util/docstrings.py index 46bb5b9b8..d81d7dd99 100644 --- a/sphinx/util/docstrings.py +++ b/sphinx/util/docstrings.py @@ -11,26 +11,28 @@ import re import sys import warnings -from typing import Dict, List +from typing import Dict, List, Tuple from docutils.parsers.rst.states import Body -from sphinx.deprecation import RemovedInSphinx50Warning +from sphinx.deprecation import RemovedInSphinx50Warning, RemovedInSphinx60Warning field_list_item_re = re.compile(Body.patterns['field_marker']) -def extract_metadata(s: str) -> Dict[str, str]: - """Extract metadata from docstring.""" +def separate_metadata(s: str) -> Tuple[str, Dict[str, str]]: + """Separate docstring into metadata and others.""" in_other_element = False metadata: Dict[str, str] = {} + lines = [] if not s: - return metadata + return s, metadata for line in prepare_docstring(s): if line.strip() == '': in_other_element = False + lines.append(line) else: matched = field_list_item_re.match(line) if matched and not in_other_element: @@ -38,9 +40,20 @@ def extract_metadata(s: str) -> Dict[str, str]: if field_name.startswith('meta '): name = field_name[5:].strip() metadata[name] = line[matched.end():].strip() + else: + lines.append(line) else: in_other_element = True + lines.append(line) + return '\n'.join(lines), metadata + + +def extract_metadata(s: str) -> Dict[str, str]: + warnings.warn("extract_metadata() is deprecated.", + RemovedInSphinx60Warning, stacklevel=2) + + docstring, metadata = separate_metadata(s) return metadata diff --git a/tests/roots/test-ext-autodoc/target/metadata.py b/tests/roots/test-ext-autodoc/target/metadata.py new file mode 100644 index 000000000..7a4488f67 --- /dev/null +++ b/tests/roots/test-ext-autodoc/target/metadata.py @@ -0,0 +1,2 @@ +def foo(): + """:meta metadata-only-docstring:""" diff --git a/tests/test_ext_autodoc.py b/tests/test_ext_autodoc.py index 2becf5de2..fcaafa2af 100644 --- a/tests/test_ext_autodoc.py +++ b/tests/test_ext_autodoc.py @@ -735,6 +735,34 @@ def test_autodoc_undoc_members(app): ] +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_autodoc_undoc_members_for_metadata_only(app): + # metadata only member is not displayed + options = {"members": None} + actual = do_autodoc(app, 'module', 'target.metadata', options) + assert list(actual) == [ + '', + '.. py:module:: target.metadata', + '', + ] + + # metadata only member is displayed when undoc-member given + options = {"members": None, + "undoc-members": None} + actual = do_autodoc(app, 'module', 'target.metadata', options) + assert list(actual) == [ + '', + '.. py:module:: target.metadata', + '', + '', + '.. py:function:: foo()', + ' :module: target.metadata', + '', + ' :meta metadata-only-docstring:', + '', + ] + + @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_autodoc_inherited_members(app): options = {"members": None, diff --git a/tests/test_util_docstrings.py b/tests/test_util_docstrings.py index 543feca2a..2d406b81c 100644 --- a/tests/test_util_docstrings.py +++ b/tests/test_util_docstrings.py @@ -8,31 +8,48 @@ :license: BSD, see LICENSE for details. """ -from sphinx.util.docstrings import extract_metadata, prepare_commentdoc, prepare_docstring +from sphinx.util.docstrings import prepare_commentdoc, prepare_docstring, separate_metadata -def test_extract_metadata(): - metadata = extract_metadata(":meta foo: bar\n" - ":meta baz:\n") +def test_separate_metadata(): + # metadata only + text = (":meta foo: bar\n" + ":meta baz:\n") + docstring, metadata = separate_metadata(text) + assert docstring == '' assert metadata == {'foo': 'bar', 'baz': ''} + # non metadata field list item + text = (":meta foo: bar\n" + ":param baz:\n") + docstring, metadata = separate_metadata(text) + assert docstring == ':param baz:\n' + assert metadata == {'foo': 'bar'} + # field_list like text following just after paragaph is not a field_list - metadata = extract_metadata("blah blah blah\n" - ":meta foo: bar\n" - ":meta baz:\n") + text = ("blah blah blah\n" + ":meta foo: bar\n" + ":meta baz:\n") + docstring, metadata = separate_metadata(text) + assert docstring == text assert metadata == {} # field_list like text following after blank line is a field_list - metadata = extract_metadata("blah blah blah\n" - "\n" - ":meta foo: bar\n" - ":meta baz:\n") + text = ("blah blah blah\n" + "\n" + ":meta foo: bar\n" + ":meta baz:\n") + docstring, metadata = separate_metadata(text) + assert docstring == "blah blah blah\n\n" assert metadata == {'foo': 'bar', 'baz': ''} # non field_list item breaks field_list - metadata = extract_metadata(":meta foo: bar\n" - "blah blah blah\n" - ":meta baz:\n") + text = (":meta foo: bar\n" + "blah blah blah\n" + ":meta baz:\n") + docstring, metadata = separate_metadata(text) + assert docstring == ("blah blah blah\n" + ":meta baz:\n") assert metadata == {'foo': 'bar'} From 7acfc7826f5ae0a250ef2641142433e37354f6a2 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Mon, 3 May 2021 13:56:18 +0900 Subject: [PATCH 14/18] Update dependency: jinja2 < 3.0 and MarkupSafe < 2.0 Jinja2 and MarkupSafe have a plan to major release in the near future. And it will introduce some changes for its APIs. To lessen the noise of the DeprecationWarnings, this pins the versions to current stable release. --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b669afc00..7ce37f9ea 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,8 @@ install_requires = [ 'sphinxcontrib-htmlhelp', 'sphinxcontrib-serializinghtml', 'sphinxcontrib-qthelp', - 'Jinja2>=2.3', + 'Jinja2>=2.3,<3.0', + 'MarkupSafe<2.0', 'Pygments>=2.0', 'docutils>=0.14,<0.18', 'snowballstemmer>=1.1', From 28ab5f233ce4265deecbe02b70e2c7a9bdf620c2 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Mon, 3 May 2021 00:51:22 +0900 Subject: [PATCH 15/18] Close #8588: autodoc: autodoc_type_aliases supports dotted name It allows users to define an alias for a class with module name like `foo.bar.BazClass`. --- CHANGES | 3 + sphinx/util/inspect.py | 87 ++++++++++++++++++- ...annotations.py => autodoc_type_aliases.py} | 5 ++ tests/test_ext_autodoc_configs.py | 57 +++++++----- tests/test_util_inspect.py | 21 ++++- 5 files changed, 147 insertions(+), 26 deletions(-) rename tests/roots/test-ext-autodoc/target/{annotations.py => autodoc_type_aliases.py} (88%) diff --git a/CHANGES b/CHANGES index 5b7a840dc..970eee214 100644 --- a/CHANGES +++ b/CHANGES @@ -16,6 +16,9 @@ Features added * #8107: autodoc: Add ``class-doc-from`` option to :rst:dir:`autoclass` directive to control the content of the specific class like :confval:`autoclass_content` +* #8588: autodoc: :confval:`autodoc_type_aliases` now supports dotted name. It + allows you to define an alias for a class with module name like + ``foo.bar.BazClass`` * #9129: html search: Show search summaries when html_copy_source = False * #9120: html theme: Eliminate prompt characters of code-block from copyable text diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index 7c9adb0bf..f216e8797 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -18,8 +18,10 @@ import types import typing import warnings from functools import partial, partialmethod +from importlib import import_module from inspect import Parameter, isclass, ismethod, ismethoddescriptor, ismodule # NOQA from io import StringIO +from types import ModuleType from typing import Any, Callable, Dict, Mapping, Optional, Sequence, Tuple, Type, cast from sphinx.deprecation import RemovedInSphinx50Warning @@ -501,6 +503,78 @@ class DefaultValue: return self.value +class TypeAliasForwardRef: + """Pseudo typing class for autodoc_type_aliases. + + This avoids the error on evaluating the type inside `get_type_hints()`. + """ + def __init__(self, name: str) -> None: + self.name = name + + def __call__(self) -> None: + # Dummy method to imitate special typing classes + pass + + def __eq__(self, other: Any) -> bool: + return self.name == other + + +class TypeAliasModule: + """Pseudo module class for autodoc_type_aliases.""" + + def __init__(self, modname: str, mapping: Dict[str, str]) -> None: + self.__modname = modname + self.__mapping = mapping + + self.__module: Optional[ModuleType] = None + + def __getattr__(self, name: str) -> Any: + fullname = '.'.join(filter(None, [self.__modname, name])) + if fullname in self.__mapping: + # exactly matched + return TypeAliasForwardRef(self.__mapping[fullname]) + else: + prefix = fullname + '.' + nested = {k: v for k, v in self.__mapping.items() if k.startswith(prefix)} + if nested: + # sub modules or classes found + return TypeAliasModule(fullname, nested) + else: + # no sub modules or classes found. + try: + # return the real submodule if exists + return import_module(fullname) + except ImportError: + # return the real class + if self.__module is None: + self.__module = import_module(self.__modname) + + return getattr(self.__module, name) + + +class TypeAliasNamespace(Dict[str, Any]): + """Pseudo namespace class for autodoc_type_aliases. + + This enables to look up nested modules and classes like `mod1.mod2.Class`. + """ + + def __init__(self, mapping: Dict[str, str]) -> None: + self.__mapping = mapping + + def __getitem__(self, key: str) -> Any: + if key in self.__mapping: + # exactly matched + return TypeAliasForwardRef(self.__mapping[key]) + else: + prefix = key + '.' + nested = {k: v for k, v in self.__mapping.items() if k.startswith(prefix)} + if nested: + # sub modules or classes found + return TypeAliasModule(key, nested) + else: + raise KeyError + + def _should_unwrap(subject: Callable) -> bool: """Check the function should be unwrapped on getting signature.""" __globals__ = getglobals(subject) @@ -549,12 +623,19 @@ def signature(subject: Callable, bound_method: bool = False, follow_wrapped: boo try: # Resolve annotations using ``get_type_hints()`` and type_aliases. - annotations = typing.get_type_hints(subject, None, type_aliases) + localns = TypeAliasNamespace(type_aliases) + annotations = typing.get_type_hints(subject, None, localns) for i, param in enumerate(parameters): if param.name in annotations: - parameters[i] = param.replace(annotation=annotations[param.name]) + annotation = annotations[param.name] + if isinstance(annotation, TypeAliasForwardRef): + annotation = annotation.name + parameters[i] = param.replace(annotation=annotation) if 'return' in annotations: - return_annotation = annotations['return'] + if isinstance(annotations['return'], TypeAliasForwardRef): + return_annotation = annotations['return'].name + else: + return_annotation = annotations['return'] except Exception: # ``get_type_hints()`` does not support some kind of objects like partial, # ForwardRef and so on. diff --git a/tests/roots/test-ext-autodoc/target/annotations.py b/tests/roots/test-ext-autodoc/target/autodoc_type_aliases.py similarity index 88% rename from tests/roots/test-ext-autodoc/target/annotations.py rename to tests/roots/test-ext-autodoc/target/autodoc_type_aliases.py index ef600e2af..d8a2fecef 100644 --- a/tests/roots/test-ext-autodoc/target/annotations.py +++ b/tests/roots/test-ext-autodoc/target/autodoc_type_aliases.py @@ -1,5 +1,6 @@ from __future__ import annotations +import io from typing import overload myint = int @@ -11,6 +12,10 @@ variable: myint variable2 = None # type: myint +def read(r: io.BytesIO) -> io.StringIO: + """docstring""" + + def sum(x: myint, y: myint) -> myint: """docstring""" return x + y diff --git a/tests/test_ext_autodoc_configs.py b/tests/test_ext_autodoc_configs.py index bc8c01fbd..04d35e335 100644 --- a/tests/test_ext_autodoc_configs.py +++ b/tests/test_ext_autodoc_configs.py @@ -792,27 +792,27 @@ def test_autodoc_typehints_description_for_invalid_node(app): def test_autodoc_type_aliases(app): # default options = {"members": None} - actual = do_autodoc(app, 'module', 'target.annotations', options) + actual = do_autodoc(app, 'module', 'target.autodoc_type_aliases', options) assert list(actual) == [ '', - '.. py:module:: target.annotations', + '.. py:module:: target.autodoc_type_aliases', '', '', '.. py:class:: Foo()', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', '', ' docstring', '', '', ' .. py:attribute:: Foo.attr1', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', ' :type: int', '', ' docstring', '', '', ' .. py:attribute:: Foo.attr2', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', ' :type: int', '', ' docstring', @@ -820,26 +820,32 @@ def test_autodoc_type_aliases(app): '', '.. py:function:: mult(x: int, y: int) -> int', ' mult(x: float, y: float) -> float', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', + '', + ' docstring', + '', + '', + '.. py:function:: read(r: _io.BytesIO) -> _io.StringIO', + ' :module: target.autodoc_type_aliases', '', ' docstring', '', '', '.. py:function:: sum(x: int, y: int) -> int', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', '', ' docstring', '', '', '.. py:data:: variable', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', ' :type: int', '', ' docstring', '', '', '.. py:data:: variable2', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', ' :type: int', ' :value: None', '', @@ -848,28 +854,29 @@ def test_autodoc_type_aliases(app): ] # define aliases - app.config.autodoc_type_aliases = {'myint': 'myint'} - actual = do_autodoc(app, 'module', 'target.annotations', options) + app.config.autodoc_type_aliases = {'myint': 'myint', + 'io.StringIO': 'my.module.StringIO'} + actual = do_autodoc(app, 'module', 'target.autodoc_type_aliases', options) assert list(actual) == [ '', - '.. py:module:: target.annotations', + '.. py:module:: target.autodoc_type_aliases', '', '', '.. py:class:: Foo()', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', '', ' docstring', '', '', ' .. py:attribute:: Foo.attr1', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', ' :type: myint', '', ' docstring', '', '', ' .. py:attribute:: Foo.attr2', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', ' :type: myint', '', ' docstring', @@ -877,26 +884,32 @@ def test_autodoc_type_aliases(app): '', '.. py:function:: mult(x: myint, y: myint) -> myint', ' mult(x: float, y: float) -> float', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', + '', + ' docstring', + '', + '', + '.. py:function:: read(r: _io.BytesIO) -> my.module.StringIO', + ' :module: target.autodoc_type_aliases', '', ' docstring', '', '', '.. py:function:: sum(x: myint, y: myint) -> myint', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', '', ' docstring', '', '', '.. py:data:: variable', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', ' :type: myint', '', ' docstring', '', '', '.. py:data:: variable2', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', ' :type: myint', ' :value: None', '', @@ -911,10 +924,10 @@ def test_autodoc_type_aliases(app): confoverrides={'autodoc_typehints': "description", 'autodoc_type_aliases': {'myint': 'myint'}}) def test_autodoc_typehints_description_and_type_aliases(app): - (app.srcdir / 'annotations.rst').write_text('.. autofunction:: target.annotations.sum') + (app.srcdir / 'autodoc_type_aliases.rst').write_text('.. autofunction:: target.autodoc_type_aliases.sum') app.build() - context = (app.outdir / 'annotations.txt').read_text() - assert ('target.annotations.sum(x, y)\n' + context = (app.outdir / 'autodoc_type_aliases.txt').read_text() + assert ('target.autodoc_type_aliases.sum(x, y)\n' '\n' ' docstring\n' '\n' diff --git a/tests/test_util_inspect.py b/tests/test_util_inspect.py index 7b86c6ade..fbf243ba1 100644 --- a/tests/test_util_inspect.py +++ b/tests/test_util_inspect.py @@ -19,7 +19,26 @@ import _testcapi import pytest from sphinx.util import inspect -from sphinx.util.inspect import stringify_signature +from sphinx.util.inspect import TypeAliasNamespace, stringify_signature + + +def test_TypeAliasNamespace(): + import logging.config + type_alias = TypeAliasNamespace({'logging.Filter': 'MyFilter', + 'logging.Handler': 'MyHandler', + 'logging.handlers.SyslogHandler': 'MySyslogHandler'}) + + assert type_alias['logging'].Filter == 'MyFilter' + assert type_alias['logging'].Handler == 'MyHandler' + assert type_alias['logging'].handlers.SyslogHandler == 'MySyslogHandler' + assert type_alias['logging'].Logger == logging.Logger + assert type_alias['logging'].config == logging.config + + with pytest.raises(KeyError): + assert type_alias['log'] + + with pytest.raises(KeyError): + assert type_alias['unknown'] def test_signature(): From 8d54010675b122444ce7c3dd7b406154d5816aa5 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Mon, 3 May 2021 18:52:19 +0900 Subject: [PATCH 16/18] Fix test: deprecation warning for extlinks --- tests/roots/test-root/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/roots/test-root/conf.py b/tests/roots/test-root/conf.py index 34cafa767..687445a70 100644 --- a/tests/roots/test-root/conf.py +++ b/tests/roots/test-root/conf.py @@ -42,7 +42,7 @@ latex_additional_files = ['svgimg.svg'] coverage_c_path = ['special/*.h'] coverage_c_regexes = {'function': r'^PyAPI_FUNC\(.*\)\s+([^_][\w_]+)'} -extlinks = {'issue': ('http://bugs.python.org/issue%s', 'issue '), +extlinks = {'issue': ('http://bugs.python.org/issue%s', 'issue %s'), 'pyurl': ('http://python.org/%s', None)} # modify tags from conf.py From cfb9183715b70c5c283d35b8db98f17c73450f3f Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Mon, 3 May 2021 13:56:18 +0900 Subject: [PATCH 17/18] Update dependency: jinja2 < 3.0 and MarkupSafe < 2.0 Jinja2 and MarkupSafe have a plan to major release in the near future. And it will introduce some changes for its APIs. To lessen the noise of the DeprecationWarnings, this pins the versions to current stable release. --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b669afc00..7ce37f9ea 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,8 @@ install_requires = [ 'sphinxcontrib-htmlhelp', 'sphinxcontrib-serializinghtml', 'sphinxcontrib-qthelp', - 'Jinja2>=2.3', + 'Jinja2>=2.3,<3.0', + 'MarkupSafe<2.0', 'Pygments>=2.0', 'docutils>=0.14,<0.18', 'snowballstemmer>=1.1', From caa6579dbda5fd351b63f0786beffcd8a1cd9425 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Sun, 2 May 2021 21:00:26 +0900 Subject: [PATCH 18/18] Fix #8872: autodoc: stacked singledispatches are wrongly rendered When multiple singledispatch decorators are stacked, the first typehints are copied to the subsequent definitions unexpectedly. Now autodoc generates a dummy function not to affect typehints to subsequent functions. --- CHANGES | 2 + sphinx/ext/autodoc/__init__.py | 63 +++++++++++-------- .../test-ext-autodoc/target/singledispatch.py | 1 + .../target/singledispatchmethod.py | 1 + tests/test_ext_autodoc.py | 3 + tests/test_ext_autodoc_autofunction.py | 1 + 6 files changed, 46 insertions(+), 25 deletions(-) diff --git a/CHANGES b/CHANGES index 5b7a840dc..8af4039f3 100644 --- a/CHANGES +++ b/CHANGES @@ -25,6 +25,8 @@ Features added Bugs fixed ---------- +* #8872: autodoc: stacked singledispatches are wrongly rendered + Testing -------- diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index 6ccfd9c7f..fab0a3d74 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -1328,12 +1328,12 @@ class FunctionDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # typ if typ is object: pass # default implementation. skipped. else: - self.annotate_to_first_argument(func, typ) - - documenter = FunctionDocumenter(self.directive, '') - documenter.object = func - documenter.objpath = [None] - sigs.append(documenter.format_signature()) + dispatchfunc = self.annotate_to_first_argument(func, typ) + if dispatchfunc: + documenter = FunctionDocumenter(self.directive, '') + documenter.object = dispatchfunc + documenter.objpath = [None] + sigs.append(documenter.format_signature()) if overloaded: actual = inspect.signature(self.object, type_aliases=self.config.autodoc_type_aliases) @@ -1358,28 +1358,34 @@ class FunctionDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # typ return overload.replace(parameters=parameters) - def annotate_to_first_argument(self, func: Callable, typ: Type) -> None: + def annotate_to_first_argument(self, func: Callable, typ: Type) -> Optional[Callable]: """Annotate type hint to the first argument of function if needed.""" try: sig = inspect.signature(func, type_aliases=self.config.autodoc_type_aliases) except TypeError as exc: logger.warning(__("Failed to get a function signature for %s: %s"), self.fullname, exc) - return + return None except ValueError: - return + return None if len(sig.parameters) == 0: - return + return None + + def dummy(): + pass params = list(sig.parameters.values()) if params[0].annotation is Parameter.empty: params[0] = params[0].replace(annotation=typ) try: - func.__signature__ = sig.replace(parameters=params) # type: ignore + dummy.__signature__ = sig.replace(parameters=params) # type: ignore + return dummy except (AttributeError, TypeError): # failed to update signature (ex. built-in or extension types) - return + return None + else: + return None class DecoratorDocumenter(FunctionDocumenter): @@ -2118,13 +2124,13 @@ class MethodDocumenter(DocstringSignatureMixin, ClassLevelDocumenter): # type: if typ is object: pass # default implementation. skipped. else: - self.annotate_to_first_argument(func, typ) - - documenter = MethodDocumenter(self.directive, '') - documenter.parent = self.parent - documenter.object = func - documenter.objpath = [None] - sigs.append(documenter.format_signature()) + dispatchmeth = self.annotate_to_first_argument(func, typ) + if dispatchmeth: + documenter = MethodDocumenter(self.directive, '') + documenter.parent = self.parent + documenter.object = dispatchmeth + documenter.objpath = [None] + sigs.append(documenter.format_signature()) if overloaded: if inspect.isstaticmethod(self.object, cls=self.parent, name=self.object_name): actual = inspect.signature(self.object, bound_method=False, @@ -2158,27 +2164,34 @@ class MethodDocumenter(DocstringSignatureMixin, ClassLevelDocumenter): # type: return overload.replace(parameters=parameters) - def annotate_to_first_argument(self, func: Callable, typ: Type) -> None: + def annotate_to_first_argument(self, func: Callable, typ: Type) -> Optional[Callable]: """Annotate type hint to the first argument of function if needed.""" try: sig = inspect.signature(func, type_aliases=self.config.autodoc_type_aliases) except TypeError as exc: logger.warning(__("Failed to get a method signature for %s: %s"), self.fullname, exc) - return + return None except ValueError: - return + return None + if len(sig.parameters) == 1: - return + return None + + def dummy(): + pass params = list(sig.parameters.values()) if params[1].annotation is Parameter.empty: params[1] = params[1].replace(annotation=typ) try: - func.__signature__ = sig.replace(parameters=params) # type: ignore + dummy.__signature__ = sig.replace(parameters=params) # type: ignore + return dummy except (AttributeError, TypeError): # failed to update signature (ex. built-in or extension types) - return + return None + else: + return None class NonDataDescriptorMixin(DataDocumenterMixinBase): diff --git a/tests/roots/test-ext-autodoc/target/singledispatch.py b/tests/roots/test-ext-autodoc/target/singledispatch.py index 3fa81dcae..fca2b6683 100644 --- a/tests/roots/test-ext-autodoc/target/singledispatch.py +++ b/tests/roots/test-ext-autodoc/target/singledispatch.py @@ -15,6 +15,7 @@ def func(arg, kwarg=None): @func.register(int) +@func.register(float) def _func_int(arg, kwarg=None): """A function for int.""" pass diff --git a/tests/roots/test-ext-autodoc/target/singledispatchmethod.py b/tests/roots/test-ext-autodoc/target/singledispatchmethod.py index b5ccbb2f0..086c7fe66 100644 --- a/tests/roots/test-ext-autodoc/target/singledispatchmethod.py +++ b/tests/roots/test-ext-autodoc/target/singledispatchmethod.py @@ -10,6 +10,7 @@ class Foo: pass @meth.register(int) + @meth.register(float) def _meth_int(self, arg, kwarg=None): """A method for int.""" pass diff --git a/tests/test_ext_autodoc.py b/tests/test_ext_autodoc.py index 2becf5de2..e7d5c4042 100644 --- a/tests/test_ext_autodoc.py +++ b/tests/test_ext_autodoc.py @@ -2080,6 +2080,7 @@ def test_singledispatch(app): '', '', '.. py:function:: func(arg, kwarg=None)', + ' func(arg: float, kwarg=None)', ' func(arg: int, kwarg=None)', ' func(arg: str, kwarg=None)', ' :module: target.singledispatch', @@ -2107,6 +2108,7 @@ def test_singledispatchmethod(app): '', '', ' .. py:method:: Foo.meth(arg, kwarg=None)', + ' Foo.meth(arg: float, kwarg=None)', ' Foo.meth(arg: int, kwarg=None)', ' Foo.meth(arg: str, kwarg=None)', ' :module: target.singledispatchmethod', @@ -2125,6 +2127,7 @@ def test_singledispatchmethod_automethod(app): assert list(actual) == [ '', '.. py:method:: Foo.meth(arg, kwarg=None)', + ' Foo.meth(arg: float, kwarg=None)', ' Foo.meth(arg: int, kwarg=None)', ' Foo.meth(arg: str, kwarg=None)', ' :module: target.singledispatchmethod', diff --git a/tests/test_ext_autodoc_autofunction.py b/tests/test_ext_autodoc_autofunction.py index 615091889..ca2429b5e 100644 --- a/tests/test_ext_autodoc_autofunction.py +++ b/tests/test_ext_autodoc_autofunction.py @@ -119,6 +119,7 @@ def test_singledispatch(app): assert list(actual) == [ '', '.. py:function:: func(arg, kwarg=None)', + ' func(arg: float, kwarg=None)', ' func(arg: int, kwarg=None)', ' func(arg: str, kwarg=None)', ' :module: target.singledispatch',