diff --git a/CHANGES.rst b/CHANGES.rst index fede8b5177b..66f8f8f2491 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -19,6 +19,9 @@ Features added * #13439: linkcheck: Permit warning on every redirect with ``linkcheck_allowed_redirects = {}``. Patch by Adam Turner. +* #13704: Autodoc: Detect :py:func:`typing_extensions.overload ` + and :py:func:`~typing.final` decorators. + Patch by Spencer Brown. Bugs fixed ---------- diff --git a/sphinx/pycode/parser.py b/sphinx/pycode/parser.py index 2390b19d4d3..ed56f1d8dc4 100644 --- a/sphinx/pycode/parser.py +++ b/sphinx/pycode/parser.py @@ -247,9 +247,9 @@ def __init__(self, buffers: list[str], encoding: str) -> None: self.deforders: dict[str, int] = {} self.finals: list[str] = [] self.overloads: dict[str, list[Signature]] = {} - self.typing: str | None = None - self.typing_final: str | None = None - self.typing_overload: str | None = None + self.typing_mods: set[str] = set() + self.typing_final_names: set[str] = set() + self.typing_overload_names: set[str] = set() super().__init__() def get_qualname_for(self, name: str) -> list[str] | None: @@ -295,12 +295,9 @@ def add_variable_annotation(self, name: str, annotation: ast.AST) -> None: self.annotations[basename, name] = ast_unparse(annotation) def is_final(self, decorators: list[ast.expr]) -> bool: - final = [] - if self.typing: - final.append('%s.final' % self.typing) - if self.typing_final: - final.append(self.typing_final) - + final = self.typing_final_names | { + f'{modname}.final' for modname in self.typing_mods + } for decorator in decorators: try: if ast_unparse(decorator) in final: @@ -311,11 +308,9 @@ def is_final(self, decorators: list[ast.expr]) -> bool: 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) + overload = self.typing_overload_names | { + f'{modname}.overload' for modname in self.typing_mods + } for decorator in decorators: try: @@ -348,22 +343,24 @@ def visit_Import(self, node: ast.Import) -> None: for name in node.names: self.add_entry(name.asname or name.name) - if name.name == 'typing': - 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 + if name.name in {'typing', 'typing_extensions'}: + self.typing_mods.add(name.asname or name.name) + elif name.name in {'typing.final', 'typing_extensions.final'}: + self.typing_final_names.add(name.asname or name.name) + elif name.name in {'typing.overload', 'typing_extensions.overload'}: + self.typing_overload_names.add(name.asname or name.name) def visit_ImportFrom(self, node: ast.ImportFrom) -> None: """Handles Import node and record the order of definitions.""" for name in node.names: self.add_entry(name.asname or name.name) - 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 + if node.module not in {'typing', 'typing_extensions'}: + continue + if name.name == 'final': + self.typing_final_names.add(name.asname or name.name) + elif name.name == 'overload': + self.typing_overload_names.add(name.asname or name.name) def visit_Assign(self, node: ast.Assign) -> None: """Handles Assign node and pick up a variable comment.""" diff --git a/tests/roots/test-ext-autodoc/target/final.py b/tests/roots/test-ext-autodoc/target/final.py index a8c3860e384..bd233abb580 100644 --- a/tests/roots/test-ext-autodoc/target/final.py +++ b/tests/roots/test-ext-autodoc/target/final.py @@ -3,6 +3,9 @@ import typing from typing import final +import typing_extensions +from typing_extensions import final as final_ext # noqa: UP035 + @typing.final class Class: @@ -14,3 +17,11 @@ def meth1(self): def meth2(self): """docstring""" + + @final_ext + def meth3(self): + """docstring""" + + @typing_extensions.final + def meth4(self): + """docstring""" diff --git a/tests/roots/test-ext-autodoc/target/overload3.py b/tests/roots/test-ext-autodoc/target/overload3.py new file mode 100644 index 00000000000..a3cc34a9f85 --- /dev/null +++ b/tests/roots/test-ext-autodoc/target/overload3.py @@ -0,0 +1,18 @@ +import typing +from typing import TYPE_CHECKING, overload + +import typing_extensions +from typing_extensions import overload as over_ext # noqa: UP035 + + +@overload +def test(x: int) -> int: ... +@typing.overload +def test(x: list[int]) -> list[int]: ... +@over_ext +def test(x: str) -> str: ... +@typing_extensions.overload +def test(x: float) -> float: ... +def test(x): + """Documentation.""" + return x diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py index a06c1bbe30d..80214bd283d 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -2819,6 +2819,20 @@ def test_final(app): '', ' docstring', '', + '', + ' .. py:method:: Class.meth3()', + ' :module: target.final', + ' :final:', + '', + ' docstring', + '', + '', + ' .. py:method:: Class.meth4()', + ' :module: target.final', + ' :final:', + '', + ' docstring', + '', ] @@ -2892,6 +2906,26 @@ def test_overload2(app): ] +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_overload3(app): + options = {'members': None} + actual = do_autodoc(app, 'module', 'target.overload3', options) + assert list(actual) == [ + '', + '.. py:module:: target.overload3', + '', + '', + '.. py:function:: test(x: int) -> int', + ' test(x: list[int]) -> list[int]', + ' test(x: str) -> str', + ' test(x: float) -> float', + ' :module: target.overload3', + '', + ' Documentation.', + '', + ] + + @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_pymodule_for_ModuleLevelDocumenter(app): app.env.ref_context['py:module'] = 'target.classes'