diff --git a/CHANGES b/CHANGES index fdac5368d..72a53269c 100644 --- a/CHANGES +++ b/CHANGES @@ -23,18 +23,17 @@ Incompatible changes * Due to the scoping changes for :rst:dir:`productionlist` some uses of :rst:role:`token` must be modified to include the scope which was previously ignored. -* #6903: js domain: Internal data structure has changed. Both objects and - modules have node_id for cross reference +* #6903: Internal data structure of Python, reST and standard domains have + changed. The node_id is added to the index of objects and modules. Now they + contains a pair of docname and node_id for cross reference. * #7210: js domain: Non intended behavior is removed such as ``parseInt_`` links to ``.. js:function:: parseInt`` -* #6903: rst domain: Internal data structure has changed. Now objects have - node_id for cross reference * #7229: rst domain: Non intended behavior is removed such as ``numref_`` links to ``.. rst:role:: numref`` -* #6903: py domain: Internal data structure has changed. Both objects and - modules have node_id for cross reference * #6903: py domain: Non intended behavior is removed such as ``say_hello_`` links to ``.. py:function:: say_hello()`` +* #7246: py domain: Drop special cross reference helper for exceptions, + functions and methods Deprecated ---------- @@ -42,6 +41,7 @@ Deprecated * ``desc_signature['first']`` * ``sphinx.directives.DescDirective`` * ``sphinx.domains.std.StandardDomain.add_object()`` +* ``sphinx.domains.python.PyDecoratorMixin`` * ``sphinx.parsers.Parser.app`` * ``sphinx.testing.path.Path.text()`` * ``sphinx.testing.path.Path.bytes()`` @@ -58,6 +58,7 @@ Features added * #6830: autodoc: consider a member private if docstring contains ``:meta private:`` in info-field-list * #7165: autodoc: Support Annotated type (PEP-593) +* #2815: autodoc: Support singledispatch functions and methods * #7079: autodoc: :confval:`autodoc_typehints` accepts ``"description"`` configuration. It shows typehints as object description * #6558: glossary: emit a warning for duplicated glossary entry @@ -93,6 +94,7 @@ Bugs fixed * C++, suppress warnings for directly dependent typenames in cross references generated automatically in signatures. * #5637: autodoc: Incorrect handling of nested class names on show-inheritance +* #7267: autodoc: error message for invalid directive options has wrong location * #5637: inheritance_diagram: Incorrect handling of nested class names * #7139: ``code-block:: guess`` does not work diff --git a/doc/extdev/deprecated.rst b/doc/extdev/deprecated.rst index e98652ed2..cf914a7cd 100644 --- a/doc/extdev/deprecated.rst +++ b/doc/extdev/deprecated.rst @@ -41,6 +41,11 @@ The following is a list of deprecated interfaces. - 5.0 - ``sphinx.domains.std.StandardDomain.note_object()`` + * - ``sphinx.domains.python.PyDecoratorMixin`` + - 3.0 + - 5.0 + - N/A + * - ``sphinx.parsers.Parser.app`` - 3.0 - 5.0 diff --git a/sphinx/domains/python.py b/sphinx/domains/python.py index e667f91dc..fa0d17315 100644 --- a/sphinx/domains/python.py +++ b/sphinx/domains/python.py @@ -25,7 +25,7 @@ from sphinx import addnodes from sphinx.addnodes import pending_xref, desc_signature from sphinx.application import Sphinx from sphinx.builders import Builder -from sphinx.deprecation import RemovedInSphinx40Warning +from sphinx.deprecation import RemovedInSphinx40Warning, RemovedInSphinx50Warning from sphinx.directives import ObjectDescription from sphinx.domains import Domain, ObjType, Index, IndexEntry from sphinx.environment import BuildEnvironment @@ -489,6 +489,23 @@ class PyFunction(PyObject): return _('%s() (built-in function)') % name +class PyDecoratorFunction(PyFunction): + """Description of a decorator.""" + + def run(self) -> List[Node]: + # a decorator function is a function after all + self.name = 'py:function' + return super().run() + + def handle_signature(self, sig: str, signode: desc_signature) -> Tuple[str, str]: + ret = super().handle_signature(sig, signode) + signode.insert(0, addnodes.desc_addname('@', '@')) + return ret + + def needs_arglist(self) -> bool: + return False + + class PyVariable(PyObject): """Description of a variable.""" @@ -700,6 +717,22 @@ class PyStaticMethod(PyMethod): return super().run() +class PyDecoratorMethod(PyMethod): + """Description of a decoratormethod.""" + + def run(self) -> List[Node]: + self.name = 'py:method' + return super().run() + + def handle_signature(self, sig: str, signode: desc_signature) -> Tuple[str, str]: + ret = super().handle_signature(sig, signode) + signode.insert(0, addnodes.desc_addname('@', '@')) + return ret + + def needs_arglist(self) -> bool: + return False + + class PyAttribute(PyObject): """Description of an attribute.""" @@ -742,6 +775,15 @@ class PyDecoratorMixin: Mixin for decorator directives. """ def handle_signature(self, sig: str, signode: desc_signature) -> Tuple[str, str]: + for cls in self.__class__.__mro__: + if cls.__name__ != 'DirectiveAdapter': + warnings.warn('PyDecoratorMixin is deprecated. ' + 'Please check the implementation of %s' % cls, + RemovedInSphinx50Warning) + break + else: + warnings.warn('PyDecoratorMixin is deprecated', RemovedInSphinx50Warning) + ret = super().handle_signature(sig, signode) # type: ignore signode.insert(0, addnodes.desc_addname('@', '@')) return ret @@ -750,25 +792,6 @@ class PyDecoratorMixin: return False -class PyDecoratorFunction(PyDecoratorMixin, PyModulelevel): - """ - Directive to mark functions meant to be used as decorators. - """ - def run(self) -> List[Node]: - # a decorator function is a function after all - self.name = 'py:function' - return super().run() - - -class PyDecoratorMethod(PyDecoratorMixin, PyClassmember): - """ - Directive to mark methods meant to be used as decorators. - """ - def run(self) -> List[Node]: - self.name = 'py:method' - return super().run() - - class PyModule(SphinxDirective): """ Directive to mark description of a new module. @@ -1110,14 +1133,6 @@ class PythonDomain(Domain): elif modname and classname and \ modname + '.' + classname + '.' + name in self.objects: newname = modname + '.' + classname + '.' + name - # special case: builtin exceptions have module "exceptions" set - elif type == 'exc' and '.' not in name and \ - 'exceptions.' + name in self.objects: - newname = 'exceptions.' + name - # special case: object methods - elif type in ('func', 'meth') and '.' not in name and \ - 'object.' + name in self.objects: - newname = 'object.' + name if newname is not None: matches.append((newname, self.objects[newname])) return matches diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index e08a3d2ef..9a6b32637 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -14,7 +14,8 @@ import importlib import re import warnings from types import ModuleType -from typing import Any, Callable, Dict, Iterator, List, Sequence, Set, Tuple, Union +from typing import Any, Callable, Dict, Iterator, List, Sequence, Set, Tuple, Type, Union +from unittest.mock import patch from docutils.statemachine import StringList @@ -1056,6 +1057,62 @@ class FunctionDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # typ self.add_line(' :async:', sourcename) +class SingledispatchFunctionDocumenter(FunctionDocumenter): + """ + Specialized Documenter subclass for singledispatch'ed functions. + """ + objtype = 'singledispatch_function' + directivetype = 'function' + member_order = 30 + + # before FunctionDocumenter + priority = FunctionDocumenter.priority + 1 + + @classmethod + def can_document_member(cls, member: Any, membername: str, isattr: bool, parent: Any + ) -> bool: + return (super().can_document_member(member, membername, isattr, parent) and + inspect.is_singledispatch_function(member)) + + def add_directive_header(self, sig: str) -> None: + sourcename = self.get_sourcename() + + # intercept generated directive headers + # TODO: It is very hacky to use mock to intercept header generation + with patch.object(self, 'add_line') as add_line: + super().add_directive_header(sig) + + # output first line of header + self.add_line(*add_line.call_args_list[0][0]) + + # inserts signature of singledispatch'ed functions + for typ, func in self.object.registry.items(): + if typ is object: + pass # default implementation. skipped. + else: + self.annotate_to_first_argument(func, typ) + + documenter = FunctionDocumenter(self.directive, '') + documenter.object = func + self.add_line(' %s%s' % (self.format_name(), + documenter.format_signature()), + sourcename) + + # output remains of directive header + for call in add_line.call_args_list[1:]: + self.add_line(*call[0]) + + def annotate_to_first_argument(self, func: Callable, typ: Type) -> None: + """Annotate type hint to the first argument of function if needed.""" + sig = inspect.signature(func) + if len(sig.parameters) == 0: + return + + name = list(sig.parameters)[0] + if name not in func.__annotations__: + func.__annotations__[name] = typ + + class DecoratorDocumenter(FunctionDocumenter): """ Specialized Documenter subclass for decorator functions. @@ -1400,6 +1457,66 @@ class MethodDocumenter(DocstringSignatureMixin, ClassLevelDocumenter): # type: pass +class SingledispatchMethodDocumenter(MethodDocumenter): + """ + Specialized Documenter subclass for singledispatch'ed methods. + """ + objtype = 'singledispatch_method' + directivetype = 'method' + member_order = 50 + + # before MethodDocumenter + priority = MethodDocumenter.priority + 1 + + @classmethod + def can_document_member(cls, member: Any, membername: str, isattr: bool, parent: Any + ) -> bool: + if super().can_document_member(member, membername, isattr, parent) and parent.object: + meth = parent.object.__dict__.get(membername) + return inspect.is_singledispatch_method(meth) + else: + return False + + def add_directive_header(self, sig: str) -> None: + sourcename = self.get_sourcename() + + # intercept generated directive headers + # TODO: It is very hacky to use mock to intercept header generation + with patch.object(self, 'add_line') as add_line: + super().add_directive_header(sig) + + # output first line of header + self.add_line(*add_line.call_args_list[0][0]) + + # inserts signature of singledispatch'ed functions + meth = self.parent.__dict__.get(self.objpath[-1]) + for typ, func in meth.dispatcher.registry.items(): + if typ is object: + pass # default implementation. skipped. + else: + self.annotate_to_first_argument(func, typ) + + documenter = MethodDocumenter(self.directive, '') + documenter.object = func + self.add_line(' %s%s' % (self.format_name(), + documenter.format_signature()), + sourcename) + + # output remains of directive header + for call in add_line.call_args_list[1:]: + self.add_line(*call[0]) + + def annotate_to_first_argument(self, func: Callable, typ: Type) -> None: + """Annotate type hint to the first argument of function if needed.""" + sig = inspect.signature(func, bound_method=True) + if len(sig.parameters) == 0: + return + + name = list(sig.parameters)[0] + if name not in func.__annotations__: + func.__annotations__[name] = typ + + class AttributeDocumenter(DocstringStripSignatureMixin, ClassLevelDocumenter): # type: ignore """ Specialized Documenter subclass for attributes. @@ -1612,8 +1729,10 @@ def setup(app: Sphinx) -> Dict[str, Any]: app.add_autodocumenter(DataDocumenter) app.add_autodocumenter(DataDeclarationDocumenter) app.add_autodocumenter(FunctionDocumenter) + app.add_autodocumenter(SingledispatchFunctionDocumenter) app.add_autodocumenter(DecoratorDocumenter) app.add_autodocumenter(MethodDocumenter) + app.add_autodocumenter(SingledispatchMethodDocumenter) app.add_autodocumenter(AttributeDocumenter) app.add_autodocumenter(PropertyDocumenter) app.add_autodocumenter(InstanceAttributeDocumenter) diff --git a/sphinx/ext/autodoc/directive.py b/sphinx/ext/autodoc/directive.py index b44bd75b3..9302a954a 100644 --- a/sphinx/ext/autodoc/directive.py +++ b/sphinx/ext/autodoc/directive.py @@ -137,7 +137,7 @@ class AutodocDirective(SphinxDirective): except (KeyError, ValueError, TypeError) as exc: # an option is either unknown or has a wrong type logger.error('An option to %s is either unknown or has an invalid value: %s' % - (self.name, exc), location=(source, lineno)) + (self.name, exc), location=(self.env.docname, lineno)) return [] # generate the output diff --git a/sphinx/ext/autosummary/generate.py b/sphinx/ext/autosummary/generate.py index ef623e2bd..8bea3b4a3 100644 --- a/sphinx/ext/autosummary/generate.py +++ b/sphinx/ext/autosummary/generate.py @@ -72,12 +72,14 @@ def setup_documenters(app: Any) -> None: FunctionDocumenter, MethodDocumenter, AttributeDocumenter, InstanceAttributeDocumenter, DecoratorDocumenter, PropertyDocumenter, SlotsAttributeDocumenter, DataDeclarationDocumenter, + SingledispatchFunctionDocumenter, ) documenters = [ ModuleDocumenter, ClassDocumenter, ExceptionDocumenter, DataDocumenter, FunctionDocumenter, MethodDocumenter, AttributeDocumenter, InstanceAttributeDocumenter, DecoratorDocumenter, PropertyDocumenter, SlotsAttributeDocumenter, DataDeclarationDocumenter, + SingledispatchFunctionDocumenter, ] # type: List[Type[Documenter]] for documenter in documenters: app.registry.add_documenter(documenter.objtype, documenter) diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index 281ef4493..d22df7656 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -224,6 +224,26 @@ def isattributedescriptor(obj: Any) -> bool: return False +def is_singledispatch_function(obj: Any) -> bool: + """Check if the object is singledispatch function.""" + if (inspect.isfunction(obj) and + hasattr(obj, 'dispatch') and + hasattr(obj, 'register') and + obj.dispatch.__module__ == 'functools'): + return True + else: + return False + + +def is_singledispatch_method(obj: Any) -> bool: + """Check if the object is singledispatch method.""" + try: + from functools import singledispatchmethod # type: ignore + return isinstance(obj, singledispatchmethod) + except ImportError: # py35-37 + return False + + def isfunction(obj: Any) -> bool: """Check if the object is function.""" return inspect.isfunction(unwrap(obj)) diff --git a/tests/roots/test-domain-py/module.rst b/tests/roots/test-domain-py/module.rst index c01032b26..dce3fa5ac 100644 --- a/tests/roots/test-domain-py/module.rst +++ b/tests/roots/test-domain-py/module.rst @@ -51,3 +51,11 @@ module .. py:attribute:: attr2 :type: :doc:`index` + +.. py:module:: exceptions + +.. py:exception:: Exception + +.. py:module:: object + +.. py:function:: sum() diff --git a/tests/roots/test-ext-autodoc/target/singledispatch.py b/tests/roots/test-ext-autodoc/target/singledispatch.py new file mode 100644 index 000000000..c33d001b1 --- /dev/null +++ b/tests/roots/test-ext-autodoc/target/singledispatch.py @@ -0,0 +1,19 @@ +from functools import singledispatch + + +@singledispatch +def func(arg, kwarg=None): + """A function for general use.""" + pass + + +@func.register(int) +def _func_int(arg, kwarg=None): + """A function for int.""" + pass + + +@func.register(str) +def _func_str(arg, kwarg=None): + """A function for str.""" + pass diff --git a/tests/roots/test-ext-autodoc/target/singledispatchmethod.py b/tests/roots/test-ext-autodoc/target/singledispatchmethod.py new file mode 100644 index 000000000..b5ccbb2f0 --- /dev/null +++ b/tests/roots/test-ext-autodoc/target/singledispatchmethod.py @@ -0,0 +1,20 @@ +from functools import singledispatchmethod + + +class Foo: + """docstring""" + + @singledispatchmethod + def meth(self, arg, kwarg=None): + """A method for general use.""" + pass + + @meth.register(int) + def _meth_int(self, arg, kwarg=None): + """A method for int.""" + pass + + @meth.register(str) + def _meth_str(self, arg, kwarg=None): + """A method for str.""" + pass diff --git a/tests/test_autodoc.py b/tests/test_autodoc.py index b001de804..0510fff86 100644 --- a/tests/test_autodoc.py +++ b/tests/test_autodoc.py @@ -1563,3 +1563,49 @@ def test_autodoc_for_egged_code(app): ' :module: sample', '' ] + + +@pytest.mark.usefixtures('setup_test') +def test_singledispatch(): + options = {"members": None} + actual = do_autodoc(app, 'module', 'target.singledispatch', options) + assert list(actual) == [ + '', + '.. py:module:: target.singledispatch', + '', + '', + '.. py:function:: func(arg, kwarg=None)', + ' func(arg: int, kwarg=None)', + ' func(arg: str, kwarg=None)', + ' :module: target.singledispatch', + '', + ' A function for general use.', + ' ' + ] + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason='singledispatchmethod is available since python3.8') +@pytest.mark.usefixtures('setup_test') +def test_singledispatchmethod(): + options = {"members": None} + actual = do_autodoc(app, 'module', 'target.singledispatchmethod', options) + assert list(actual) == [ + '', + '.. py:module:: target.singledispatchmethod', + '', + '', + '.. py:class:: Foo', + ' :module: target.singledispatchmethod', + '', + ' docstring', + ' ', + ' ', + ' .. py:method:: Foo.meth(arg, kwarg=None)', + ' Foo.meth(arg: int, kwarg=None)', + ' Foo.meth(arg: str, kwarg=None)', + ' :module: target.singledispatchmethod', + ' ', + ' A method for general use.', + ' ' + ] diff --git a/tests/test_domain_py.py b/tests/test_domain_py.py index 218ded510..27819af6b 100644 --- a/tests/test_domain_py.py +++ b/tests/test_domain_py.py @@ -585,6 +585,36 @@ def test_pyattribute(app): assert domain.objects['Class.attr'] == ('index', 'class-attr', 'attribute') +def test_pydecorator_signature(app): + text = ".. py:decorator:: deco" + domain = app.env.get_domain('py') + doctree = restructuredtext.parse(app, text) + assert_node(doctree, (addnodes.index, + [desc, ([desc_signature, ([desc_addname, "@"], + [desc_name, "deco"])], + desc_content)])) + assert_node(doctree[1], addnodes.desc, desctype="function", + domain="py", objtype="function", noindex=False) + + assert 'deco' in domain.objects + assert domain.objects['deco'] == ('index', 'deco', 'function') + + +def test_pydecoratormethod_signature(app): + text = ".. py:decoratormethod:: deco" + domain = app.env.get_domain('py') + doctree = restructuredtext.parse(app, text) + assert_node(doctree, (addnodes.index, + [desc, ([desc_signature, ([desc_addname, "@"], + [desc_name, "deco"])], + desc_content)])) + assert_node(doctree[1], addnodes.desc, desctype="method", + domain="py", objtype="method", noindex=False) + + assert 'deco' in domain.objects + assert domain.objects['deco'] == ('index', 'deco', 'method') + + @pytest.mark.sphinx(freshenv=True) def test_module_index(app): text = (".. py:module:: docutils\n"