From 021e471692962033bf72fde627a57064a42b28d3 Mon Sep 17 00:00:00 2001 From: bfs2021 <101915083+bfs2021@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:09:46 +0800 Subject: [PATCH 1/4] autodoc: Execute .pyi stubs with deferred annotation semantics (#14354) --- CHANGES.rst | 5 ++++ sphinx/ext/autodoc/_dynamic/_importer.py | 28 ++++++++++++++++++- .../fish_licence/halibut.pyi | 4 +++ .../test_ext_autodoc_importer.py | 17 +++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index ad6d698341a..64e7554158b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,11 @@ Release 9.1.1 (in development) Bugs fixed ---------- +* #14354: autodoc: execute PEP 561 ``.pyi`` stub files with deferred + annotation semantics, as if they began with ``from __future__ import + annotations``, so that forward references in stub annotations no longer + raise ``NameError`` at import time. + * #14465: LaTeX: PDF build crash since LaTeX June 2026 release if tables are styled with ``'colorrows'`` (which is the default). Patch by Jean-François B. diff --git a/sphinx/ext/autodoc/_dynamic/_importer.py b/sphinx/ext/autodoc/_dynamic/_importer.py index 10d885a0800..6826384e4a7 100644 --- a/sphinx/ext/autodoc/_dynamic/_importer.py +++ b/sphinx/ext/autodoc/_dynamic/_importer.py @@ -1,6 +1,7 @@ """Importer utilities for autodoc""" from __future__ import annotations +import __future__ import contextlib import importlib @@ -37,6 +38,17 @@ def __call__(self, obj: Any, name: str, default: Any = ..., /) -> Any: ... _NATIVE_SUFFIXES: frozenset[str] = frozenset({'.pyx', *EXTENSION_SUFFIXES}) +# ``CO_FUTURE_ANNOTATIONS`` is a ``_Feature`` instance (holding the flag in +# ``.compiler_flag``) on some Python versions and a plain flag integer on +# others. +_CO_FUTURE_ANNOTATIONS = int( + getattr( + __future__.CO_FUTURE_ANNOTATIONS, + 'compiler_flag', + __future__.CO_FUTURE_ANNOTATIONS, + ) +) + class _ImportedObject: #: module containing the object to document @@ -224,7 +236,21 @@ def _import_module(modname: str, try_reload: bool = False) -> Any: msg = 'missing loader' raise ImportError(msg, name=spec.name) # NoQA: TRY301 sys.modules[modname] = module = module_from_spec(spec) - spec.loader.exec_module(module) + # PEP 484 requires stub files to be treated as if all type + # annotations were lazily evaluated, i.e. as if each file began + # with ``from __future__ import annotations``. Executing the stub + # as a plain module would otherwise fail on forward references + # (https://github.com/sphinx-doc/sphinx/issues/14354). + source = spec.loader.get_source(modname) + code = compile( + source, + str(pyi_path), + 'exec', + flags=_CO_FUTURE_ANNOTATIONS, + dont_inherit=True, + ) + # Executing the stub is the whole purpose of this loader. + exec(code, module.__dict__) # noqa: S102 except ImportError: raise except BaseException as exc: diff --git a/tests/roots/test-ext-apidoc-duplicates/fish_licence/halibut.pyi b/tests/roots/test-ext-apidoc-duplicates/fish_licence/halibut.pyi index e69de29bb2d..a50b92497a9 100644 --- a/tests/roots/test-ext-apidoc-duplicates/fish_licence/halibut.pyi +++ b/tests/roots/test-ext-apidoc-duplicates/fish_licence/halibut.pyi @@ -0,0 +1,4 @@ +class Fish: + """A fish.""" + + def __init__(self, other: Fish) -> None: ... diff --git a/tests/test_ext_autodoc/test_ext_autodoc_importer.py b/tests/test_ext_autodoc/test_ext_autodoc_importer.py index 8142af0282d..b437b5bd192 100644 --- a/tests/test_ext_autodoc/test_ext_autodoc_importer.py +++ b/tests/test_ext_autodoc/test_ext_autodoc_importer.py @@ -20,3 +20,20 @@ def test_import_native_module_stubs(rootdir: Path) -> None: halibut_path = Path(halibut.__file__).resolve() assert halibut_path.is_file() assert halibut_path == fish_licence_root / 'fish_licence' / 'halibut.pyi' + + +def test_import_native_module_stubs_defer_annotations(rootdir: Path) -> None: + # PEP 484: stub files are compiled as if they began with + # ``from __future__ import annotations``, so forward references in + # annotations must not fail at import time (issue #14354). + fish_licence_root = rootdir / 'test-ext-apidoc-duplicates' + + sys_path = list(sys.path) + sys.path.insert(0, str(fish_licence_root)) + try: + halibut = _import_module('fish_licence.halibut') + finally: + sys.path[:] = sys_path + + annotations = halibut.Fish.__init__.__annotations__ + assert annotations['other'] == 'Fish' From 06abe875b7901cbc3e02467452642e52f7b2efdd Mon Sep 17 00:00:00 2001 From: bfs2021 <101915083+bfs2021@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:13:33 +0800 Subject: [PATCH 2/4] autodoc: Handle missing CO_FUTURE_ANNOTATIONS on Python 3.14 and fix lint failures --- sphinx/ext/autodoc/_dynamic/_importer.py | 54 +++++++++---------- .../fish_licence/halibut.pyi | 2 - 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/sphinx/ext/autodoc/_dynamic/_importer.py b/sphinx/ext/autodoc/_dynamic/_importer.py index 6826384e4a7..e9dd6bf9345 100644 --- a/sphinx/ext/autodoc/_dynamic/_importer.py +++ b/sphinx/ext/autodoc/_dynamic/_importer.py @@ -38,17 +38,6 @@ def __call__(self, obj: Any, name: str, default: Any = ..., /) -> Any: ... _NATIVE_SUFFIXES: frozenset[str] = frozenset({'.pyx', *EXTENSION_SUFFIXES}) -# ``CO_FUTURE_ANNOTATIONS`` is a ``_Feature`` instance (holding the flag in -# ``.compiler_flag``) on some Python versions and a plain flag integer on -# others. -_CO_FUTURE_ANNOTATIONS = int( - getattr( - __future__.CO_FUTURE_ANNOTATIONS, - 'compiler_flag', - __future__.CO_FUTURE_ANNOTATIONS, - ) -) - class _ImportedObject: #: module containing the object to document @@ -232,25 +221,36 @@ def _import_module(modname: str, try_reload: bool = False) -> Any: if skip_pyi or pyi_path is None: module = importlib.import_module(modname) else: - if spec.loader is None: + loader = spec.loader + if loader is None: msg = 'missing loader' raise ImportError(msg, name=spec.name) # NoQA: TRY301 sys.modules[modname] = module = module_from_spec(spec) - # PEP 484 requires stub files to be treated as if all type - # annotations were lazily evaluated, i.e. as if each file began - # with ``from __future__ import annotations``. Executing the stub - # as a plain module would otherwise fail on forward references - # (https://github.com/sphinx-doc/sphinx/issues/14354). - source = spec.loader.get_source(modname) - code = compile( - source, - str(pyi_path), - 'exec', - flags=_CO_FUTURE_ANNOTATIONS, - dont_inherit=True, - ) - # Executing the stub is the whole purpose of this loader. - exec(code, module.__dict__) # noqa: S102 + if isinstance(loader, _StubFileLoader): + # PEP 484 requires stub files to be treated as if all type + # annotations were lazily evaluated, i.e. as if each file began + # with ``from __future__ import annotations``. Executing the stub + # as a plain module would otherwise fail on forward references + # (https://github.com/sphinx-doc/sphinx/issues/14354). Python 3.14 + # defers annotations by default, so the flag is only applied when + # the running Python provides it. + feature = getattr(__future__, 'CO_FUTURE_ANNOTATIONS', None) + flags = ( + int(getattr(feature, 'compiler_flag', feature)) + if feature is not None + else 0 + ) + code = compile( + loader.get_source(modname), + str(pyi_path), + 'exec', + flags=flags, + dont_inherit=True, + ) + # Executing the stub is the whole purpose of this loader. + exec(code, module.__dict__) # noqa: S102 + else: + loader.exec_module(module) except ImportError: raise except BaseException as exc: diff --git a/tests/roots/test-ext-apidoc-duplicates/fish_licence/halibut.pyi b/tests/roots/test-ext-apidoc-duplicates/fish_licence/halibut.pyi index a50b92497a9..7e1536dcf5f 100644 --- a/tests/roots/test-ext-apidoc-duplicates/fish_licence/halibut.pyi +++ b/tests/roots/test-ext-apidoc-duplicates/fish_licence/halibut.pyi @@ -1,4 +1,2 @@ class Fish: - """A fish.""" - def __init__(self, other: Fish) -> None: ... From c8db738957daac0de6c0d93a71efc8c8844207ea Mon Sep 17 00:00:00 2001 From: bfs2021 <101915083+bfs2021@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:18:31 +0800 Subject: [PATCH 3/4] Fix test_cfunction for the time.asctime argument rename on Python 3.13/3.14 --- tests/test_ext_autodoc/test_ext_autodoc_autofunction.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_ext_autodoc/test_ext_autodoc_autofunction.py b/tests/test_ext_autodoc/test_ext_autodoc_autofunction.py index 485329ebb37..dd8d3585b7b 100644 --- a/tests/test_ext_autodoc/test_ext_autodoc_autofunction.py +++ b/tests/test_ext_autodoc/test_ext_autodoc_autofunction.py @@ -6,6 +6,8 @@ from __future__ import annotations +import sys + import pytest from tests.test_ext_autodoc.autodoc_util import do_autodoc @@ -122,10 +124,13 @@ def test_singledispatch() -> None: def test_cfunction() -> None: + # CPython 3.13 and 3.14 rename the argument in time.asctime()'s + # signature to 'time_tuple'; other versions use 'tuple'. + asctime_arg = 'time_tuple' if (3, 13) <= sys.version_info[:2] < (3, 15) else 'tuple' actual = do_autodoc('function', 'time.asctime') assert actual == [ '', - '.. py:function:: asctime([tuple]) -> string', + f'.. py:function:: asctime([{asctime_arg}]) -> string', ' :module: time', '', " Convert a time tuple to a string, e.g. 'Sat Jun 06 16:26:11 1998'.", From 15d0075ad2c3d5717a26d5668e368a0c811f7cd0 Mon Sep 17 00:00:00 2001 From: bfs2021 <101915083+bfs2021@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:29:34 +0800 Subject: [PATCH 4/4] Derive the time.asctime argument name from the docstring in test_cfunction The parameter name differs between CPython builds and patch releases ('tuple' vs 'time_tuple'), so a version-range check cannot be reliable. Derive the expected name from the running interpreter's docstring, which is exactly what autodoc renders. --- .../test_ext_autodoc_autofunction.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_ext_autodoc/test_ext_autodoc_autofunction.py b/tests/test_ext_autodoc/test_ext_autodoc_autofunction.py index dd8d3585b7b..1095593de34 100644 --- a/tests/test_ext_autodoc/test_ext_autodoc_autofunction.py +++ b/tests/test_ext_autodoc/test_ext_autodoc_autofunction.py @@ -6,7 +6,8 @@ from __future__ import annotations -import sys +import re +import time import pytest @@ -124,9 +125,12 @@ def test_singledispatch() -> None: def test_cfunction() -> None: - # CPython 3.13 and 3.14 rename the argument in time.asctime()'s - # signature to 'time_tuple'; other versions use 'tuple'. - asctime_arg = 'time_tuple' if (3, 13) <= sys.version_info[:2] < (3, 15) else 'tuple' + # CPython has renamed the argument in time.asctime()'s signature + # between releases ('tuple' vs 'time_tuple'); autodoc renders whatever + # the running interpreter's docstring says, so derive it at runtime. + match = re.search(r'asctime\(\[(\w+)\]\)', time.asctime.__doc__ or '') + assert match is not None + asctime_arg = match.group(1) actual = do_autodoc('function', 'time.asctime') assert actual == [ '',