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..e9dd6bf9345 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 @@ -220,11 +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) - spec.loader.exec_module(module) + 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 e69de29bb2d..7e1536dcf5f 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,2 @@ +class Fish: + def __init__(self, other: Fish) -> None: ... diff --git a/tests/test_ext_autodoc/test_ext_autodoc_autofunction.py b/tests/test_ext_autodoc/test_ext_autodoc_autofunction.py index 485329ebb37..1095593de34 100644 --- a/tests/test_ext_autodoc/test_ext_autodoc_autofunction.py +++ b/tests/test_ext_autodoc/test_ext_autodoc_autofunction.py @@ -6,6 +6,9 @@ from __future__ import annotations +import re +import time + import pytest from tests.test_ext_autodoc.autodoc_util import do_autodoc @@ -122,10 +125,16 @@ def test_singledispatch() -> None: def test_cfunction() -> None: + # 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 == [ '', - '.. 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'.", 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'