diff --git a/CHANGES b/CHANGES index 65a69f6b8..3263f9fb5 100644 --- a/CHANGES +++ b/CHANGES @@ -58,7 +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 +* #2815: autodoc: Support singledispatch functions and methods * #6558: glossary: emit a warning for duplicated glossary entry * #3106: domain: Register hyperlink target for index page automatically * #6558: std domain: emit a warning for duplicated generic objects diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index f6d581cfc..97995a410 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -1457,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. @@ -1672,6 +1732,7 @@ def setup(app: Sphinx) -> Dict[str, Any]: 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/util/inspect.py b/sphinx/util/inspect.py index 58f922cae..d22df7656 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -235,6 +235,15 @@ def is_singledispatch_function(obj: Any) -> bool: 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-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 dcbdc6d3a..0510fff86 100644 --- a/tests/test_autodoc.py +++ b/tests/test_autodoc.py @@ -1582,3 +1582,30 @@ def test_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.', + ' ' + ]