From 8c80cfb894f319fce5fcc11da4b5249d5ae24a8f Mon Sep 17 00:00:00 2001 From: Rui Pinheiro Date: Fri, 10 Oct 2025 16:11:01 +0100 Subject: [PATCH 1/8] Use FORWARDREF format in inspect.signature calls Fixes https://github.com/sphinx-doc/sphinx/issues/13945 --- AUTHORS.rst | 1 + CHANGES.rst | 3 +++ sphinx/util/inspect.py | 22 +++++++++++++++--- tests/test_util/test_util_inspect.py | 15 ++++++++++++ tests/test_util/test_util_inspect_py314.py | 27 ++++++++++++++++++++++ 5 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 tests/test_util/test_util_inspect_py314.py diff --git a/AUTHORS.rst b/AUTHORS.rst index ea363fd118f..f1f6d272dea 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -100,6 +100,7 @@ Contributors * Pauli Virtanen -- autodoc improvements, autosummary extension * Rafael Fontenelle -- internationalisation * \A. Rafey Khan -- improved intersphinx typing +* Rui Pinheiro -- Python 3.14 forward references support * Roland Meister -- epub builder * Sebastian Wiesner -- image handling, distutils support * Slawek Figiel -- additional warning suppression diff --git a/CHANGES.rst b/CHANGES.rst index 792f6ce2201..86d990cb846 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -132,6 +132,9 @@ Bugs fixed directly defined in certain cases, depending on autodoc processing order. Patch by Jeremy Maitin-Shepard. +* #13945: sphinx.ext.autodoc: Fix handling of Python 3.14 forward references in + annotations by using ```annotationlib.Format.FORWARDREF``. + Patch by Rui Pinheiro. Testing diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index 65088c38d05..8dd1f32e370 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -72,6 +72,18 @@ def __call__(self, obj: Any, name: str, default: Any = ..., /) -> Any: ... isclass = inspect.isclass ismodule = inspect.ismodule +# Python 3.14 added the annotationlib module to the standard library as well as the +# 'annotation_format' keyword parameter to inspect.signature(), which allows us to handle +# forward references more robustly. +if sys.version_info[0:2] >= (3, 14): + import annotationlib # type: ignore[import-not-found] + + inspect_signature_extra: dict[str, Any] = { + 'annotation_format': annotationlib.Format.FORWARDREF + } +else: + inspect_signature_extra: dict[str, Any] = {} + def unwrap(obj: Any) -> Any: """Get an original object from wrapped object (wrapped functions). @@ -718,12 +730,16 @@ def signature( try: if _should_unwrap(subject): - signature = inspect.signature(subject) # type: ignore[arg-type] + signature = inspect.signature(subject, **inspect_signature_extra) # type: ignore[arg-type] else: - signature = inspect.signature(subject, follow_wrapped=True) # type: ignore[arg-type] + signature = inspect.signature( + subject, # type: ignore[arg-type] + follow_wrapped=True, + **inspect_signature_extra, + ) except ValueError: # follow built-in wrappers up (ex. functools.lru_cache) - signature = inspect.signature(subject) # type: ignore[arg-type] + signature = inspect.signature(subject, **inspect_signature_extra) # type: ignore[arg-type] parameters = list(signature.parameters.values()) return_annotation = signature.return_annotation diff --git a/tests/test_util/test_util_inspect.py b/tests/test_util/test_util_inspect.py index 2ff37091fd2..0140df39c8f 100644 --- a/tests/test_util/test_util_inspect.py +++ b/tests/test_util/test_util_inspect.py @@ -106,6 +106,14 @@ def wrapper(): return wrapper +def forward_reference_in_args(x: Foo) -> None: # type: ignore[name-defined] # noqa: F821 + pass + + +def forward_reference_in_return() -> Foo: # type: ignore[name-defined] # noqa: F821 + pass + + def test_TypeAliasForwardRef(): alias = TypeAliasForwardRef('example') sig_str = stringify_annotation(alias, 'fully-qualified-except-typing') @@ -164,6 +172,13 @@ def func(a, b, c=1, d=2, *e, **f): sig = inspect.stringify_signature(inspect.signature(func)) assert sig == '(a, b, c=1, d=2, *e, **f)' + # forward references + sig = inspect.stringify_signature(inspect.signature(forward_reference_in_args)) + assert sig == '(x: Foo) -> None' + + sig = inspect.stringify_signature(inspect.signature(forward_reference_in_return)) + assert sig == '() -> Foo' + def test_signature_partial() -> None: def fun(a, b, c=1, d=2): diff --git a/tests/test_util/test_util_inspect_py314.py b/tests/test_util/test_util_inspect_py314.py new file mode 100644 index 00000000000..0a8e7a2884d --- /dev/null +++ b/tests/test_util/test_util_inspect_py314.py @@ -0,0 +1,27 @@ +# noqa: I002 as 'from __future__ import annotations' prevents Python 3.14 +# forward references from causing issues, so we must skip it here. + +import sys + +import pytest + +from sphinx.util import inspect + +if sys.version_info[0:2] < (3, 14): + pytest.skip('These tests are for Python 3.14+', allow_module_level=True) + + +def forward_reference_in_args(x: Foo) -> None: # type: ignore[name-defined] # noqa: F821 + pass + + +def forward_reference_in_return() -> Foo: # type: ignore[name-defined] # noqa: F821 + pass + + +def test_signature_forwardref() -> None: + sig = inspect.stringify_signature(inspect.signature(forward_reference_in_args)) + assert sig == '(x: Foo) -> None' + + sig = inspect.stringify_signature(inspect.signature(forward_reference_in_return)) + assert sig == '() -> Foo' From b8a8ba59a5c3845fca984c8656839ac682b887e3 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Fri, 28 Nov 2025 23:19:11 +0000 Subject: [PATCH 2/8] Improvements --- CHANGES.rst | 6 +-- sphinx/util/inspect.py | 28 ++++++------ .../test-ext-autodoc/target/properties.py | 9 ++++ .../test_ext_autodoc_autoproperty.py | 15 +++++++ tests/test_util/test_util_inspect_py314.py | 43 +++++++++++++++---- 5 files changed, 74 insertions(+), 27 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 42b9b015783..2c29abfada0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -174,9 +174,9 @@ Bugs fixed * #12797: Fix ``Some type variables (...) are not listed in Generic[...]`` TypeError when inheriting from both Generic and autodoc mocked class. Patch by Ikor Jefocur and Daniel Sperber. -* #13945: sphinx.ext.autodoc: Fix handling of Python 3.14 forward references in - annotations by using ```annotationlib.Format.FORWARDREF``. - Patch by Rui Pinheiro. +* #13945: autodoc: Fix handling of undefined names in annotations by using + the ``FORWARDREF`` :mod:`annotationlib` format. + Patch by Rui Pinheiro and Adam Turner. Testing diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index a244d6be620..cc70f785819 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -72,18 +72,6 @@ def __call__(self, obj: Any, name: str, default: Any = ..., /) -> Any: ... isclass = inspect.isclass ismodule = inspect.ismodule -# Python 3.14 added the annotationlib module to the standard library as well as the -# 'annotation_format' keyword parameter to inspect.signature(), which allows us to handle -# forward references more robustly. -if sys.version_info[0:2] >= (3, 14): - import annotationlib # type: ignore[import-not-found] - - inspect_signature_extra: dict[str, Any] = { - 'annotation_format': annotationlib.Format.FORWARDREF - } -else: - inspect_signature_extra: dict[str, Any] = {} - def unwrap(obj: Any) -> Any: """Get an original object from wrapped object (wrapped functions). @@ -722,6 +710,16 @@ def _should_unwrap(subject: _SignatureType) -> bool: ) +# Python 3.14 uses deferred evaluation of annotations by default. +# Using annotationlib's FORWARDREF format gives us more robust handling +# of forward references in type annotations. +signature_kwds: dict[str, Any] = {} +if sys.version_info[:2] >= (3, 14): + import annotationlib # type: ignore[import-not-found] + + signature_kwds['annotation_format'] = annotationlib.Format.FORWARDREF + + def signature( subject: _SignatureType, bound_method: bool = False, @@ -738,16 +736,16 @@ def signature( try: if _should_unwrap(subject): - signature = inspect.signature(subject, **inspect_signature_extra) # type: ignore[arg-type] + signature = inspect.signature(subject, **signature_kwds) # type: ignore[arg-type] else: signature = inspect.signature( subject, # type: ignore[arg-type] follow_wrapped=True, - **inspect_signature_extra, + **signature_kwds, ) except ValueError: # follow built-in wrappers up (ex. functools.lru_cache) - signature = inspect.signature(subject, **inspect_signature_extra) # type: ignore[arg-type] + signature = inspect.signature(subject, **signature_kwds) # type: ignore[arg-type] parameters = list(signature.parameters.values()) return_annotation = signature.return_annotation diff --git a/tests/roots/test-ext-autodoc/target/properties.py b/tests/roots/test-ext-autodoc/target/properties.py index 84d1c2a1a19..9e3de9dd503 100644 --- a/tests/roots/test-ext-autodoc/target/properties.py +++ b/tests/roots/test-ext-autodoc/target/properties.py @@ -1,3 +1,8 @@ +TYPE_CHECKING = False +if TYPE_CHECKING: + TypeCheckingOnlyName = int + + class Foo: """docstring""" @@ -20,3 +25,7 @@ def prop1_with_type_comment(self): def prop2_with_type_comment(cls): # type: () -> int """docstring""" + + @property + def prop3_with_undefined_anotation(self) -> TypeCheckingOnlyName: + """docstring""" diff --git a/tests/test_ext_autodoc/test_ext_autodoc_autoproperty.py b/tests/test_ext_autodoc/test_ext_autodoc_autoproperty.py index 7fdd57ad7f8..a4dc70c9352 100644 --- a/tests/test_ext_autodoc/test_ext_autodoc_autoproperty.py +++ b/tests/test_ext_autodoc/test_ext_autodoc_autoproperty.py @@ -87,3 +87,18 @@ def test_cached_properties_with_type_comment() -> None: ' :type: int', '', ] + + +def test_property_with_undefined_annotation() -> None: + actual = do_autodoc( + 'property', 'target.properties.Foo.prop3_with_undefined_anotation' + ) + assert actual == [ + '', + '.. py:property:: Foo.prop3_with_undefined_anotation', + ' :module: target.properties', + ' :type: TypeCheckingOnlyName', + '', + ' docstring', + '', + ] diff --git a/tests/test_util/test_util_inspect_py314.py b/tests/test_util/test_util_inspect_py314.py index 0a8e7a2884d..dbda82a53b2 100644 --- a/tests/test_util/test_util_inspect_py314.py +++ b/tests/test_util/test_util_inspect_py314.py @@ -1,14 +1,15 @@ # noqa: I002 as 'from __future__ import annotations' prevents Python 3.14 # forward references from causing issues, so we must skip it here. -import sys - import pytest from sphinx.util import inspect +from sphinx.util.typing import stringify_annotation -if sys.version_info[0:2] < (3, 14): - pytest.skip('These tests are for Python 3.14+', allow_module_level=True) +try: + from annotationlib import ForwardRef # type: ignore[import-not-found] +except ImportError: + pytest.skip('Requires annotationlib (Python 3.14+).', allow_module_level=True) def forward_reference_in_args(x: Foo) -> None: # type: ignore[name-defined] # noqa: F821 @@ -19,9 +20,33 @@ def forward_reference_in_return() -> Foo: # type: ignore[name-defined] # noqa: pass -def test_signature_forwardref() -> None: - sig = inspect.stringify_signature(inspect.signature(forward_reference_in_args)) - assert sig == '(x: Foo) -> None' +def test_signature_forwardref_in_args() -> None: + sig = inspect.signature(forward_reference_in_args) + + assert sig.return_annotation is None + + assert len(sig.parameters) + assert 'x' in sig.parameters + param = sig.parameters['x'] + + ann = param.annotation + assert isinstance(ann, ForwardRef) + assert ann.__arg__ == 'Foo' + assert ann.__forward_is_class__ is False + assert ann.__forward_module__ is None + assert ann.__owner__ is not None + assert stringify_annotation(ann) == 'Foo' + + +def test_signature_forwardref_in_return() -> None: + sig = inspect.signature(forward_reference_in_return) + + assert sig.parameters == {} - sig = inspect.stringify_signature(inspect.signature(forward_reference_in_return)) - assert sig == '() -> Foo' + ann = sig.return_annotation + assert isinstance(ann, ForwardRef) + assert ann.__arg__ == 'Foo' + assert ann.__forward_is_class__ is False + assert ann.__forward_module__ is None + assert ann.__owner__ is not None + assert stringify_annotation(ann) == 'Foo' From 84be582f3daf20efaa960db98856d56156247ba3 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Fri, 28 Nov 2025 23:24:48 +0000 Subject: [PATCH 3/8] Fix test_properties() --- tests/roots/test-ext-autodoc/target/properties.py | 4 +++- tests/test_ext_autodoc/test_ext_autodoc_autoclass.py | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/roots/test-ext-autodoc/target/properties.py b/tests/roots/test-ext-autodoc/target/properties.py index 9e3de9dd503..7f04b70c436 100644 --- a/tests/roots/test-ext-autodoc/target/properties.py +++ b/tests/roots/test-ext-autodoc/target/properties.py @@ -1,5 +1,7 @@ +import sys + TYPE_CHECKING = False -if TYPE_CHECKING: +if sys.version_info[:2] >= (3, 14) and TYPE_CHECKING: TypeCheckingOnlyName = int diff --git a/tests/test_ext_autodoc/test_ext_autodoc_autoclass.py b/tests/test_ext_autodoc/test_ext_autodoc_autoclass.py index b479094c6be..cfc2bef21a7 100644 --- a/tests/test_ext_autodoc/test_ext_autodoc_autoclass.py +++ b/tests/test_ext_autodoc/test_ext_autodoc_autoclass.py @@ -247,6 +247,13 @@ def test_properties() -> None: '', ' docstring', '', + '', + ' .. py:property:: Foo.prop3_with_undefined_anotation', + ' :module: target.properties', + ' :type: TypeCheckingOnlyName', + '', + ' docstring', + '', ] From 6cf6c642bef44f27b1e0015c0671c82439269d52 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Sat, 29 Nov 2025 00:58:39 +0000 Subject: [PATCH 4/8] fixup! Fix test_properties() --- tests/roots/test-ext-autodoc/target/properties.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/roots/test-ext-autodoc/target/properties.py b/tests/roots/test-ext-autodoc/target/properties.py index 7f04b70c436..071bf182b55 100644 --- a/tests/roots/test-ext-autodoc/target/properties.py +++ b/tests/roots/test-ext-autodoc/target/properties.py @@ -1,7 +1,7 @@ import sys TYPE_CHECKING = False -if sys.version_info[:2] >= (3, 14) and TYPE_CHECKING: +if sys.version_info[:2] < (3, 14) or TYPE_CHECKING: TypeCheckingOnlyName = int From 84d002be230f70999d3916a496565a70aa22a256 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Sat, 29 Nov 2025 01:04:25 +0000 Subject: [PATCH 5/8] fixup! Fix test_properties() --- tests/roots/test-ext-autodoc/target/properties.py | 4 +--- tests/test_ext_autodoc/test_ext_autodoc_autoproperty.py | 6 ++++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/roots/test-ext-autodoc/target/properties.py b/tests/roots/test-ext-autodoc/target/properties.py index 071bf182b55..9e3de9dd503 100644 --- a/tests/roots/test-ext-autodoc/target/properties.py +++ b/tests/roots/test-ext-autodoc/target/properties.py @@ -1,7 +1,5 @@ -import sys - TYPE_CHECKING = False -if sys.version_info[:2] < (3, 14) or TYPE_CHECKING: +if TYPE_CHECKING: TypeCheckingOnlyName = int diff --git a/tests/test_ext_autodoc/test_ext_autodoc_autoproperty.py b/tests/test_ext_autodoc/test_ext_autodoc_autoproperty.py index a4dc70c9352..3e149f2a06b 100644 --- a/tests/test_ext_autodoc/test_ext_autodoc_autoproperty.py +++ b/tests/test_ext_autodoc/test_ext_autodoc_autoproperty.py @@ -6,6 +6,8 @@ from __future__ import annotations +import sys + import pytest from tests.test_ext_autodoc.autodoc_util import do_autodoc @@ -89,6 +91,10 @@ def test_cached_properties_with_type_comment() -> None: ] +@pytest.mark.skipif( + sys.version_info[:2] < (3, 14), + reason='deferred evaluation of annotations introduced in Python 3.14', +) def test_property_with_undefined_annotation() -> None: actual = do_autodoc( 'property', 'target.properties.Foo.prop3_with_undefined_anotation' From 84b1667ae010087db241f517378315a09ae80284 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Sat, 29 Nov 2025 01:09:51 +0000 Subject: [PATCH 6/8] fixup! Fix test_properties() --- tests/roots/test-ext-autodoc/target/properties.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/roots/test-ext-autodoc/target/properties.py b/tests/roots/test-ext-autodoc/target/properties.py index 9e3de9dd503..071bf182b55 100644 --- a/tests/roots/test-ext-autodoc/target/properties.py +++ b/tests/roots/test-ext-autodoc/target/properties.py @@ -1,5 +1,7 @@ +import sys + TYPE_CHECKING = False -if TYPE_CHECKING: +if sys.version_info[:2] < (3, 14) or TYPE_CHECKING: TypeCheckingOnlyName = int From f93072c2006240c5e15f30fd555c2fd607557084 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Sat, 29 Nov 2025 01:14:14 +0000 Subject: [PATCH 7/8] fixup! Fix test_properties() --- tests/test_ext_autodoc/test_ext_autodoc_autoclass.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_ext_autodoc/test_ext_autodoc_autoclass.py b/tests/test_ext_autodoc/test_ext_autodoc_autoclass.py index cfc2bef21a7..acecfce0e53 100644 --- a/tests/test_ext_autodoc/test_ext_autodoc_autoclass.py +++ b/tests/test_ext_autodoc/test_ext_autodoc_autoclass.py @@ -6,6 +6,7 @@ from __future__ import annotations +import sys import typing import pytest @@ -208,6 +209,11 @@ def test_decorators() -> None: def test_properties() -> None: + if sys.version_info[:2] >= (3, 14): + type_checking_only_name = 'TypeCheckingOnlyName' + else: + type_checking_only_name = 'int' + options = {'members': None} actual = do_autodoc('class', 'target.properties.Foo', options=options) assert actual == [ @@ -250,7 +256,7 @@ def test_properties() -> None: '', ' .. py:property:: Foo.prop3_with_undefined_anotation', ' :module: target.properties', - ' :type: TypeCheckingOnlyName', + f' :type: {type_checking_only_name}', '', ' docstring', '', From 38bb71bf5381eaa8fc3a7ed904975ceb9357c418 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Sat, 29 Nov 2025 01:14:43 +0000 Subject: [PATCH 8/8] fixup! Fix test_properties() --- .../test_ext_autodoc/test_ext_autodoc_autoproperty.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/test_ext_autodoc/test_ext_autodoc_autoproperty.py b/tests/test_ext_autodoc/test_ext_autodoc_autoproperty.py index 3e149f2a06b..8042a1ff310 100644 --- a/tests/test_ext_autodoc/test_ext_autodoc_autoproperty.py +++ b/tests/test_ext_autodoc/test_ext_autodoc_autoproperty.py @@ -91,11 +91,12 @@ def test_cached_properties_with_type_comment() -> None: ] -@pytest.mark.skipif( - sys.version_info[:2] < (3, 14), - reason='deferred evaluation of annotations introduced in Python 3.14', -) def test_property_with_undefined_annotation() -> None: + if sys.version_info[:2] >= (3, 14): + type_checking_only_name = 'TypeCheckingOnlyName' + else: + type_checking_only_name = 'int' + actual = do_autodoc( 'property', 'target.properties.Foo.prop3_with_undefined_anotation' ) @@ -103,7 +104,7 @@ def test_property_with_undefined_annotation() -> None: '', '.. py:property:: Foo.prop3_with_undefined_anotation', ' :module: target.properties', - ' :type: TypeCheckingOnlyName', + f' :type: {type_checking_only_name}', '', ' docstring', '',