diff --git a/CHANGES b/CHANGES index 78dc38213..f262131db 100644 --- a/CHANGES +++ b/CHANGES @@ -51,6 +51,7 @@ Features added builtin base classes * #2106: autodoc: Support multiple signatures on docstring * #4422: autodoc: Support GenericAlias in Python 3.7 or above +* #3610: autodoc: Support overloaded functions * #7466: autosummary: headings in generated documents are not translated * #7490: autosummary: Add ``:caption:`` option to autosummary directive to set a caption to the toctree diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index e247d3bd2..31301e01e 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -1192,8 +1192,14 @@ class FunctionDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # typ self.add_line(' :async:', sourcename) def format_signature(self, **kwargs: Any) -> str: - sig = super().format_signature(**kwargs) - sigs = [sig] + sigs = [] + if self.analyzer and '.'.join(self.objpath) in self.analyzer.overloads: + # Use signatures for overloaded functions instead of the implementation function. + overloaded = True + else: + overloaded = False + sig = super().format_signature(**kwargs) + sigs.append(sig) if inspect.is_singledispatch_function(self.object): # append signature of singledispatch'ed functions @@ -1207,6 +1213,10 @@ class FunctionDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # typ documenter.object = func documenter.objpath = [None] sigs.append(documenter.format_signature()) + if overloaded: + for overload in self.analyzer.overloads.get('.'.join(self.objpath)): + sig = stringify_signature(overload, **kwargs) + sigs.append(sig) return "\n".join(sigs) @@ -1269,6 +1279,9 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type: 'private-members': bool_option, 'special-members': members_option, } # type: Dict[str, Callable] + _signature_class = None # type: Any + _signature_method_name = None # type: str + def __init__(self, *args: Any) -> None: super().__init__(*args) merge_special_members_option(self.options) @@ -1289,7 +1302,7 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type: self.doc_as_attr = True return ret - def _get_signature(self) -> Optional[Signature]: + def _get_signature(self) -> Tuple[Optional[Any], Optional[str], Optional[Signature]]: def get_user_defined_function_or_method(obj: Any, attr: str) -> Any: """ Get the `attr` function or method from `obj`, if it is user-defined. """ if inspect.is_builtin_class_method(obj, attr): @@ -1313,7 +1326,8 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type: if call is not None: self.env.app.emit('autodoc-before-process-signature', call, True) try: - return inspect.signature(call, bound_method=True) + sig = inspect.signature(call, bound_method=True) + return type(self.object), '__call__', sig except ValueError: pass @@ -1322,7 +1336,8 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type: if new is not None: self.env.app.emit('autodoc-before-process-signature', new, True) try: - return inspect.signature(new, bound_method=True) + sig = inspect.signature(new, bound_method=True) + return self.object, '__new__', sig except ValueError: pass @@ -1331,7 +1346,8 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type: if init is not None: self.env.app.emit('autodoc-before-process-signature', init, True) try: - return inspect.signature(init, bound_method=True) + sig = inspect.signature(init, bound_method=True) + return self.object, '__init__', sig except ValueError: pass @@ -1341,20 +1357,21 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type: # the signature from, so just pass the object itself to our hook. self.env.app.emit('autodoc-before-process-signature', self.object, False) try: - return inspect.signature(self.object, bound_method=False) + sig = inspect.signature(self.object, bound_method=False) + return None, None, sig except ValueError: pass # Still no signature: happens e.g. for old-style classes # with __init__ in C and no `__text_signature__`. - return None + return None, None, None def format_args(self, **kwargs: Any) -> str: if self.env.config.autodoc_typehints in ('none', 'description'): kwargs.setdefault('show_annotation', False) try: - sig = self._get_signature() + self._signature_class, self._signature_method_name, sig = self._get_signature() except TypeError as exc: # __signature__ attribute contained junk logger.warning(__("Failed to get a constructor signature for %s: %s"), @@ -1370,7 +1387,30 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type: if self.doc_as_attr: return '' - return super().format_signature(**kwargs) + sig = super().format_signature() + + overloaded = False + qualname = None + # TODO: recreate analyzer for the module of class (To be clear, owner of the method) + if self._signature_class and self._signature_method_name and self.analyzer: + qualname = '.'.join([self._signature_class.__qualname__, + self._signature_method_name]) + if qualname in self.analyzer.overloads: + overloaded = True + + sigs = [] + if overloaded: + # Use signatures for overloaded methods instead of the implementation method. + for overload in self.analyzer.overloads.get(qualname): + parameters = list(overload.parameters.values()) + overload = overload.replace(parameters=parameters[1:], + return_annotation=Parameter.empty) + sig = stringify_signature(overload, **kwargs) + sigs.append(sig) + else: + sigs.append(sig) + + return "\n".join(sigs) def add_directive_header(self, sig: str) -> None: sourcename = self.get_sourcename() @@ -1693,8 +1733,14 @@ class MethodDocumenter(DocstringSignatureMixin, ClassLevelDocumenter): # type: pass def format_signature(self, **kwargs: Any) -> str: - sig = super().format_signature(**kwargs) - sigs = [sig] + sigs = [] + if self.analyzer and '.'.join(self.objpath) in self.analyzer.overloads: + # Use signatures for overloaded methods instead of the implementation method. + overloaded = True + else: + overloaded = False + sig = super().format_signature(**kwargs) + sigs.append(sig) meth = self.parent.__dict__.get(self.objpath[-1]) if inspect.is_singledispatch_method(meth): @@ -1710,6 +1756,14 @@ class MethodDocumenter(DocstringSignatureMixin, ClassLevelDocumenter): # type: documenter.object = func documenter.objpath = [None] sigs.append(documenter.format_signature()) + if overloaded: + for overload in self.analyzer.overloads.get('.'.join(self.objpath)): + if not inspect.isstaticmethod(self.object, cls=self.parent, + name=self.object_name): + parameters = list(overload.parameters.values()) + overload = overload.replace(parameters=parameters[1:]) + sig = stringify_signature(overload, **kwargs) + sigs.append(sig) return "\n".join(sigs) diff --git a/sphinx/pycode/__init__.py b/sphinx/pycode/__init__.py index 4879fb349..963680a54 100644 --- a/sphinx/pycode/__init__.py +++ b/sphinx/pycode/__init__.py @@ -12,6 +12,7 @@ import re import tokenize import warnings from importlib import import_module +from inspect import Signature from io import StringIO from os import path from typing import Any, Dict, IO, List, Tuple, Optional @@ -145,6 +146,7 @@ class ModuleAnalyzer: self.annotations = None # type: Dict[Tuple[str, str], str] self.attr_docs = None # type: Dict[Tuple[str, str], List[str]] self.finals = None # type: List[str] + self.overloads = None # type: Dict[str, List[Signature]] self.tagorder = None # type: Dict[str, int] self.tags = None # type: Dict[str, Tuple[str, int, int]] @@ -163,6 +165,7 @@ class ModuleAnalyzer: self.annotations = parser.annotations self.finals = parser.finals + self.overloads = parser.overloads self.tags = parser.definitions self.tagorder = parser.deforders except Exception as exc: diff --git a/sphinx/pycode/parser.py b/sphinx/pycode/parser.py index 3762c72cc..ec89a3616 100644 --- a/sphinx/pycode/parser.py +++ b/sphinx/pycode/parser.py @@ -12,12 +12,14 @@ import itertools import re import sys import tokenize +from inspect import Signature from token import NAME, NEWLINE, INDENT, DEDENT, NUMBER, OP, STRING from tokenize import COMMENT, NL from typing import Any, Dict, List, Optional, Tuple from sphinx.pycode.ast import ast # for py37 or older from sphinx.pycode.ast import parse, unparse +from sphinx.util.inspect import signature_from_ast comment_re = re.compile('^\\s*#: ?(.*)\r?\n?$') @@ -232,8 +234,10 @@ class VariableCommentPicker(ast.NodeVisitor): self.previous = None # type: ast.AST self.deforders = {} # type: Dict[str, int] self.finals = [] # type: List[str] + self.overloads = {} # type: Dict[str, List[Signature]] self.typing = None # type: str self.typing_final = None # type: str + self.typing_overload = None # type: str super().__init__() def get_qualname_for(self, name: str) -> Optional[List[str]]: @@ -257,6 +261,12 @@ class VariableCommentPicker(ast.NodeVisitor): if qualname: self.finals.append(".".join(qualname)) + def add_overload_entry(self, func: ast.FunctionDef) -> None: + qualname = self.get_qualname_for(func.name) + if qualname: + overloads = self.overloads.setdefault(".".join(qualname), []) + overloads.append(signature_from_ast(func)) + def add_variable_comment(self, name: str, comment: str) -> None: qualname = self.get_qualname_for(name) if qualname: @@ -285,6 +295,22 @@ class VariableCommentPicker(ast.NodeVisitor): return False + def is_overload(self, decorators: List[ast.expr]) -> bool: + overload = [] + if self.typing: + overload.append('%s.overload' % self.typing) + if self.typing_overload: + overload.append(self.typing_overload) + + for decorator in decorators: + try: + if unparse(decorator) in overload: + return True + except NotImplementedError: + pass + + return False + def get_self(self) -> ast.arg: """Returns the name of first argument if in function.""" if self.current_function and self.current_function.args.args: @@ -310,6 +336,8 @@ class VariableCommentPicker(ast.NodeVisitor): self.typing = name.asname or name.name elif name.name == 'typing.final': self.typing_final = name.asname or name.name + elif name.name == 'typing.overload': + self.typing_overload = name.asname or name.name def visit_ImportFrom(self, node: ast.ImportFrom) -> None: """Handles Import node and record it to definition orders.""" @@ -318,6 +346,8 @@ class VariableCommentPicker(ast.NodeVisitor): if node.module == 'typing' and name.name == 'final': self.typing_final = name.asname or name.name + elif node.module == 'typing' and name.name == 'overload': + self.typing_overload = name.asname or name.name def visit_Assign(self, node: ast.Assign) -> None: """Handles Assign node and pick up a variable comment.""" @@ -417,6 +447,8 @@ class VariableCommentPicker(ast.NodeVisitor): self.add_entry(node.name) # should be called before setting self.current_function if self.is_final(node.decorator_list): self.add_final_entry(node.name) + if self.is_overload(node.decorator_list): + self.add_overload_entry(node) self.context.append(node.name) self.current_function = node for child in node.body: @@ -518,6 +550,7 @@ class Parser: self.deforders = {} # type: Dict[str, int] self.definitions = {} # type: Dict[str, Tuple[str, int, int]] self.finals = [] # type: List[str] + self.overloads = {} # type: Dict[str, List[Signature]] def parse(self) -> None: """Parse the source code.""" @@ -533,6 +566,7 @@ class Parser: self.comments = picker.comments self.deforders = picker.deforders self.finals = picker.finals + self.overloads = picker.overloads def parse_definition(self) -> None: """Parse the location of definitions from the code.""" diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index 0f3f47562..d4928c847 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -527,10 +527,14 @@ def stringify_signature(sig: inspect.Signature, show_annotation: bool = True, def signature_from_str(signature: str) -> inspect.Signature: """Create a Signature object from string.""" module = ast.parse('def func' + signature + ': pass') - definition = cast(ast.FunctionDef, module.body[0]) # type: ignore + function = cast(ast.FunctionDef, module.body[0]) # type: ignore - # parameters - args = definition.args + return signature_from_ast(function) + + +def signature_from_ast(node: ast.FunctionDef) -> inspect.Signature: + """Create a Signature object from AST *node*.""" + args = node.args defaults = list(args.defaults) params = [] if hasattr(args, "posonlyargs"): @@ -580,7 +584,7 @@ def signature_from_str(signature: str) -> inspect.Signature: params.append(Parameter(args.kwarg.arg, Parameter.VAR_KEYWORD, annotation=annotation)) - return_annotation = ast_unparse(definition.returns) or Parameter.empty + return_annotation = ast_unparse(node.returns) or Parameter.empty return inspect.Signature(params, return_annotation=return_annotation) diff --git a/tests/roots/test-ext-autodoc/target/overload.py b/tests/roots/test-ext-autodoc/target/overload.py new file mode 100644 index 000000000..da43d32eb --- /dev/null +++ b/tests/roots/test-ext-autodoc/target/overload.py @@ -0,0 +1,88 @@ +from typing import Any, overload + + +@overload +def sum(x: int, y: int) -> int: + ... + + +@overload +def sum(x: float, y: float) -> float: + ... + + +@overload +def sum(x: str, y: str) -> str: + ... + + +def sum(x, y): + """docstring""" + return x + y + + +class Math: + """docstring""" + + @overload + def sum(self, x: int, y: int) -> int: + ... + + @overload + def sum(self, x: float, y: float) -> float: + ... + + @overload + def sum(self, x: str, y: str) -> str: + ... + + def sum(self, x, y): + """docstring""" + return x + y + + +class Foo: + """docstring""" + + @overload + def __new__(cls, x: int, y: int) -> "Foo": + ... + + @overload + def __new__(cls, x: str, y: str) -> "Foo": + ... + + def __new__(cls, x, y): + pass + + +class Bar: + """docstring""" + + @overload + def __init__(cls, x: int, y: int) -> None: + ... + + @overload + def __init__(cls, x: str, y: str) -> None: + ... + + def __init__(cls, x, y): + pass + + +class Meta(type): + @overload + def __call__(cls, x: int, y: int) -> Any: + ... + + @overload + def __call__(cls, x: str, y: str) -> Any: + ... + + def __call__(cls, x, y): + pass + + +class Baz(metaclass=Meta): + """docstring""" diff --git a/tests/test_ext_autodoc.py b/tests/test_ext_autodoc.py index 5cc1f3c22..e80e8b357 100644 --- a/tests/test_ext_autodoc.py +++ b/tests/test_ext_autodoc.py @@ -1787,6 +1787,60 @@ def test_final(app): ] +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_overload(app): + options = {"members": None} + actual = do_autodoc(app, 'module', 'target.overload', options) + assert list(actual) == [ + '', + '.. py:module:: target.overload', + '', + '', + '.. py:class:: Bar(x: int, y: int)', + ' Bar(x: str, y: str)', + ' :module: target.overload', + '', + ' docstring', + '', + '', + '.. py:class:: Baz(x: int, y: int)', + ' Baz(x: str, y: str)', + ' :module: target.overload', + '', + ' docstring', + '', + '', + '.. py:class:: Foo(x: int, y: int)', + ' Foo(x: str, y: str)', + ' :module: target.overload', + '', + ' docstring', + '', + '', + '.. py:class:: Math()', + ' :module: target.overload', + '', + ' docstring', + '', + '', + ' .. py:method:: Math.sum(x: int, y: int) -> int', + ' Math.sum(x: float, y: float) -> float', + ' Math.sum(x: str, y: str) -> str', + ' :module: target.overload', + '', + ' docstring', + '', + '', + '.. py:function:: sum(x: int, y: int) -> int', + ' sum(x: float, y: float) -> float', + ' sum(x: str, y: str) -> str', + ' :module: target.overload', + '', + ' docstring', + '', + ] + + @pytest.mark.sphinx('dummy', testroot='ext-autodoc') def test_autodoc(app, status, warning): app.builder.build_all() diff --git a/tests/test_pycode_parser.py b/tests/test_pycode_parser.py index 398c9f8a4..71847f04f 100644 --- a/tests/test_pycode_parser.py +++ b/tests/test_pycode_parser.py @@ -13,6 +13,7 @@ import sys import pytest from sphinx.pycode.parser import Parser +from sphinx.util.inspect import signature_from_str def test_comment_picker_basic(): @@ -452,3 +453,80 @@ def test_typing_final_not_imported(): parser = Parser(source) parser.parse() assert parser.finals == [] + + +def test_typing_overload(): + source = ('import typing\n' + '\n' + '@typing.overload\n' + 'def func(x: int, y: int) -> int: pass\n' + '\n' + '@typing.overload\n' + 'def func(x: str, y: str) -> str: pass\n' + '\n' + 'def func(x, y): pass\n') + parser = Parser(source) + parser.parse() + assert parser.overloads == {'func': [signature_from_str('(x: int, y: int) -> int'), + signature_from_str('(x: str, y: str) -> str')]} + + +def test_typing_overload_from_import(): + source = ('from typing import overload\n' + '\n' + '@overload\n' + 'def func(x: int, y: int) -> int: pass\n' + '\n' + '@overload\n' + 'def func(x: str, y: str) -> str: pass\n' + '\n' + 'def func(x, y): pass\n') + parser = Parser(source) + parser.parse() + assert parser.overloads == {'func': [signature_from_str('(x: int, y: int) -> int'), + signature_from_str('(x: str, y: str) -> str')]} + + +def test_typing_overload_import_as(): + source = ('import typing as foo\n' + '\n' + '@foo.overload\n' + 'def func(x: int, y: int) -> int: pass\n' + '\n' + '@foo.overload\n' + 'def func(x: str, y: str) -> str: pass\n' + '\n' + 'def func(x, y): pass\n') + parser = Parser(source) + parser.parse() + assert parser.overloads == {'func': [signature_from_str('(x: int, y: int) -> int'), + signature_from_str('(x: str, y: str) -> str')]} + + +def test_typing_overload_from_import_as(): + source = ('from typing import overload as bar\n' + '\n' + '@bar\n' + 'def func(x: int, y: int) -> int: pass\n' + '\n' + '@bar\n' + 'def func(x: str, y: str) -> str: pass\n' + '\n' + 'def func(x, y): pass\n') + parser = Parser(source) + parser.parse() + assert parser.overloads == {'func': [signature_from_str('(x: int, y: int) -> int'), + signature_from_str('(x: str, y: str) -> str')]} + + +def test_typing_overload_not_imported(): + source = ('@typing.final\n' + 'def func(x: int, y: int) -> int: pass\n' + '\n' + '@typing.final\n' + 'def func(x: str, y: str) -> str: pass\n' + '\n' + 'def func(x, y): pass\n') + parser = Parser(source) + parser.parse() + assert parser.overloads == {} diff --git a/tests/test_util_inspect.py b/tests/test_util_inspect.py index fa0ff84e1..6a14dc1ac 100644 --- a/tests/test_util_inspect.py +++ b/tests/test_util_inspect.py @@ -9,6 +9,7 @@ """ import _testcapi +import ast import datetime import functools import sys @@ -350,6 +351,38 @@ def test_signature_from_str_invalid(): inspect.signature_from_str('') +def test_signature_from_ast(): + signature = 'def func(a, b, *args, c=0, d="blah", **kwargs): pass' + tree = ast.parse(signature) + sig = inspect.signature_from_ast(tree.body[0]) + assert list(sig.parameters.keys()) == ['a', 'b', 'args', 'c', 'd', 'kwargs'] + assert sig.parameters['a'].name == 'a' + assert sig.parameters['a'].kind == Parameter.POSITIONAL_OR_KEYWORD + assert sig.parameters['a'].default == Parameter.empty + assert sig.parameters['a'].annotation == Parameter.empty + assert sig.parameters['b'].name == 'b' + assert sig.parameters['b'].kind == Parameter.POSITIONAL_OR_KEYWORD + assert sig.parameters['b'].default == Parameter.empty + assert sig.parameters['b'].annotation == Parameter.empty + assert sig.parameters['args'].name == 'args' + assert sig.parameters['args'].kind == Parameter.VAR_POSITIONAL + assert sig.parameters['args'].default == Parameter.empty + assert sig.parameters['args'].annotation == Parameter.empty + assert sig.parameters['c'].name == 'c' + assert sig.parameters['c'].kind == Parameter.KEYWORD_ONLY + assert sig.parameters['c'].default == '0' + assert sig.parameters['c'].annotation == Parameter.empty + assert sig.parameters['d'].name == 'd' + assert sig.parameters['d'].kind == Parameter.KEYWORD_ONLY + assert sig.parameters['d'].default == "'blah'" + assert sig.parameters['d'].annotation == Parameter.empty + assert sig.parameters['kwargs'].name == 'kwargs' + assert sig.parameters['kwargs'].kind == Parameter.VAR_KEYWORD + assert sig.parameters['kwargs'].default == Parameter.empty + assert sig.parameters['kwargs'].annotation == Parameter.empty + assert sig.return_annotation == Parameter.empty + + def test_safe_getattr_with_default(): class Foo: def __getattr__(self, item):