From 2fef298da1944a95e496ed0fc1f4d03020e88ea1 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 16 Oct 2026 20:04:45 -0400 Subject: [PATCH 1/2] docs(conf): build with sphinx-autodoc-typehints 3.15.0 (#1856) - Pin sphinx-autodoc-typehints to 3.15.0 in docs/requirements.txt. - Import resolve_type_guarded_imports from _resolve from 3.14 on and from _resolver before it, chosen by the installed release, so each branch is a plain import that still fails loudly if the private API moves again. - From 3.14 the library records a module's TYPE_CHECKING names instead of binding them, so Sphinx's own signature introspection lost them and schema signatures linked the wrong _VT; bind_guarded_names binds them again. - Run bind_type_checking_names on env-before-read-docs, after the library clears its guard record there. - Connect claim_attribute_signature only before 3.14, whose handler no longer returns a signature for an attribute. - Add tests/project/test_docs_typehints_pin_1856_unit.py. Closes #1856 --- docs/requirements.txt | 2 +- docs/source/conf.py | 92 +++++++++++-- .../test_docs_typehints_pin_1856_unit.py | 130 ++++++++++++++++++ 3 files changed, 212 insertions(+), 12 deletions(-) create mode 100644 tests/project/test_docs_typehints_pin_1856_unit.py diff --git a/docs/requirements.txt b/docs/requirements.txt index 6b18b101e..602c810b3 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,7 +1,7 @@ Sphinx==9.1.0 # extentions -sphinx-autodoc-typehints==3.13.9 +sphinx-autodoc-typehints==3.15.0 furo==2025.12.19 sphinxext-opengraph==0.13.0 sphinx-copybutton==0.5.2 diff --git a/docs/source/conf.py b/docs/source/conf.py index 2afbe35fa..9e822ed52 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -8,6 +8,7 @@ import datetime import enum import importlib +import importlib.metadata import logging import os import pkgutil @@ -15,12 +16,30 @@ from sphinx import addnodes -# NB: a private module of ``sphinx-autodoc-typehints``, used deliberately -- see -# ``bind_type_checking_names`` below for what it buys and why reimplementing it -# here would be worse. If a future release moves it the import fails loudly at +#: The installed ``sphinx-autodoc-typehints`` release, as ``(major, minor)``. +TYPEHINTS_RELEASE = tuple(int(part) for part in + importlib.metadata.version('sphinx-autodoc-typehints').split('.')[:2]) + +# NB: private modules of ``sphinx-autodoc-typehints``, used deliberately -- see +# ``bind_type_checking_names`` below for what they buy and why reimplementing them +# here would be worse. If a future release moves them the import fails loudly at # build time, which is the outcome to want: the alternative is a build that # silently goes back to emitting unresolvable annotations. -from sphinx_autodoc_typehints._resolver import resolve_type_guarded_imports +# +# The branch is chosen by the installed release, not by probing for the module, so +# each branch is a plain import that still fails loudly. 3.14.0 replaced the +# ``_resolver`` module with the ``_resolve`` package, and ``docs/requirements.txt`` +# pins a release after that. The older branch serves an environment that has an +# earlier release beside Sphinx, where ``tests/project`` loads this file to test +# its hooks. +if TYPEHINTS_RELEASE >= (3, 14): + from sphinx_autodoc_typehints._resolve import guarded_names, resolve_type_guarded_imports +else: + from sphinx_autodoc_typehints._resolver import resolve_type_guarded_imports + + def guarded_names(obj: 'Any') -> 'Dict[str, Any]': # pylint: disable=unused-argument + """Nothing: before 3.14, :func:`resolve_type_guarded_imports` binds the names itself.""" + return {} # NB: this must precede ``import pcapkit``. ``pcapkit.utilities.logging`` reads # ``PCAPKIT_SPHINX`` at *import* time to compute ``SPHINX_TYPE_CHECKING``, and @@ -32,9 +51,11 @@ import pcapkit if TYPE_CHECKING: + from types import ModuleType from typing import Any, Dict, List, Optional from docutils.nodes import document from sphinx.application import Sphinx + from sphinx.environment import BuildEnvironment logger = logging.getLogger('pcapkit-sphinx') logger.setLevel(logging.INFO) @@ -341,7 +362,38 @@ def skip_enum_value_hook(app: 'Sphinx', what: str, name: str, # pylint: disable return None -def bind_type_checking_names(app: 'Sphinx') -> None: +def bind_guarded_names(mock_imports: 'List[str]', module: 'ModuleType') -> None: + """Execute one module's ``if TYPE_CHECKING:`` block and bind its names there. + + Up to ``sphinx-autodoc-typehints`` 3.13, :func:`resolve_type_guarded_imports` + binds the names into the module itself. From 3.14 it only records them, and + the library lays them over the module's globals around the annotation reads it + patches, then takes them away again. Sphinx's own signature introspection is + not one of those reads. ``Schema.__new__`` takes ``*args: '_VT'`` and returns + the guarded ``'Self'``, so :func:`typing.get_type_hints` raised + :exc:`NameError` for it and the schema class signatures fell back to the bare + ``_VT``. The Python domain then found both + :data:`pcapkit.corekit.multidict._VT` and + :data:`pcapkit.protocols.schema.schema._VT` for it, warned, and linked the + first, which is the wrong one. Binding what the library recorded restores + the earlier behaviour. + + Only a name the module lacks is bound. A name the module binds at runtime keeps + that binding, as the library's own overlay leaves it. + + Args: + mock_imports: The ``autodoc_mock_imports`` setting. + module: The module whose block is executed. + + """ + resolve_type_guarded_imports(mock_imports, module) + namespace = vars(module) + for name, value in guarded_names(module).items(): + namespace.setdefault(name, value) + + +def bind_type_checking_names(app: 'Sphinx', env: 'BuildEnvironment', # pylint: disable=unused-argument + docnames: 'List[str]') -> None: # pylint: disable=unused-argument """Execute every ``pcapkit`` module's ``if TYPE_CHECKING:`` block. Annotations throughout the package are quoted and their types imported only @@ -362,13 +414,20 @@ def bind_type_checking_names(app: 'Sphinx') -> None: the resolved object and carries its full dotted path. Nothing is suppressed: the references are qualified rather than silenced. - ``sphinx-autodoc-typehints`` already does this per module, but lazily, only - for the objects it happens to process. Walking every module here binds the + ``sphinx-autodoc-typehints`` already executes the block per module, but lazily, + only for the objects it happens to process. Walking every module here binds the names up front, so :func:`typing.get_type_hints` succeeds for anything autodoc renders, whether or not the library reaches it. Its implementation is reused rather than rewritten because it executes the block one statement at a time, so an unimportable optional dependency (``pcap``, ``pcapfile``) cannot strand - the names declared after it. + the names declared after it. :func:`bind_guarded_names` does the binding, per + module. + + It runs on ``env-before-read-docs`` rather than on ``builder-inited``, after the + library's own handler for that event. From 3.14 that handler clears what the + library recorded from the blocks it ran, so the record of a walk done before + it would be discarded, and the library would run those blocks again as reading + reached them. One consequence is worth knowing before it is met as a mystery: making the annotations resolvable also makes a malformed one **fatal**. A quoted @@ -384,6 +443,8 @@ def bind_type_checking_names(app: 'Sphinx') -> None: Args: app: Sphinx application. + env: Build environment. + docnames: The documents about to be read. """ for info in pkgutil.walk_packages(pcapkit.__path__, 'pcapkit.', @@ -396,7 +457,7 @@ def bind_type_checking_names(app: 'Sphinx') -> None: # worth saying out loud rather than passing silently. logger.info('skipped unimportable module %s: %s', info.name, exc) continue - resolve_type_guarded_imports(app.config.autodoc_mock_imports, module) + bind_guarded_names(app.config.autodoc_mock_imports, module) def claim_attribute_signature(app: 'Sphinx', what: str, name: str, # pylint: disable=unused-argument @@ -427,6 +488,12 @@ def claim_attribute_signature(app: 'Sphinx', what: str, name: str, # pylint: di ``obj`` is the :class:`property` itself and therefore not callable -- those already resolve correctly and are left alone. + ``setup`` connects this only for ``sphinx-autodoc-typehints`` releases before + 3.14. From 3.14 the library's handler returns nothing when autodoc passes no + signature, as it does for every attribute, so there is no signature left to + stop, and claiming the event would only keep the library's handler from + running for these attributes at all. + Args: app: Sphinx application. what: Type of the object being documented. @@ -587,7 +654,9 @@ def restore_enum_member_docstring(app: 'Sphinx', what: str, name: str, # pylint def setup(app: 'Sphinx') -> None: - app.connect('builder-inited', bind_type_checking_names) + # NB: after ``sphinx_autodoc_typehints``, whose ``env-before-read-docs`` + # handlers connect at the default 500; see ``bind_type_checking_names``. + app.connect('env-before-read-docs', bind_type_checking_names, priority=600) app.connect('autodoc-skip-member', maybe_skip_member) # NB: before napoleon's ``autodoc-skip-member`` handler, which connects at the # default 500 and returns ``False`` for this hook; the first answer that is @@ -596,7 +665,8 @@ def setup(app: 'Sphinx') -> None: # NB: below ``sphinx_autodoc_typehints``, which connects at the default 500 and # would otherwise win the tie on registration order -- conf.py's ``setup`` runs # after the extensions in ``extensions`` have been set up. - app.connect('autodoc-process-signature', claim_attribute_signature, priority=400) + if TYPEHINTS_RELEASE < (3, 14): + app.connect('autodoc-process-signature', claim_attribute_signature, priority=400) app.connect('doctree-read', resolve_builtins_exactly) # NB: either side of napoleon's ``autodoc-process-docstring`` handler, which # connects at the default 500. diff --git a/tests/project/test_docs_typehints_pin_1856_unit.py b/tests/project/test_docs_typehints_pin_1856_unit.py new file mode 100644 index 000000000..fd0a2b6e4 --- /dev/null +++ b/tests/project/test_docs_typehints_pin_1856_unit.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +"""Pins :file:`docs/source/conf.py` to the ``sphinx-autodoc-typehints`` it is built with. + +GitHub issue #1856: :file:`docs/source/conf.py` imports +``resolve_type_guarded_imports`` from a private module of +``sphinx-autodoc-typehints``, and 3.14.0 replaced that module, ``_resolver``, +with the ``_resolve`` package, so a dependency bump to such a release fails the +docs build at import. The import now follows the installed release. + +3.14.0 also stopped binding a module's ``if TYPE_CHECKING:`` names into +the module, so Sphinx's own signature introspection lost them again: the schema +class signatures fell back to a bare ``_VT``, which matched two TypeVars. +``bind_guarded_names`` binds them itself now. + +The pin test runs only where the release :file:`docs/requirements.txt` pins is +the one installed, since only there does it check what the docs build imports, +and it says why when it skips. The other tests need Sphinx and +``sphinx-autodoc-typehints`` of any release, and skip without them. + +""" + +from __future__ import annotations + +import importlib.metadata +import inspect +import re +import typing +import unittest +from typing import TYPE_CHECKING + +from tests.project.test_docs_ambiguous_xrefs_1775_unit import (HAS_SPHINX, ROOT, connections, + load_conf) + +if TYPE_CHECKING: + from typing import Any + +#: The distribution whose private API :file:`docs/source/conf.py` imports. +DISTRIBUTION = 'sphinx-autodoc-typehints' + +#: The requirements file the docs build installs. +REQUIREMENTS = ROOT / 'docs' / 'requirements.txt' + + +def pinned_release() -> 'str': + """The release of :data:`DISTRIBUTION` that :data:`REQUIREMENTS` pins.""" + text = REQUIREMENTS.read_text(encoding='utf-8') + match = re.search(rf'^{re.escape(DISTRIBUTION)}==(\S+)\s*$', text, re.MULTILINE) + if match is None: + raise AssertionError(f'{REQUIREMENTS} pins no exact {DISTRIBUTION} release') + return match.group(1) + + +class PinnedReleaseTests(unittest.TestCase): + """The import in :file:`docs/source/conf.py` works with the pinned release.""" + + def test_conf_imports_the_function_from_the_pinned_release(self) -> 'None': + """Loading the file binds the library's ``resolve_type_guarded_imports``. + + A release that moves the function makes the load raise instead, which + is how the docs build fails too. + + """ + pin = pinned_release() + try: + installed = importlib.metadata.version(DISTRIBUTION) + except importlib.metadata.PackageNotFoundError: + self.skipTest(f'{DISTRIBUTION} is not installed; docs/requirements.txt pins {pin}') + if installed != pin: + self.skipTest(f'{DISTRIBUTION} {installed} is installed, ' + f'not the {pin} that docs/requirements.txt pins') + + function = load_conf().resolve_type_guarded_imports + self.assertTrue(callable(function)) + self.assertEqual(function.__name__, 'resolve_type_guarded_imports') + self.assertEqual(function.__module__.partition('.')[0], 'sphinx_autodoc_typehints') + + +@unittest.skipUnless(HAS_SPHINX, 'Sphinx and sphinx-autodoc-typehints are not installed') +class GuardedNameTests(unittest.TestCase): + """``bind_guarded_names`` leaves a module's guarded names where Sphinx reads them.""" + + conf = None # type: Any + + @classmethod + def setUpClass(cls) -> 'None': + cls.conf = load_conf() + + def test_schema_constructor_hints_resolve(self) -> 'None': + """``Schema.__new__`` returns the guarded ``'Self'``, which must resolve. + + Without it :func:`typing.get_type_hints` raises :exc:`NameError` for the + whole constructor, and Sphinx renders ``*args: '_VT'`` from the string. + + """ + from pcapkit.protocols.schema import schema # pylint: disable=import-outside-toplevel + + namespace = vars(schema) + before = set(namespace) + try: + self.conf.bind_guarded_names([], schema) + hints = typing.get_type_hints(schema.Schema.__new__) + finally: + for name in set(namespace) - before: + del namespace[name] + self.assertIs(hints['args'], schema._VT) # pylint: disable=protected-access + + def test_setup_binds_after_the_library_clears_its_record(self) -> 'None': + """From 3.14 the library clears what it recorded on ``env-before-read-docs``. + + It connects its handler at the default priority, so the walk has to + come after that one to be kept. + + """ + from sphinx.application import Sphinx # pylint: disable=import-outside-toplevel + default = inspect.signature(Sphinx.connect).parameters['priority'].default + wired = [(event, priority) for event, handler, priority in connections(self.conf) + if handler is self.conf.bind_type_checking_names] + self.assertEqual([event for event, _ in wired], ['env-before-read-docs']) + self.assertGreater(wired[0][1], default) + + def test_attribute_signature_claim_follows_the_release(self) -> 'None': + """Only a release before 3.14 hands a class-valued attribute a signature.""" + claimed = [event for event, handler, _ in connections(self.conf) + if handler is self.conf.claim_attribute_signature] + expected = ['autodoc-process-signature'] if self.conf.TYPEHINTS_RELEASE < (3, 14) else [] + self.assertEqual(claimed, expected) + + +if __name__ == '__main__': + unittest.main() From 57922c03253cd4c380c0ea65f020e506fcd0c4dc Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 16 Oct 2026 22:25:07 -0400 Subject: [PATCH 2/2] docs(conf): pin sphinx-autodoc-typehints and drop the release branch (#1856) - Pin sphinx-autodoc-typehints in the docs extra to the release docs/requirements.txt pins, so `pip install -e .[docs]` installs what the docs build runs. - Import its private modules unconditionally in conf.py, and drop the release check, the pre-3.14 fallbacks and claim_attribute_signature, which only an older release needed. - Gate every test that loads conf.py on HAS_SPHINX, which now also requires the pinned release and skips with a message saying which condition failed. - Add a test that the docs extra and docs/requirements.txt pin the same release, and update the HAS_SPHINX exclusion's reason. Closes #1856 --- docs/source/conf.py | 103 +++--------------- pyproject.toml | 6 +- tests/_dependency_gates.py | 30 +++-- .../test_docs_ambiguous_xrefs_1775_unit.py | 69 ++++++++++-- .../project/test_docs_enum_value_hook_unit.py | 8 +- .../test_docs_typehints_pin_1856_unit.py | 77 ++++++------- 6 files changed, 138 insertions(+), 155 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 9e822ed52..c71df742c 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -8,7 +8,6 @@ import datetime import enum import importlib -import importlib.metadata import logging import os import pkgutil @@ -16,30 +15,16 @@ from sphinx import addnodes -#: The installed ``sphinx-autodoc-typehints`` release, as ``(major, minor)``. -TYPEHINTS_RELEASE = tuple(int(part) for part in - importlib.metadata.version('sphinx-autodoc-typehints').split('.')[:2]) - # NB: private modules of ``sphinx-autodoc-typehints``, used deliberately -- see # ``bind_type_checking_names`` below for what they buy and why reimplementing them -# here would be worse. If a future release moves them the import fails loudly at -# build time, which is the outcome to want: the alternative is a build that -# silently goes back to emitting unresolvable annotations. -# -# The branch is chosen by the installed release, not by probing for the module, so -# each branch is a plain import that still fails loudly. 3.14.0 replaced the -# ``_resolver`` module with the ``_resolve`` package, and ``docs/requirements.txt`` -# pins a release after that. The older branch serves an environment that has an -# earlier release beside Sphinx, where ``tests/project`` loads this file to test -# its hooks. -if TYPEHINTS_RELEASE >= (3, 14): - from sphinx_autodoc_typehints._resolve import guarded_names, resolve_type_guarded_imports -else: - from sphinx_autodoc_typehints._resolver import resolve_type_guarded_imports - - def guarded_names(obj: 'Any') -> 'Dict[str, Any]': # pylint: disable=unused-argument - """Nothing: before 3.14, :func:`resolve_type_guarded_imports` binds the names itself.""" - return {} +# here would be worse. This file is written against the release that +# ``docs/requirements.txt`` pins, which the ``docs`` extra in ``pyproject.toml`` +# pins too, and ``tests/project/test_docs_typehints_pin_1856_unit.py`` keeps the +# pins equal, so there is no other release to branch for. A bump that moves these +# modules fails this import loudly at build time, which is the outcome to want: +# the alternative is a build that silently goes back to emitting unresolvable +# annotations. +from sphinx_autodoc_typehints._resolve import guarded_names, resolve_type_guarded_imports # NB: this must precede ``import pcapkit``. ``pcapkit.utilities.logging`` reads # ``PCAPKIT_SPHINX`` at *import* time to compute ``SPHINX_TYPE_CHECKING``, and @@ -365,10 +350,10 @@ def skip_enum_value_hook(app: 'Sphinx', what: str, name: str, # pylint: disable def bind_guarded_names(mock_imports: 'List[str]', module: 'ModuleType') -> None: """Execute one module's ``if TYPE_CHECKING:`` block and bind its names there. - Up to ``sphinx-autodoc-typehints`` 3.13, :func:`resolve_type_guarded_imports` - binds the names into the module itself. From 3.14 it only records them, and - the library lays them over the module's globals around the annotation reads it - patches, then takes them away again. Sphinx's own signature introspection is + :func:`resolve_type_guarded_imports` only records the names (it bound them into + the module itself before ``sphinx-autodoc-typehints`` 3.14), and the library + lays them over the module's globals around the annotation reads it patches, + then takes them away again. Sphinx's own signature introspection is not one of those reads. ``Schema.__new__`` takes ``*args: '_VT'`` and returns the guarded ``'Self'``, so :func:`typing.get_type_hints` raised :exc:`NameError` for it and the schema class signatures fell back to the bare @@ -376,7 +361,7 @@ def bind_guarded_names(mock_imports: 'List[str]', module: 'ModuleType') -> None: :data:`pcapkit.corekit.multidict._VT` and :data:`pcapkit.protocols.schema.schema._VT` for it, warned, and linked the first, which is the wrong one. Binding what the library recorded restores - the earlier behaviour. + what the releases before 3.14 did. Only a name the module lacks is bound. A name the module binds at runtime keeps that binding, as the library's own overlay leaves it. @@ -424,8 +409,8 @@ def bind_type_checking_names(app: 'Sphinx', env: 'BuildEnvironment', # pylint: module. It runs on ``env-before-read-docs`` rather than on ``builder-inited``, after the - library's own handler for that event. From 3.14 that handler clears what the - library recorded from the blocks it ran, so the record of a walk done before + library's own handler for that event. That handler clears what the library + recorded from the blocks it ran, so the record of a walk done before it would be discarded, and the library would run those blocks again as reading reached them. @@ -460,59 +445,6 @@ def bind_type_checking_names(app: 'Sphinx', env: 'BuildEnvironment', # pylint: bind_guarded_names(app.config.autodoc_mock_imports, module) -def claim_attribute_signature(app: 'Sphinx', what: str, name: str, # pylint: disable=unused-argument - obj: 'Any', options: 'Dict[str, Any]', # pylint: disable=unused-argument - signature: 'Optional[str]', # pylint: disable=unused-argument - return_annotation: 'Optional[str]') -> 'Optional[tuple]': # pylint: disable=unused-argument - """Stop a class-valued attribute being given the class's own signature. - - Several class attributes here hold a *class* as their value -- - :attr:`Protocol.__schema__ ` and - the ``__protocol_type__`` of each reassembly and flow-tracing class. Autodoc - documents them as attributes, so it collects no signature for them, but - ``sphinx_autodoc_typehints`` keys its own handler off ``callable(obj)`` alone, - sees a class, and hands one back. Sphinx 9.1 then stores it with - ``signatures[0] = ...`` on a list it never populated, which raises - ``IndexError: list assignment index out of range`` -- and autodoc turns that - into ``error while formatting signature for ...`` and drops the member from the - page entirely. - - Claiming the event first is what prevents that. The returned value has to be - non-:data:`None` to win ``emit_firstresult`` yet must not look like a - ``(args, retann)`` pair, so it is the empty tuple: falsy, and of the wrong - length for the ``len(result) == 2`` guard Sphinx applies before unpacking. An - attribute has no signature of its own to lose, so nothing is suppressed here - beyond a value that was never meaningful. - - ``callable(obj)`` keeps this off ordinary attributes and off properties, whose - ``obj`` is the :class:`property` itself and therefore not callable -- those - already resolve correctly and are left alone. - - ``setup`` connects this only for ``sphinx-autodoc-typehints`` releases before - 3.14. From 3.14 the library's handler returns nothing when autodoc passes no - signature, as it does for every attribute, so there is no signature left to - stop, and claiming the event would only keep the library's handler from - running for these attributes at all. - - Args: - app: Sphinx application. - what: Type of the object being documented. - name: Fully qualified name of the object. - obj: The object itself. - options: Directive options. - signature: Signature autodoc has so far, if any. - return_annotation: Return annotation autodoc has so far, if any. - - Returns: - The empty tuple for a class-valued attribute, to claim the event without - recording a signature; :data:`None` otherwise, to let other handlers run. - - """ - if what in {'attribute', 'property'} and callable(obj): - return () - return None - - #: Every name :mod:`builtins` binds -- ``dict``, ``set``, ``type`` and the rest. BUILTIN_NAMES = frozenset(dir(builtins)) @@ -662,11 +594,6 @@ def setup(app: 'Sphinx') -> None: # default 500 and returns ``False`` for this hook; the first answer that is # not ``None`` is the one autodoc uses. app.connect('autodoc-skip-member', skip_enum_value_hook, priority=400) - # NB: below ``sphinx_autodoc_typehints``, which connects at the default 500 and - # would otherwise win the tie on registration order -- conf.py's ``setup`` runs - # after the extensions in ``extensions`` have been set up. - if TYPEHINTS_RELEASE < (3, 14): - app.connect('autodoc-process-signature', claim_attribute_signature, priority=400) app.connect('doctree-read', resolve_builtins_exactly) # NB: either side of napoleon's ``autodoc-process-docstring`` handler, which # connects at the default 500. diff --git a/pyproject.toml b/pyproject.toml index 7b881d1dd..a07731bda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -282,7 +282,11 @@ dev = [ ] docs = [ "Sphinx>=7.3", "furo", - "sphinx-autodoc-typehints", "sphinxext-opengraph", "sphinx-copybutton", + # Pinned to the release ``docs/requirements.txt`` pins, because + # ``docs/source/conf.py`` imports that release's private modules and is + # written against it alone (#1856). tests/project/test_docs_typehints_pin_1856_unit.py + # fails when the pins differ. + "sphinx-autodoc-typehints==3.15.0", "sphinxext-opengraph", "sphinx-copybutton", "sphinxcontrib-mermaid", "typing-extensions", "mypy-extensions", ] diff --git a/tests/_dependency_gates.py b/tests/_dependency_gates.py index 67e6c38ff..3e752121e 100644 --- a/tests/_dependency_gates.py +++ b/tests/_dependency_gates.py @@ -230,9 +230,9 @@ # ``HAS_PYPCAP``'s candidates -- see :func:`ambiguous_satisfactions`'s own # docstring. 'pcap._pcap': ('pcap-ct',), - # The two :file:`docs/source/conf.py` imports at module level, which the + # The :file:`docs/source/conf.py` imports at module level, which the # ``HAS_SPHINX`` gate on its hook tests (#1775) asks for. Only the ``docs`` - # extra carries either. + # extra carries them. 'sphinx': ('Sphinx',), 'sphinx_autodoc_typehints': ('sphinx-autodoc-typehints',), } @@ -625,16 +625,22 @@ class Exclusion(NamedTuple): dark={'test': ('sphinx', 'sphinx_autodoc_typehints'), 'gate': ('sphinx', 'sphinx_autodoc_typehints')}, reason=( - 'The two classes it gates (#1775) load docs/source/conf.py and drive its ' - 'cross-reference and napoleon hooks with the nodes Sphinx itself builds, so ' - 'they need the docs extra, which only deploy-pages.yml installs -- and that ' - 'workflow builds the documentation rather than running pytest. The same ' - 'argument declines it here as declines HAS_MYPY above: the docs build is ' - 'already the end-to-end check of those hooks, and putting Sphinx, furo and ' - 'their dependencies on every unit-test leg would cost each one an install for ' - 'two classes. They run wherever Sphinx is importable, a developer checkout ' - 'with the docs extra included, and skip visibly elsewhere. The ' - 'directive-name test in the same module needs nothing and runs on every leg.' + 'The classes it gates (#1775, #1856 and the enum-value hook\'s) load ' + 'docs/source/conf.py and drive its hooks with the nodes and events Sphinx ' + 'itself builds, so they need Sphinx and the pinned sphinx-autodoc-typehints. ' + 'Only deploy-pages.yml installs those, from docs/requirements.txt, and that ' + 'workflow builds the documentation rather than running pytest; no workflow ' + 'installs the docs extra itself. The same argument declines it here as ' + 'declines HAS_MYPY above: the docs build is already the end-to-end check of ' + 'those hooks, and putting Sphinx, furo and their dependencies on every ' + 'unit-test leg would cost each one an install for these classes alone. The ' + 'flag also requires the installed sphinx-autodoc-typehints to be the release ' + 'docs/requirements.txt pins, since conf.py imports that release\'s private ' + 'modules; that check reads no module, so this entry still names only the ' + 'imports. They run wherever docs/requirements.txt or the docs extra is ' + 'installed, a developer checkout included, and skip visibly elsewhere, saying ' + 'which condition failed. The directive-name and pin-agreement tests need ' + 'nothing and run on every leg.' ), ), } diff --git a/tests/project/test_docs_ambiguous_xrefs_1775_unit.py b/tests/project/test_docs_ambiguous_xrefs_1775_unit.py index bda27c386..f23b342a3 100644 --- a/tests/project/test_docs_ambiguous_xrefs_1775_unit.py +++ b/tests/project/test_docs_ambiguous_xrefs_1775_unit.py @@ -21,13 +21,18 @@ The hook tests drive the hooks with the nodes Sphinx's own producers build and with napoleon itself, and check that ``setup`` connects each one where the build -needs it, so they need Sphinx and skip without it. The directive test is textual -and runs everywhere. +needs it, so they need Sphinx and the pinned sphinx-autodoc-typehints, and skip +without them. The directive test is textual and runs everywhere. + +This module also holds what every test loading :file:`conf.py` shares: the +loader, the ``setup`` connection reader, and :data:`HAS_SPHINX` with its skip +message. """ from __future__ import annotations +import importlib.metadata import importlib.util import inspect import os @@ -39,7 +44,7 @@ from unittest import mock if TYPE_CHECKING: - from typing import Any + from typing import Any, Optional ROOT = pathlib.Path(__file__).resolve().parents[2] @@ -47,9 +52,57 @@ DOCS = ROOT / 'docs' / 'source' CONF = DOCS / 'conf.py' -#: ``conf.py`` imports both at module level. -HAS_SPHINX = all(importlib.util.find_spec(name) is not None - for name in ('sphinx', 'sphinx_autodoc_typehints')) +#: The distribution whose private modules ``conf.py`` imports. +TYPEHINTS = 'sphinx-autodoc-typehints' + +#: The requirements file the docs build installs, which pins :data:`TYPEHINTS`. +REQUIREMENTS = ROOT / 'docs' / 'requirements.txt' + + +def pinned_release() -> 'str': + """The release of :data:`TYPEHINTS` that :data:`REQUIREMENTS` pins.""" + text = REQUIREMENTS.read_text(encoding='utf-8') + match = re.search(rf'^{re.escape(TYPEHINTS)}==(\S+)\s*$', text, re.MULTILINE) + if match is None: + raise AssertionError(f'{REQUIREMENTS} pins no exact {TYPEHINTS} release') + return match.group(1) + + +def installed_release() -> 'Optional[str]': + """The installed release of :data:`TYPEHINTS`, or :data:`None` without one.""" + try: + return importlib.metadata.version(TYPEHINTS) + except importlib.metadata.PackageNotFoundError: + return None + + +def runs_pinned_release() -> 'bool': + """Whether the installed :data:`TYPEHINTS` is the release ``conf.py`` is written against.""" + return installed_release() == pinned_release() + + +#: ``conf.py`` imports these at module level, and imports private modules of +#: sphinx-autodoc-typehints that only the pinned release is known to have. Another release would +#: fail the load, or load and test something the docs build never runs, so a +#: test that loads ``conf.py`` gates on this and skips with +#: :data:`SPHINX_SKIP_REASON`. +HAS_SPHINX = (all(importlib.util.find_spec(name) is not None + for name in ('sphinx', 'sphinx_autodoc_typehints')) + and runs_pinned_release()) + + +def sphinx_skip_reason() -> 'str': + """Why :data:`HAS_SPHINX` is false, for a skip message; empty when it is true.""" + if HAS_SPHINX: + return '' + if any(importlib.util.find_spec(name) is None for name in ('sphinx', 'sphinx_autodoc_typehints')): + return 'Sphinx and sphinx-autodoc-typehints are not installed' + return (f'{TYPEHINTS} {installed_release()} is installed, not the {pinned_release()} ' + f'that docs/requirements.txt pins and docs/source/conf.py is written against') + + +#: The skip message of every test gated on :data:`HAS_SPHINX`. +SPHINX_SKIP_REASON = sphinx_skip_reason() #: A Python domain directive, or one that sets the current module. DIRECTIVE = re.compile(r'^\s*\.\. (?:py:)?(?P[a-z]+)::\s*(?P\S+)') @@ -138,7 +191,7 @@ def test_no_directive_repeats_its_module(self) -> 'None': self.assertEqual(doubled_names(), []) -@unittest.skipUnless(HAS_SPHINX, 'Sphinx and sphinx-autodoc-typehints are not installed') +@unittest.skipUnless(HAS_SPHINX, SPHINX_SKIP_REASON) class BuiltinReferenceTests(unittest.TestCase): """``resolve_builtins_exactly`` keeps builtin names off registry members.""" @@ -226,7 +279,7 @@ def test_setup_connects_it_to_doctree_read(self) -> 'None': self.assertEqual(wired, [('doctree-read', 500)]) -@unittest.skipUnless(HAS_SPHINX, 'Sphinx and sphinx-autodoc-typehints are not installed') +@unittest.skipUnless(HAS_SPHINX, SPHINX_SKIP_REASON) class EnumMemberDocstringTests(unittest.TestCase): """Napoleon's inline type is undone on enum members, and only there.""" diff --git a/tests/project/test_docs_enum_value_hook_unit.py b/tests/project/test_docs_enum_value_hook_unit.py index ac12b66e8..8356d0001 100644 --- a/tests/project/test_docs_enum_value_hook_unit.py +++ b/tests/project/test_docs_enum_value_hook_unit.py @@ -10,7 +10,8 @@ The tests call the hook directly, and replay the event through Sphinx's own :class:`~sphinx.events.EventManager` with napoleon's real handler, in the order -the build connects them. They need Sphinx and skip without it, but run no build. +the build connects them. They need Sphinx and the pinned sphinx-autodoc-typehints, +and skip without them, but run no build. """ @@ -22,7 +23,8 @@ import unittest from typing import TYPE_CHECKING -from tests.project.test_docs_ambiguous_xrefs_1775_unit import HAS_SPHINX, connections, load_conf +from tests.project.test_docs_ambiguous_xrefs_1775_unit import (HAS_SPHINX, SPHINX_SKIP_REASON, connections, + load_conf) if TYPE_CHECKING: from typing import Any @@ -40,7 +42,7 @@ class Colour(enum.Enum): RED = 1 -@unittest.skipUnless(HAS_SPHINX, 'Sphinx and sphinx-autodoc-typehints are not installed') +@unittest.skipUnless(HAS_SPHINX, SPHINX_SKIP_REASON) class EnumValueHookTests(unittest.TestCase): """``skip_enum_value_hook`` skips the inherited hook, and nothing else.""" diff --git a/tests/project/test_docs_typehints_pin_1856_unit.py b/tests/project/test_docs_typehints_pin_1856_unit.py index fd0a2b6e4..61821abe1 100644 --- a/tests/project/test_docs_typehints_pin_1856_unit.py +++ b/tests/project/test_docs_typehints_pin_1856_unit.py @@ -4,52 +4,59 @@ GitHub issue #1856: :file:`docs/source/conf.py` imports ``resolve_type_guarded_imports`` from a private module of ``sphinx-autodoc-typehints``, and 3.14.0 replaced that module, ``_resolver``, -with the ``_resolve`` package, so a dependency bump to such a release fails the -docs build at import. The import now follows the installed release. +with the ``_resolve`` package, so a dependency bump to such a release failed the +docs build at import. -3.14.0 also stopped binding a module's ``if TYPE_CHECKING:`` names into -the module, so Sphinx's own signature introspection lost them again: the schema -class signatures fell back to a bare ``_VT``, which matched two TypeVars. -``bind_guarded_names`` binds them itself now. +The file is written against a pinned release rather than branching on the +installed one. :file:`docs/requirements.txt` pins it for the docs build, and the +``docs`` extra in :file:`pyproject.toml` pins the same release, so ``pip install +-e '.[docs]'`` installs what the build runs. The pin test is textual and runs +everywhere. -The pin test runs only where the release :file:`docs/requirements.txt` pins is -the one installed, since only there does it check what the docs build imports, -and it says why when it skips. The other tests need Sphinx and -``sphinx-autodoc-typehints`` of any release, and skip without them. +3.14.0 also stopped binding a module's ``if TYPE_CHECKING:`` names into the +module, so Sphinx's own signature introspection lost them again: the schema +class signatures fell back to a bare ``_VT``, which matched another module's +TypeVar as well. ``bind_guarded_names`` binds them itself. + +The tests that load :file:`conf.py` need Sphinx and the pinned release, and skip +without them, saying which. """ from __future__ import annotations -import importlib.metadata import inspect -import re import typing import unittest from typing import TYPE_CHECKING -from tests.project.test_docs_ambiguous_xrefs_1775_unit import (HAS_SPHINX, ROOT, connections, - load_conf) +from tests._dependency_gates import declared_requirements, requirement_key +from tests.project.test_docs_ambiguous_xrefs_1775_unit import (HAS_SPHINX, SPHINX_SKIP_REASON, TYPEHINTS, + connections, load_conf, pinned_release) if TYPE_CHECKING: from typing import Any -#: The distribution whose private API :file:`docs/source/conf.py` imports. -DISTRIBUTION = 'sphinx-autodoc-typehints' -#: The requirements file the docs build installs. -REQUIREMENTS = ROOT / 'docs' / 'requirements.txt' +class PinAgreementTests(unittest.TestCase): + """The ``docs`` extra installs the release :file:`docs/requirements.txt` pins.""" + + def test_docs_extra_pins_the_requirements_release(self) -> 'None': + """Every extra naming the distribution pins exactly that release. + Unpinned, ``pip install -e '.[docs]'`` resolves the newest release, which + :file:`conf.py` is not written against. -def pinned_release() -> 'str': - """The release of :data:`DISTRIBUTION` that :data:`REQUIREMENTS` pins.""" - text = REQUIREMENTS.read_text(encoding='utf-8') - match = re.search(rf'^{re.escape(DISTRIBUTION)}==(\S+)\s*$', text, re.MULTILINE) - if match is None: - raise AssertionError(f'{REQUIREMENTS} pins no exact {DISTRIBUTION} release') - return match.group(1) + """ + name = requirement_key(TYPEHINTS).name + found = {extra: [requirement.text.replace(' ', '') for requirement in requirements + if requirement.name == name] + for extra, requirements in declared_requirements().items()} + self.assertEqual({extra: texts for extra, texts in found.items() if texts}, + {'docs': [f'{TYPEHINTS}=={pinned_release()}']}) +@unittest.skipUnless(HAS_SPHINX, SPHINX_SKIP_REASON) class PinnedReleaseTests(unittest.TestCase): """The import in :file:`docs/source/conf.py` works with the pinned release.""" @@ -60,22 +67,13 @@ def test_conf_imports_the_function_from_the_pinned_release(self) -> 'None': is how the docs build fails too. """ - pin = pinned_release() - try: - installed = importlib.metadata.version(DISTRIBUTION) - except importlib.metadata.PackageNotFoundError: - self.skipTest(f'{DISTRIBUTION} is not installed; docs/requirements.txt pins {pin}') - if installed != pin: - self.skipTest(f'{DISTRIBUTION} {installed} is installed, ' - f'not the {pin} that docs/requirements.txt pins') - function = load_conf().resolve_type_guarded_imports self.assertTrue(callable(function)) self.assertEqual(function.__name__, 'resolve_type_guarded_imports') self.assertEqual(function.__module__.partition('.')[0], 'sphinx_autodoc_typehints') -@unittest.skipUnless(HAS_SPHINX, 'Sphinx and sphinx-autodoc-typehints are not installed') +@unittest.skipUnless(HAS_SPHINX, SPHINX_SKIP_REASON) class GuardedNameTests(unittest.TestCase): """``bind_guarded_names`` leaves a module's guarded names where Sphinx reads them.""" @@ -105,7 +103,7 @@ def test_schema_constructor_hints_resolve(self) -> 'None': self.assertIs(hints['args'], schema._VT) # pylint: disable=protected-access def test_setup_binds_after_the_library_clears_its_record(self) -> 'None': - """From 3.14 the library clears what it recorded on ``env-before-read-docs``. + """The library clears what it recorded on ``env-before-read-docs``. It connects its handler at the default priority, so the walk has to come after that one to be kept. @@ -118,13 +116,6 @@ def test_setup_binds_after_the_library_clears_its_record(self) -> 'None': self.assertEqual([event for event, _ in wired], ['env-before-read-docs']) self.assertGreater(wired[0][1], default) - def test_attribute_signature_claim_follows_the_release(self) -> 'None': - """Only a release before 3.14 hands a class-valued attribute a signature.""" - claimed = [event for event, handler, _ in connections(self.conf) - if handler is self.conf.claim_attribute_signature] - expected = ['autodoc-process-signature'] if self.conf.TYPEHINTS_RELEASE < (3, 14) else [] - self.assertEqual(claimed, expected) - if __name__ == '__main__': unittest.main()