Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
123 changes: 60 additions & 63 deletions docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,16 @@

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
# 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
# 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. 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
Expand All @@ -32,9 +36,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)
Expand Down Expand Up @@ -341,7 +347,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.

: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
``_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
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.

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
Expand All @@ -362,13 +399,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. 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
Expand All @@ -384,6 +428,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.',
Expand All @@ -396,54 +442,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)


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__ <pcapkit.protocols.protocol.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.

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
bind_guarded_names(app.config.autodoc_mock_imports, module)


#: Every name :mod:`builtins` binds -- ``dict``, ``set``, ``type`` and the rest.
Expand Down Expand Up @@ -587,16 +586,14 @@ 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
# 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.
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.
Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down
30 changes: 18 additions & 12 deletions tests/_dependency_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',),
}
Expand Down Expand Up @@ -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.'
),
),
}
Expand Down
69 changes: 61 additions & 8 deletions tests/project/test_docs_ambiguous_xrefs_1775_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -39,17 +44,65 @@
from unittest import mock

if TYPE_CHECKING:
from typing import Any
from typing import Any, Optional

ROOT = pathlib.Path(__file__).resolve().parents[2]

#: The documentation tree, and the configuration whose hooks are tested.
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<kind>[a-z]+)::\s*(?P<name>\S+)')
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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."""

Expand Down
8 changes: 5 additions & 3 deletions tests/project/test_docs_enum_value_hook_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

"""

Expand All @@ -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
Expand All @@ -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."""

Expand Down
Loading
Loading