diff --git a/docs/source/conf.py b/docs/source/conf.py index c71df742c..6ee709c74 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -8,23 +8,36 @@ import datetime import enum import importlib +import inspect import logging import os import pkgutil +import re +import typing from typing import TYPE_CHECKING +import aenum +from docutils import nodes from sphinx import addnodes +from sphinx.ext.autodoc.preserve_defaults import DefaultValue, update_default_value +from sphinx.transforms.post_transforms import SphinxPostTransform +from sphinx.util.inspect import stringify_signature # 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. +# ``bind_type_checking_names``, ``restore_class_signature`` and +# ``read_constructor_types_from_init`` 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._docstring import handler as typehints_docstring_handler +from sphinx_autodoc_typehints._documented import hints as typehints_hints +from sphinx_autodoc_typehints._objects import constructor_name as typehints_constructor_name from sphinx_autodoc_typehints._resolve import guarded_names, resolve_type_guarded_imports +from sphinx_autodoc_typehints._signature.handler import process_signature as typehints_process_signature # NB: this must precede ``import pcapkit``. ``pcapkit.utilities.logging`` reads # ``PCAPKIT_SPHINX`` at *import* time to compute ``SPHINX_TYPE_CHECKING``, and @@ -34,12 +47,16 @@ os.environ['PCAPKIT_SPHINX'] = '1' import pcapkit +from pcapkit.corekit.infoclass import Info +from pcapkit.corekit.sentinels import NO_VALUE +from pcapkit.protocols.schema.schema import Schema if TYPE_CHECKING: - from types import ModuleType + from types import FunctionType, ModuleType from typing import Any, Dict, List, Optional from docutils.nodes import document from sphinx.application import Sphinx + from sphinx.config import Config from sphinx.environment import BuildEnvironment logger = logging.getLogger('pcapkit-sphinx') @@ -173,6 +190,11 @@ 'show-inheritance': True, } autodoc_typehints = 'signature' +# NB: a default renders as its source text rather than its ``repr()``. By value, +# every ``byteorder=sys.byteorder`` showed the build host's ``'little'``, and the +# ``corekit.fields`` pages showed ``default=`` and ``callback=>`` (:issue:`1859`). +autodoc_preserve_defaults = True #autodoc_member_order = 'bysource' #autodoc_member_order = 'alphabetic' autoclass_content = 'class' @@ -199,6 +221,7 @@ todo_include_todos = True set_type_checking_flag = True +# NB: ``typehints_formatter`` is set below, beside ``format_aenum_class``. typehints_fully_qualified = False always_document_param_types = False typehints_document_rtype = True @@ -563,6 +586,367 @@ def restore_enum_member_docstring(app: 'Sphinx', what: str, name: str, # pylint lines[:] = original +def format_aenum_class(annotation: 'Any', config: 'Config') -> 'Optional[str]': # pylint: disable=unused-argument + """Name a class that :mod:`aenum` defines by its qualified name. + + ``typehints_fully_qualified`` is off, so a class renders by its bare name, and + :mod:`aenum` publishes no inventory to link to. A union of an :mod:`aenum` + class and its standard library namesake therefore read ``IntEnum | IntEnum``: + the first linked to :class:`enum.IntEnum`, the second plain text + (:issue:`1858`). Qualifying the :mod:`aenum` one tells them apart. + + The name is the one the package exports, ``aenum.IntEnum``, rather than the + ``aenum._enum`` module that defines the class. ``sphinx-autodoc-typehints`` + 3.15 renders ``Bases:`` lines too, so ``NumberField[IntEnum | IntEnum]`` + reads apart there as well. + + Args: + annotation: The type being rendered, or a part of one. + config: Sphinx configuration. + + Returns: + A ``:py:class:`` role naming an :mod:`aenum` class, or :data:`None` to + leave anything else to the default rendering. + + """ + module = getattr(annotation, '__module__', None) + if not isinstance(annotation, type) or not isinstance(module, str) or module.partition('.')[0] != 'aenum': + return None + name = annotation.__qualname__ + owner = 'aenum' if getattr(aenum, name, None) is annotation else module + return f':py:class:`{owner}.{name}`' + + +# NB: only one formatter can be configured, so any other formatter-based fix has +# to share this one. +typehints_formatter = format_aenum_class + +#: A name followed by an attribute access or a call, in the source text of a default. +DOTTED_HEAD = re.compile(r'(? 'inspect.Signature': + """Name each class a preserved default reaches through an import alias by its own name. + + ``autodoc_preserve_defaults`` renders a default as its source text, and the + protocol modules import their registries under private aliases, so a + ``make()`` signature read ``next=Enum_TransType.UDP`` (:issue:`1859`). The + alias is the module's own spelling; the class is + :class:`~pcapkit.const.reg.transtype.TransType`, which the parameter field + names, so the default reads ``next=TransType.UDP``. + + Only a name the module binds to a class of another name is replaced, and only + where it heads an attribute access or a call, as in an enumeration member or + ``Enum_FilterType(0)``. A module, such as ``sys`` in ``sys.byteorder``, is + left alone. + + Args: + signature: The signature, its defaults as autodoc preserved them. + namespace: The globals of the module defining the callable. + + Returns: + The signature with those defaults renamed. + + """ + def rename(match: 're.Match[str]') -> str: + value = namespace.get(match.group(1)) + return value.__name__ if isinstance(value, type) and value.__name__ != match.group(1) else match.group(1) + + parameters = [] + for param in signature.parameters.values(): + if isinstance(param.default, DefaultValue): + text = DOTTED_HEAD.sub(rename, param.default.name) + # NB: an unchanged parameter is kept as is, since a DefaultValue + # compares by identity and the caller compares the signatures. + if text != param.default.name: + param = param.replace(default=DefaultValue(text)) + parameters.append(param) + return signature.replace(parameters=parameters) + + +def name_aliased_defaults(app: 'Sphinx', obj: 'Any', bound_method: bool) -> None: # pylint: disable=unused-argument + """Rename the aliased classes in the defaults autodoc just preserved. + + autodoc emits ``autodoc-before-process-signature`` once it has written the + preserved defaults to the callable's ``__signature__``, and reads the + signature after it, so :func:`unalias_defaults` is applied there. + + Args: + app: Sphinx application. + obj: The function or method whose signature autodoc is about to read. + bound_method: Whether it is read as a bound method. + + """ + if not app.config.autodoc_preserve_defaults or not (inspect.isfunction(obj) or inspect.ismethod(obj)): + return + signature = getattr(obj, '__signature__', None) + namespace = getattr(inspect.unwrap(getattr(obj, '__func__', obj)), '__globals__', None) + if not isinstance(signature, inspect.Signature) or namespace is None: + return + renamed = unalias_defaults(signature, namespace) + if renamed == signature: + return + # NB: as autodoc stores it, since a bound method takes no attribute of its own. + try: + obj.__signature__ = renamed + except AttributeError: + obj.__dict__['__signature__'] = renamed + + +def passes_through(new: 'FunctionType') -> bool: + """Whether a ``__new__`` takes nothing but ``*args`` and ``**kwargs``. + + Such a ``__new__`` leaves the parameters to ``__init__``. + :class:`~pcapkit.protocols.protocol.ProtocolBase`, :class:`Info`, + :class:`Schema` and the reassembly and flow-tracing bases each define one. + + Args: + new: The ``__new__`` function. + + Returns: + :data:`True` if its parameters after ``cls`` are exactly ``*args`` and + ``**kwargs``. + + """ + try: + parameters = list(inspect.signature(new).parameters.values())[1:] + except (TypeError, ValueError): + return False + return [param.kind for param in parameters] == [inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD] + + +def hidden_init(cls: type) -> 'Optional[FunctionType]': + """The ``__init__`` a call to ``cls`` runs, where a ``__new__`` hides it from autodoc. + + Sphinx 9.1 takes a class signature from any ``__new__`` the class defines or + inherits before it looks at ``__init__``. ``sphinx-autodoc-typehints`` 3.15 + takes the ``__new__`` where it and the ``__init__`` sit at the same depth of the MRO, as + :func:`inspect.signature` does, and otherwise leaves Sphinx's choice alone + when the ``__init__`` it picks carries no annotations, or names parameters the + other does not. So a ``__new__`` passing ``*args`` and ``**kwargs`` through + wins over the ``__init__`` that names the parameters, and ``FTP`` rendered as + ``FTP(*args, **kwargs)``. A ``__new__`` deeper in the MRO wins over a nearer + ``__init__`` too, so :class:`~pcapkit.dumpkit.pcap.PCAPIO` rendered the + ``(fname, **kwargs)`` of :class:`dictdumper.dumper.Dumper` (:issue:`1860`). + + The MRO is walked as :func:`inspect.signature` walks it, but past each + ``__new__`` that only passes its arguments through. A ``__new__`` with + parameters of its own is the constructor, and autodoc reads it already. + + :class:`Info` and :class:`Schema` give each subclass its own ``__init__`` on + its first construction (see :func:`~pcapkit.corekit.infoclass.info_final`), + so the ``__init__`` a subclass inherits from them before then is not the one + a call runs. Such a class keeps the catch-all, which is also what + :func:`inspect.signature` reports for it. + + Args: + cls: The class being documented. + + Returns: + The ``__init__`` function, or :data:`None` where autodoc already reads + the constructor or where it is not known until the class is first + constructed. + + """ + if not inspect.isfunction(inspect.unwrap(cls.__new__)): + return None + for klass in cls.__mro__: + namespace = vars(klass) + new = namespace.get('__new__') + if isinstance(new, staticmethod): + new = new.__func__ + if inspect.isfunction(new) and not passes_through(new): + return None + if '__init__' in namespace: + init = namespace['__init__'] + if not inspect.isfunction(init) or (klass in (Info, Schema) and klass is not cls): + return None + return init + return None + + +def init_signature(app: 'Sphinx', init: 'FunctionType') -> str: + """Render an ``__init__``'s parameters as a class signature. + + The instance parameter is dropped, as a call to the class does not pass it. + The types are left out, as ``sphinx-autodoc-typehints`` leaves them out of + every signature in this build and puts them in the parameter fields. The + defaults go through ``autodoc_preserve_defaults`` and + :func:`unalias_defaults` as autodoc's own signatures do. A generated + ``__init__`` has no source for that to read, so its + :data:`~pcapkit.corekit.sentinels.NO_VALUE` defaults are written as the + generator writes them, whatever the sentinel's ``repr()``. + + Args: + app: Sphinx application. + init: The ``__init__`` function. + + Returns: + The parenthesised parameter list. + + """ + preserve = app.config.autodoc_preserve_defaults + if preserve: + update_default_value(init, False) + signature = inspect.signature(init) + if preserve: + signature = unalias_defaults(signature, init.__globals__) + parameters = list(signature.parameters.values())[1:] + if preserve: + parameters = [param.replace(default=DefaultValue('NO_VALUE')) if param.default is NO_VALUE else param + for param in parameters] + text = stringify_signature(signature.replace(parameters=parameters), + show_annotation=False, show_return_annotation=False) + return text.replace('\\', '\\\\') if app.config.strip_signature_backslash else text + + +def restore_class_signature(app: 'Sphinx', what: str, name: str, obj: 'Any', + options: 'Dict[str, Any]', signature: 'Optional[str]', + return_annotation: 'Optional[str]') -> 'Optional[tuple[str, None]]': + """Give a class the signature that a call to it takes. + + * A class whose ``__init__`` a ``__new__`` hides (see :func:`hidden_init`) + gets that ``__init__``'s parameters. + * An enumeration gets ``(*values)``, the lookup by value. The standard + library's ``__signature__`` gives a class with no members the + functional-API form instead, ``(new_class_name, /, names, *, module=None, + ...)``, which is how :class:`~pcapkit.const.reg.apptype.apptype.AppType`, + :class:`~pcapkit.const.pcapng.filter_type.FilterType` and + ``FrameType.Flags`` rendered (:issue:`1862`). + + The handler of ``sphinx-autodoc-typehints`` is still run first, for what it + records: it notes each object autodoc documents, and only a noted class has + its type parameters added to its header. Its signature is then replaced. + + 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 signature and no return annotation for such a class; :data:`None` + otherwise, to let the other handlers run. + + """ + if what not in {'class', 'exception'} or not isinstance(obj, type): + return None + if isinstance(obj, enum.EnumMeta): + restored = '(*values)' + else: + init = hidden_init(obj) + if init is None: + return None + restored = init_signature(app, init) + typehints_process_signature(app, what, name, obj, options, signature, return_annotation) + return restored, None + + +def constructor_for_types(cls: type) -> str: + """Which constructor ``sphinx-autodoc-typehints`` reads a class's parameter types from. + + The library reads them from the constructor :func:`inspect.signature` would + choose, so a pass-through ``__new__`` defined beside the ``__init__`` wins, + and a class docstring's parameter list loses its types: ``strict``, + ``store`` and ``timeout`` on the reassembly classes, ``fout`` and the rest on + the flow-tracing ones, ``dict_`` on :class:`Info` (:issue:`1860`). The + ``__init__`` that :func:`hidden_init` finds is read instead, as the releases + before 3.14 read it. + + An overloaded ``__init__`` keeps the library's choice. Reading it would add an + ``Overloads`` field to each class, which no release showed for these classes, + and the protocol classes, whose shared ``__init__`` is overloaded, list no + constructor parameters for types to go on. + + Args: + cls: The class being documented. + + Returns: + ``'__init__'`` or ``'__new__'``. + + """ + chosen = typehints_constructor_name(cls) + if chosen == '__new__': + init = hidden_init(cls) + if init is not None and not typing.get_overloads(init): + return '__init__' + return chosen + + +def read_constructor_types_from_init(app: 'Sphinx') -> None: # pylint: disable=unused-argument + """Have ``sphinx-autodoc-typehints`` choose constructors with :func:`constructor_for_types`. + + The library has no setting for the choice, so the modules of it that read the + choice for the docstring types are pointed at this one. + + Args: + app: Sphinx application. + + """ + for module in (typehints_docstring_handler, typehints_hints): + # NB: read first, so a release that drops the name fails here rather + # than gaining an attribute nothing reads. + getattr(module, 'constructor_name') + module.constructor_name = constructor_for_types + + +class DropTableWrapperIds(SphinxPostTransform): + """Take a wrapped table's ids off the ``table-wrapper`` container furo adds. + + furo wraps each table in a container so it can scroll, and copies every + attribute of the table onto it, ``ids`` included. A titled ``list-table`` + carries an id, so the page carried that id on the wrapper as well as on the + table -- the titled tables on ``const/reg.html`` (:issue:`1861`). The table + keeps its ids, so a reference to it still lands on it. + + """ + + formats = ('html',) + #: After furo's own wrapper transform, at the default 500. + default_priority = 510 + + def run(self, **kwargs: 'Any') -> None: + for container in self.document.findall(nodes.container): + if 'table-wrapper' not in container['classes']: + continue + inner = {id_ for child in container.children if isinstance(child, nodes.table) + for id_ in child['ids']} + container['ids'] = [id_ for id_ in container['ids'] if id_ not in inner] + + +def follow_builtins_alias(app: 'Sphinx', modname: 'Optional[str]', # pylint: disable=unused-argument + attribute: str) -> 'Optional[str]': + """Keep viewcode on the module that binds a name whose object reports :mod:`builtins`. + + The ``BufferID`` aliases are subscripted ``Tuple``, and ``Tuple`` in + :mod:`pcapkit.utilities.compat` is the builtin :class:`tuple`, so each alias + reports ``__module__ == 'builtins'``. viewcode followed that, recorded a + module with no source, and listed it in its module index, which then linked a + ``builtins.html`` that is never written (:issue:`1863`). + + Args: + app: Sphinx application. + modname: The module the object is documented in. + attribute: Its name there. + + Returns: + ``modname`` for such an object, and :data:`None` for anything else, which + leaves viewcode to follow it as usual. + + """ + if not modname: + return None + try: + value = pkgutil.resolve_name(f'{modname}:{attribute}') + except (ImportError, AttributeError, ValueError): + return None + return modname if getattr(value, '__module__', None) == 'builtins' else None + + # NB: handlers used to sit here -- ``remove_module_docstring``, # ``process_docstring``, ``process_fields`` and ``source_read`` -- each with its # ``app.connect`` call commented out in ``setup`` below, so none of them had run @@ -594,6 +978,17 @@ 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) + app.connect('builder-inited', read_constructor_types_from_init) + # 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. Its signature is + # the one this replaces. + app.connect('autodoc-process-signature', restore_class_signature, priority=400) + # NB: after the ``update_defvalue`` that autodoc's legacy documenters connect + # at the default 500; the current ones preserve the defaults before emitting. + app.connect('autodoc-before-process-signature', name_aliased_defaults, priority=600) + app.connect('viewcode-follow-imported', follow_builtins_alias) + app.add_post_transform(DropTableWrapperIds) 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/_dependency_gates.py b/tests/_dependency_gates.py index 294c2f22e..d4558df93 100644 --- a/tests/_dependency_gates.py +++ b/tests/_dependency_gates.py @@ -247,6 +247,9 @@ # cut to ``('docutils',)``. If the ``test`` and ``gate`` jobs install that # extra, its gap closes and the ``HAS_DOCUTILS`` exclusion has to go as well. 'docutils': ('Sphinx', 'docutils'), + # The theme ``HAS_FURO`` asks for, whose table wrapper the #1861 transform + # test runs. Only the ``docs`` extra carries it. + 'furo': ('furo',), } #: Flags whose condition is not a distribution, with what it is instead. A flag @@ -637,7 +640,7 @@ class Exclusion(NamedTuple): dark={'test': ('sphinx', 'sphinx_autodoc_typehints'), 'gate': ('sphinx', 'sphinx_autodoc_typehints')}, reason=( - 'The classes it gates (#1775, #1856 and the enum-value hook\'s) load ' + 'The classes it gates (#1775, #1856, #1858-#1863 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 ' @@ -674,6 +677,17 @@ class Exclusion(NamedTuple): 'wherever docutils is importable as well.' ), ), + 'HAS_FURO': Exclusion( + dark={'test': ('furo',), 'gate': ('furo',)}, + reason=( + 'The class it gates (#1861) runs the table-wrapper transform of furo, the ' + 'docs theme, ahead of the conf.py transform that undoes its duplicate ids, ' + 'and is gated on HAS_SPHINX as well. It is declined for the reason ' + 'HAS_SPHINX is: only deploy-pages.yml installs furo, from ' + 'docs/requirements.txt, and no workflow installs the docs extra itself; that ' + 'build is the end-to-end check of the same transform.' + ), + ), } diff --git a/tests/project/test_docs_aenum_formatter_1858_unit.py b/tests/project/test_docs_aenum_formatter_1858_unit.py new file mode 100644 index 000000000..3a60da841 --- /dev/null +++ b/tests/project/test_docs_aenum_formatter_1858_unit.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +"""Pins the docs build's rendering of :mod:`aenum` classes in types. + +GitHub issue #1858: ``typehints_fully_qualified`` is off and :mod:`aenum` has no +inventory, so a parameter annotated ``StdlibIntEnum | AenumIntEnum`` rendered as +``IntEnum | IntEnum``. ``format_aenum_class`` in :file:`docs/source/conf.py`, the +build's ``typehints_formatter``, names an :mod:`aenum` class ``aenum.IntEnum`` +and leaves everything else to the default rendering. + +The tests call the formatter directly. They need Sphinx and +the pinned ``sphinx-autodoc-typehints``, which :file:`conf.py` imports, and skip +without them, but run no build. + +""" + +from __future__ import annotations + +import enum +import typing +import unittest +from typing import TYPE_CHECKING + +import aenum + +from pcapkit.const.reg.linktype import LinkType +from tests.project.test_docs_ambiguous_xrefs_1775_unit import HAS_SPHINX, SPHINX_SKIP_REASON, load_conf + +if TYPE_CHECKING: + from typing import Any + + +@unittest.skipUnless(HAS_SPHINX, SPHINX_SKIP_REASON) +class AenumFormatterTests(unittest.TestCase): + """``format_aenum_class`` qualifies :mod:`aenum` classes and nothing else.""" + + conf = None # type: Any + + @classmethod + def setUpClass(cls) -> 'None': + cls.conf = load_conf() + + def format(self, annotation: 'Any') -> 'Any': + """The formatter's answer for ``annotation``.""" + return self.conf.format_aenum_class(annotation, None) + + def test_aenum_classes_are_qualified(self) -> 'None': + """By the name the package exports, not the ``aenum._enum`` module defining them.""" + for cls in (aenum.IntEnum, aenum.StrEnum, aenum.Enum, aenum.IntFlag): + with self.subTest(cls=cls): + self.assertEqual(cls.__module__, 'aenum._enum') + self.assertEqual(self.format(cls), f':py:class:`aenum.{cls.__name__}`') + + def test_union_members_render_apart(self) -> 'None': + """``StdlibIntEnum | AenumIntEnum``, as ``dumpkit/pcap.py`` annotates ``protocol``.""" + answers = [self.format(arg) for arg in typing.get_args(typing.Union[enum.IntEnum, aenum.IntEnum])] + self.assertEqual(answers, [None, ':py:class:`aenum.IntEnum`']) + + def test_other_annotations_are_left_alone(self) -> 'None': + """A standard library class, a pcapkit registry built on :mod:`aenum`, and non-classes.""" + for annotation in (enum.IntEnum, int, LinkType, 'IntEnum', None, typing.Optional[int], + list[aenum.IntEnum]): + with self.subTest(annotation=annotation): + self.assertIsNone(self.format(annotation)) + + def test_conf_configures_it(self) -> 'None': + """Only one formatter can be configured, and this is it.""" + self.assertIs(self.conf.typehints_formatter, self.conf.format_aenum_class) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/project/test_docs_class_signature_1860_unit.py b/tests/project/test_docs_class_signature_1860_unit.py new file mode 100644 index 000000000..8492715ec --- /dev/null +++ b/tests/project/test_docs_class_signature_1860_unit.py @@ -0,0 +1,254 @@ +# -*- coding: utf-8 -*- +"""Pins the docs build's class signatures to the constructor a call runs. + +GitHub issue #1860: Sphinx 9.1 takes a class signature from any ``__new__`` the +class defines or inherits, before ``__init__``. ``sphinx-autodoc-typehints`` 3.15 +takes a ``__new__`` defined beside the ``__init__``, as :func:`inspect.signature` +does. :class:`~pcapkit.protocols.protocol.ProtocolBase`, +:class:`~pcapkit.corekit.infoclass.Info`, +:class:`~pcapkit.protocols.schema.schema.Schema` and the reassembly and +flow-tracing bases each define a ``__new__`` that only passes ``*args`` and +``**kwargs`` through, so their classes rendered that catch-all, and their +class docstrings lost the types of the parameters they list. + +``restore_class_signature`` in :file:`docs/source/conf.py` gives such a class its +``__init__``'s parameters, and ``constructor_for_types`` has the library read the +parameter types from that ``__init__``. The expected signatures are the ones the +build showed before 3.15, where it had one. + +The tests call the hooks directly and replay the signature event through Sphinx's +own :class:`~sphinx.events.EventManager`. They need Sphinx and +the pinned ``sphinx-autodoc-typehints`` and skip without them, but run no build. + +""" + +from __future__ import annotations + +import inspect +import types +import unittest +from typing import TYPE_CHECKING +from unittest import mock + +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 + +#: The event the signature hook answers. +EVENT = 'autodoc-process-signature' + +#: The catch-all the build rendered for these classes. +CATCH_ALL = '(*args, **kwargs)' + + +def app_for(conf: 'Any') -> 'Any': + """A stand-in application carrying the settings the hooks read.""" + config = types.SimpleNamespace(autodoc_preserve_defaults=conf.autodoc_preserve_defaults, + strip_signature_backslash=False) + return types.SimpleNamespace(config=config, pdb=False) + + +def emit(conf: 'Any', app: 'Any', event: 'str', *args: 'Any', library: 'Any' = None) -> 'Any': + """What the build's handlers for ``event`` answer, in the order the build runs them. + + Each handler is passed ``app`` and then ``args``. ``library`` stands in for a + handler an extension connects at :meth:`Sphinx.connect + `'s default priority; :file:`conf.py`'s + handlers follow with the priorities its ``setup`` gives them. + + """ + # pylint: disable=import-outside-toplevel + from sphinx.application import Sphinx + from sphinx.events import EventManager + + events = EventManager(app) + events.add(event) + if library is not None: + events.connect(event, library, inspect.signature(Sphinx.connect).parameters['priority'].default) + for name, handler, priority in connections(conf): + if name == event: + events.connect(name, handler, priority) + return events.emit_firstresult(event, *args) + + +def resolve(name: 'str') -> 'Any': + """The object at the dotted ``name``, which may name a nested class.""" + import pkgutil # pylint: disable=import-outside-toplevel + return pkgutil.resolve_name(name) + + +#: Each class, and the signature restored for it. +RESTORED = { + 'pcapkit.protocols.application.ftp.FTP': '(file=None, length=None, **kwargs)', + 'pcapkit.dumpkit.pcap.PCAPIO': '(fname, *, protocol, byteorder=sys.byteorder, nanosecond=False, **kwargs)', + 'pcapkit.foundation.reassembly.ipv4.IPv4': '(*, strict=True, store=True, timeout=None)', + 'pcapkit.foundation.traceflow.tcp.TCP': ('(fout, format, byteorder=sys.byteorder, nanosecond=False, ' + 'bidirectional=True, analyse=False)'), + 'pcapkit.corekit.infoclass.Info': '(dict_=None, **kwargs)', + 'pcapkit.corekit.infoclass.MultiInfo': '(mapping=None)', + 'pcapkit.interface.misc.Stream': '(filename, packets, conversations)', + 'pcapkit.protocols.schema.transport.udp.UDP': ('(srcport=NO_VALUE, dstport=NO_VALUE, len=NO_VALUE, ' + 'checksum=NO_VALUE, payload=NO_VALUE, trailer=NO_VALUE, *, ' + '__packet__=None, **kwargs)'), +} + +#: Classes whose signature is left to the other handlers: a class not yet +#: finalised, whose ``__init__`` is generated on its first construction; a class +#: whose ``__new__`` takes parameters of its own; a class with no ``__new__`` at all. +LEFT_ALONE = ( + 'pcapkit.protocols.data.application.ftp.FTP', + 'pcapkit.corekit.fields.strings.DecodedString', + 'pcapkit.foundation.extraction.Extractor', +) + + +class Forwarding: + """A base whose ``__init__`` names no parameters of its own.""" + + def __init__(self, *args: 'Any', **kwargs: 'Any') -> 'None': + pass + + +class Built(Forwarding): + """A class whose ``__new__`` takes the parameters, ahead of the base's ``__init__``.""" + + def __new__(cls, value: 'str', raw: 'bytes') -> 'Built': # pylint: disable=unused-argument + return super().__new__(cls) + + +@unittest.skipUnless(HAS_SPHINX, SPHINX_SKIP_REASON) +class ClassSignatureTests(unittest.TestCase): + """``restore_class_signature`` gives these classes their ``__init__``'s parameters.""" + + conf = None # type: Any + + @classmethod + def setUpClass(cls) -> 'None': + cls.conf = load_conf() + + def setUp(self) -> 'None': + # NB: the library's handler needs a real environment; here it is only + # checked to be called. + patcher = mock.patch.object(self.conf, 'typehints_process_signature', return_value=None) + self.library = patcher.start() + self.addCleanup(patcher.stop) + self.app = app_for(self.conf) + + def restore(self, name: 'str', what: 'str' = 'class') -> 'Any': + """The hook's own answer for the class at ``name``.""" + return self.conf.restore_class_signature(self.app, what, name, resolve(name), {}, CATCH_ALL, None) + + def test_classes_get_their_init_parameters(self) -> 'None': + """Each rendered ``(*args, **kwargs)``, or ``PCAPIO`` its base's ``(fname, **kwargs)``.""" + for name, expected in RESTORED.items(): + with self.subTest(name=name): + self.assertEqual(self.restore(name), (expected, None)) + + def test_restored_parameters_are_the_init_parameters(self) -> 'None': + """The names and kinds are the ``__init__``'s, and :func:`inspect.signature`'s where it has them.""" + from sphinx.util.inspect import signature_from_str # pylint: disable=import-outside-toplevel + for name in RESTORED: + cls = resolve(name) + init = self.conf.hidden_init(cls) + restored = signature_from_str(self.restore(name)[0]) + shape = [(param.name, param.kind) for param in restored.parameters.values()] + with self.subTest(name=name): + self.assertEqual(shape, [(param.name, param.kind) + for param in list(inspect.signature(init).parameters.values())[1:]]) + public = inspect.signature(cls).parameters.values() + if [param.kind for param in public] != [inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD]: + self.assertEqual(shape, [(param.name, param.kind) for param in public]) + + def test_other_classes_are_left_alone(self) -> 'None': + """Nothing hides these classes' constructor, or it is not known before first construction.""" + for name in LEFT_ALONE: + with self.subTest(name=name): + self.assertIsNone(self.restore(name)) + self.assertIsNone(self.restore('pcapkit.protocols.application.ftp.FTP', what='attribute')) + self.library.assert_not_called() + + def test_new_with_parameters_is_left_to_autodoc(self) -> 'None': + """Walking past it would restore the base's ``(*args, **kwargs)`` over ``(value, raw)``.""" + self.assertIsNone(self.conf.hidden_init(Built)) + self.assertIsNone(self.conf.restore_class_signature(self.app, 'class', 'Built', Built, {}, '(value, raw)', + None)) + self.library.assert_not_called() + + def test_library_handler_still_runs(self) -> 'None': + """From 3.14 it records the class, and only a recorded class gets its type parameters.""" + name = 'pcapkit.foundation.reassembly.ipv4.IPv4' + self.restore(name) + self.library.assert_called_once_with(self.app, 'class', name, resolve(name), {}, CATCH_ALL, None) + + def test_setup_connects_it_ahead_of_the_library(self) -> 'None': + """At the library's priority the library would answer first, with the catch-all.""" + 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.restore_class_signature] + self.assertEqual([event for event, _ in wired], [EVENT]) + self.assertLess(wired[0][1], default) + + def test_build_restores_the_signature(self) -> 'None': + """Through the event, the catch-all the library answers is replaced.""" + name = 'pcapkit.protocols.application.ftp.FTP' + result = emit(self.conf, self.app, EVENT, 'class', name, resolve(name), {}, CATCH_ALL, None, + library=lambda *args: (CATCH_ALL, None)) + self.assertEqual(result, (RESTORED[name], None)) + + +@unittest.skipUnless(HAS_SPHINX, SPHINX_SKIP_REASON) +class ConstructorTypesTests(unittest.TestCase): + """``constructor_for_types`` has the library read parameter types from the ``__init__``.""" + + conf = None # type: Any + + @classmethod + def setUpClass(cls) -> 'None': + cls.conf = load_conf() + + def test_init_behind_a_pass_through_new_is_read(self) -> 'None': + """Each class docstring lists the ``__init__`` parameters, which lost their types.""" + for name in ('pcapkit.foundation.reassembly.ipv4.IPv4', 'pcapkit.foundation.traceflow.tcp.TCP', + 'pcapkit.corekit.infoclass.Info', 'pcapkit.corekit.infoclass.MultiInfo'): + cls = resolve(name) + with self.subTest(name=name): + self.assertEqual(self.conf.typehints_constructor_name(cls), '__new__') + self.assertEqual(self.conf.constructor_for_types(cls), '__init__') + + def test_overloaded_init_keeps_the_library_choice(self) -> 'None': + """Reading it would add an ``Overloads`` field to every protocol class.""" + cls = resolve('pcapkit.protocols.application.ftp.FTP') + self.assertEqual(self.conf.constructor_for_types(cls), self.conf.typehints_constructor_name(cls)) + + def test_other_classes_keep_the_library_choice(self) -> 'None': + """A class with no pass-through ``__new__`` in the way is left as the library reads it.""" + for name in ('pcapkit.dumpkit.pcap.PCAPIO', 'pcapkit.corekit.fields.strings.DecodedString', + 'pcapkit.protocols.data.application.ftp.FTP'): + cls = resolve(name) + with self.subTest(name=name): + self.assertEqual(self.conf.constructor_for_types(cls), self.conf.typehints_constructor_name(cls)) + + def test_builder_inited_points_the_library_at_it(self) -> 'None': + """Each module that reads the choice for the docstring types reads this one.""" + modules = (self.conf.typehints_docstring_handler, self.conf.typehints_hints) + cls = resolve('pcapkit.foundation.reassembly.ipv4.IPv4') + with mock.patch.object(modules[0], 'constructor_name', modules[0].constructor_name), \ + mock.patch.object(modules[1], 'constructor_name', modules[1].constructor_name): + self.conf.read_constructor_types_from_init(None) + for module in modules: + self.assertIs(module.constructor_name, self.conf.constructor_for_types) + self.assertEqual(self.conf.typehints_hints.documenting_class('x', cls), (cls, '__init__')) + + def test_setup_connects_it(self) -> 'None': + """Unconnected, the library goes on reading the pass-through ``__new__``.""" + wired = [event for event, handler, _ in connections(self.conf) + if handler is self.conf.read_constructor_types_from_init] + self.assertEqual(wired, ['builder-inited']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/project/test_docs_enum_signature_1862_unit.py b/tests/project/test_docs_enum_signature_1862_unit.py new file mode 100644 index 000000000..1c50bbd0a --- /dev/null +++ b/tests/project/test_docs_enum_signature_1862_unit.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +"""Pins the docs build's enumeration signatures to ``(*values)``. + +GitHub issue #1862: for an enumeration with no members, the standard library's +``__signature__`` is the functional-API form, ``(new_class_name, /, names, *, +module=None, ...)``, so +:class:`~pcapkit.const.reg.apptype.apptype.AppType`, +:class:`~pcapkit.const.pcapng.filter_type.FilterType` and +:class:`FrameType.Flags ` +rendered it. ``restore_class_signature`` in :file:`docs/source/conf.py` gives +every enumeration the ``(*values)`` an enumeration with members renders. + +The tests call the hook directly and replay the event through Sphinx's own +:class:`~sphinx.events.EventManager`. They need Sphinx and +the pinned ``sphinx-autodoc-typehints``, and skip without them, but run no build. + +""" + +from __future__ import annotations + +import enum +import inspect +import unittest +from typing import TYPE_CHECKING +from unittest import mock + +from tests.project.test_docs_ambiguous_xrefs_1775_unit import HAS_SPHINX, SPHINX_SKIP_REASON, load_conf +from tests.project.test_docs_class_signature_1860_unit import EVENT, app_for, emit, resolve + +if TYPE_CHECKING: + from typing import Any + +#: The memberless enumerations that rendered the functional-API form. +MEMBERLESS = ( + 'pcapkit.const.reg.apptype.apptype.AppType', + 'pcapkit.const.pcapng.filter_type.FilterType', + 'pcapkit.protocols.schema.application.httpv2.FrameType.Flags', +) + +#: The signature every enumeration gets. +VALUES = '(*values)' + + +class Empty(enum.Enum): + """A standard library enumeration with no members.""" + + +class Colour(enum.Enum): + """A standard library enumeration with members.""" + + RED = 1 + + +@unittest.skipUnless(HAS_SPHINX, SPHINX_SKIP_REASON) +class EnumSignatureTests(unittest.TestCase): + """``restore_class_signature`` gives every enumeration ``(*values)``.""" + + conf = None # type: Any + + @classmethod + def setUpClass(cls) -> 'None': + cls.conf = load_conf() + + def setUp(self) -> 'None': + patcher = mock.patch.object(self.conf, 'typehints_process_signature', return_value=None) + self.library = patcher.start() + self.addCleanup(patcher.stop) + self.app = app_for(self.conf) + + def restore(self, name: 'str', obj: 'Any') -> 'Any': + """The hook's own answer for the enumeration ``obj``.""" + return self.conf.restore_class_signature(self.app, 'class', name, obj, {}, str(inspect.signature(obj)), None) + + def test_memberless_registries_get_values(self) -> 'None': + """Each one's own signature is another form, the functional API's from Python 3.12.""" + for name in MEMBERLESS: + obj = resolve(name) + with self.subTest(name=name): + self.assertFalse(obj.__members__) + self.assertNotEqual(str(inspect.signature(obj)), VALUES) + self.assertEqual(self.restore(name, obj), (VALUES, None)) + + def test_any_enumeration_gets_values(self) -> 'None': + """An :mod:`aenum` registry with members, and standard library ones with and without.""" + for obj in (resolve('pcapkit.const.arp.hardware.Hardware'), Empty, Colour): + with self.subTest(obj=obj): + self.assertEqual(self.restore(obj.__qualname__, obj), (VALUES, None)) + + def test_build_gives_values(self) -> 'None': + """Through the event, the library's functional-API answer is replaced.""" + name = MEMBERLESS[0] + obj = resolve(name) + written = str(inspect.signature(obj)) + result = emit(self.conf, self.app, EVENT, 'class', name, obj, {}, written, None, + library=lambda *args: (written, None)) + self.assertEqual(result, (VALUES, None)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/project/test_docs_preserve_defaults_1859_unit.py b/tests/project/test_docs_preserve_defaults_1859_unit.py new file mode 100644 index 000000000..794fae34a --- /dev/null +++ b/tests/project/test_docs_preserve_defaults_1859_unit.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +"""Pins the docs build's signature defaults to their source text. + +GitHub issue #1859: autodoc renders a default's ``repr()`` unless +``autodoc_preserve_defaults`` is on, so every ``byteorder=sys.byteorder`` showed +the build host's ``'little'``, and the ``corekit.fields`` pages showed +``default=`` and +``callback=>``. :file:`docs/source/conf.py` turns +the setting on. Source text then exposed the private aliases the protocol +modules import their registries under, ``next=Enum_TransType.UDP``, so +``name_aliased_defaults`` names each such class by its own name. + +The tests replay ``autodoc-before-process-signature`` through Sphinx's own +:class:`~sphinx.events.EventManager`, with Sphinx's +:func:`~sphinx.ext.autodoc.preserve_defaults.update_defvalue`, which acts only +when the setting is on, and render the signature as autodoc does. They need +Sphinx and the pinned ``sphinx-autodoc-typehints``, and skip without them, but run no build. + +""" + +from __future__ import annotations + +import inspect +import unittest +from typing import TYPE_CHECKING + +from tests.project.test_docs_ambiguous_xrefs_1775_unit import (HAS_SPHINX, SPHINX_SKIP_REASON, connections, + load_conf) +from tests.project.test_docs_class_signature_1860_unit import app_for, emit + +if TYPE_CHECKING: + from typing import Any, Callable + +#: The event the alias hook answers. +EVENT = 'autodoc-before-process-signature' + + +@unittest.skipUnless(HAS_SPHINX, SPHINX_SKIP_REASON) +class PreserveDefaultsTests(unittest.TestCase): + """With the build's settings and hooks, defaults render as written, aliases named.""" + + conf = None # type: Any + + @classmethod + def setUpClass(cls) -> 'None': + cls.conf = load_conf() + + def render(self, function: 'Callable[..., Any]') -> 'str': + """The signature autodoc renders for ``function`` in the build.""" + # pylint: disable=import-outside-toplevel + from sphinx.ext.autodoc.preserve_defaults import update_defvalue + from sphinx.util.inspect import stringify_signature + + had = '__signature__' in vars(function) + try: + emit(self.conf, app_for(self.conf), EVENT, function, False, library=update_defvalue) + return stringify_signature(inspect.signature(function), show_annotation=False) + finally: + if not had: + vars(function).pop('__signature__', None) + + def test_byteorder_renders_as_written(self) -> 'None': + """Not as the byte order of whichever host built the docs; a module alias stays.""" + from pcapkit.interface.core import trace # pylint: disable=import-outside-toplevel + rendered = self.render(trace) + self.assertIn('byteorder=sys.byteorder', rendered) + self.assertNotIn("'little'", rendered) + self.assertNotIn("'big'", rendered) + + def test_sentinel_and_lambda_render_as_written(self) -> 'None': + """``NO_VALUE`` and the lambda, rather than their ``repr()``.""" + from pcapkit.corekit.fields.numbers import NumberField # pylint: disable=import-outside-toplevel + rendered = self.render(NumberField.__init__) + self.assertIn('default=NO_VALUE', rendered) + self.assertIn('callback=lambda', rendered) + self.assertNotIn('<', rendered) + + def test_aliased_registries_are_named_by_their_class(self) -> 'None': + """``Enum_TransType`` is the module's private name for ``TransType``.""" + # pylint: disable=import-outside-toplevel + from pcapkit.protocols.application.httpv2 import HTTP + from pcapkit.protocols.internet.ah import AH + from pcapkit.protocols.misc.pcapng import PCAPNG + + for function, expected in ((AH.make, 'next=TransType.UDP'), (HTTP.make, 'type=Frame.DATA'), + (PCAPNG._make_option_if_filter, # pylint: disable=protected-access + 'filter=FilterType(0)')): + with self.subTest(function=function.__qualname__): + rendered = self.render(function) + self.assertIn(expected, rendered) + self.assertNotIn('Enum_', rendered) + + def test_setup_renames_after_the_defaults_are_preserved(self) -> 'None': + """At or below the legacy ``update_defvalue``'s priority, nothing would be preserved yet.""" + 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.name_aliased_defaults] + self.assertEqual([event for event, _ in wired], [EVENT]) + self.assertGreater(wired[0][1], default) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/project/test_docs_table_wrapper_ids_1861_unit.py b/tests/project/test_docs_table_wrapper_ids_1861_unit.py new file mode 100644 index 000000000..999d77b62 --- /dev/null +++ b/tests/project/test_docs_table_wrapper_ids_1861_unit.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +"""Pins the docs build to unique table ids. + +GitHub issue #1861: furo wraps each table in a ``table-wrapper`` container and +copies every attribute of the table onto it, ``ids`` included, so a titled +``list-table`` -- which Sphinx gives an id, to number it -- carried its id on +the wrapper as well as on the table on ``const/reg.html``. ``DropTableWrapperIds`` in +:file:`docs/source/conf.py` takes the table's ids off the wrapper after furo's +transform has run. + +The tests run furo's transform and then this one over a parsed ``list-table``. +They need Sphinx, the pinned ``sphinx-autodoc-typehints`` and furo, and skip without them, but run +no build. + +""" + +from __future__ import annotations + +import importlib.util +import unittest +from typing import TYPE_CHECKING +from unittest import mock + +from tests.project.test_docs_ambiguous_xrefs_1775_unit import HAS_SPHINX, SPHINX_SKIP_REASON, load_conf + +if TYPE_CHECKING: + from typing import Any + +#: The theme whose transform copies the ids. +HAS_FURO = importlib.util.find_spec('furo') is not None + +#: A titled table, as ``AppType.tabulate`` writes them on ``const/reg.html``. +SOURCE = '''\ +.. list-table:: Transport protocols + + * - TCP + - 6 +''' + + +@unittest.skipUnless(HAS_SPHINX, SPHINX_SKIP_REASON) +@unittest.skipUnless(HAS_FURO, 'furo is not installed') +class TableWrapperIdTests(unittest.TestCase): + """``DropTableWrapperIds`` leaves each id on the table alone.""" + + conf = None # type: Any + + @classmethod + def setUpClass(cls) -> 'None': + cls.conf = load_conf() + + def wrapped(self, *extra: 'str') -> 'Any': + """The table furo has wrapped, with the id Sphinx's numbering gives it.""" + # pylint: disable=import-outside-toplevel + import furo + from docutils import nodes + from docutils.core import publish_doctree + + document = publish_doctree(SOURCE) + table = next(iter(document.findall(nodes.table))) + # NB: what Sphinx's ``AutoNumbering`` transform does for a titled table. + document.note_implicit_target(table) + furo.WrapTableAndMathInAContainerTransform(document).run() + wrapper = table.parent + wrapper['ids'].extend(extra) + return document, wrapper, table + + def test_furo_copies_the_id(self) -> 'None': + """Without the fix, the wrapper and the table carry the same id.""" + _, wrapper, table = self.wrapped() + self.assertIn('table-wrapper', wrapper['classes']) + self.assertEqual(wrapper['ids'], table['ids']) + self.assertTrue(table['ids']) + + def test_wrapper_loses_the_table_id(self) -> 'None': + """The table keeps it, so a reference to the table still lands on it.""" + document, wrapper, table = self.wrapped() + ids = list(table['ids']) + self.conf.DropTableWrapperIds(document).run() + self.assertEqual(wrapper['ids'], []) + self.assertEqual(table['ids'], ids) + + def test_wrapper_keeps_ids_of_its_own(self) -> 'None': + """Only the ids the table carries are taken off.""" + document, wrapper, _ = self.wrapped('wrapper-only') + self.conf.DropTableWrapperIds(document).run() + self.assertEqual(wrapper['ids'], ['wrapper-only']) + + def test_setup_registers_it_after_furo(self) -> 'None': + """Post-transforms run by priority, so at furo's or below it would find nothing to fix.""" + import furo # pylint: disable=import-outside-toplevel + from sphinx.application import Sphinx # pylint: disable=import-outside-toplevel + app = mock.create_autospec(Sphinx, instance=True) + self.conf.setup(app) + app.add_post_transform.assert_called_once_with(self.conf.DropTableWrapperIds) + self.assertGreater(self.conf.DropTableWrapperIds.default_priority, + furo.WrapTableAndMathInAContainerTransform.default_priority) + self.assertEqual(self.conf.DropTableWrapperIds.formats, ('html',)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/project/test_docs_viewcode_builtins_1863_unit.py b/tests/project/test_docs_viewcode_builtins_1863_unit.py new file mode 100644 index 000000000..fdfbb70bc --- /dev/null +++ b/tests/project/test_docs_viewcode_builtins_1863_unit.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +"""Pins viewcode's module index to modules that have source pages. + +GitHub issue #1863: each ``BufferID`` alias is a subscripted ``Tuple``, which is +the builtin :class:`tuple` on every Python the docs build on, so it reports +``__module__ == 'builtins'``. viewcode followed that, and its module index linked +a ``builtins.html`` that is never written. ``follow_builtins_alias`` in +:file:`docs/source/conf.py` keeps viewcode on the module that binds such a name. + +The tests call the hook directly and replay the event through Sphinx's own +:class:`~sphinx.events.EventManager`. They need Sphinx and +the pinned ``sphinx-autodoc-typehints``, and skip without them, but run no build. + +""" + +from __future__ import annotations + +import types +import unittest +from typing import TYPE_CHECKING + +from tests.project.test_docs_ambiguous_xrefs_1775_unit import (HAS_SPHINX, SPHINX_SKIP_REASON, connections, + load_conf) +from tests.project.test_docs_class_signature_1860_unit import emit, resolve + +if TYPE_CHECKING: + from typing import Any + +#: The event the hook answers. +EVENT = 'viewcode-follow-imported' + +#: The modules binding a ``BufferID`` alias. +ALIASES = ( + 'pcapkit.foundation.reassembly.data.ip', + 'pcapkit.foundation.reassembly.data.tcp', + 'pcapkit.foundation.traceflow.data.tcp', +) + + +@unittest.skipUnless(HAS_SPHINX, SPHINX_SKIP_REASON) +class ViewcodeBuiltinsTests(unittest.TestCase): + """``follow_builtins_alias`` answers for an object reporting :mod:`builtins`, and nothing else.""" + + conf = None # type: Any + + @classmethod + def setUpClass(cls) -> 'None': + cls.conf = load_conf() + + def follow(self, modname: 'Any', attribute: 'str') -> 'Any': + """The hook's own answer for ``attribute`` documented in ``modname``.""" + return self.conf.follow_builtins_alias(None, modname, attribute) + + def test_aliases_stay_in_their_module(self) -> 'None': + """viewcode's own lookup would record :mod:`builtins` for each.""" + from sphinx.ext.viewcode import _get_full_modname # pylint: disable=import-outside-toplevel + for modname in ALIASES: + with self.subTest(modname=modname): + self.assertEqual(_get_full_modname(modname, 'BufferID'), 'builtins') + self.assertEqual(self.follow(modname, 'BufferID'), modname) + + def test_other_names_are_left_to_viewcode(self) -> 'None': + """A class defined there, a name it lacks, and an object with no module.""" + self.assertIsNone(self.follow(ALIASES[0], 'Packet')) + self.assertIsNone(self.follow(ALIASES[0], 'NoSuchName')) + self.assertIsNone(self.follow(None, 'BufferID')) + + def test_setup_connects_it(self) -> 'None': + """Unconnected, viewcode records :mod:`builtins` again.""" + wired = [event for event, handler, _ in connections(self.conf) + if handler is self.conf.follow_builtins_alias] + self.assertEqual(wired, [EVENT]) + + def test_build_follows_the_alias_to_its_module(self) -> 'None': + """Through the event, as viewcode emits it before its own lookup.""" + self.assertEqual(resolve(f'{ALIASES[0]}.BufferID').__module__, 'builtins') + app = types.SimpleNamespace(pdb=False) + self.assertEqual(emit(self.conf, app, EVENT, ALIASES[0], 'BufferID'), ALIASES[0]) + + +if __name__ == '__main__': + unittest.main()