diff --git a/docs/source/conf.py b/docs/source/conf.py index a2c2be33b..e5370ba03 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -4,13 +4,17 @@ # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html +import builtins import datetime +import enum import importlib import logging import os import pkgutil from typing import TYPE_CHECKING +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 @@ -28,7 +32,8 @@ import pcapkit if TYPE_CHECKING: - from typing import Any, Dict, Optional + from typing import Any, Dict, List, Optional + from docutils.nodes import document from sphinx.application import Sphinx logger = logging.getLogger('pcapkit-sphinx') @@ -393,6 +398,124 @@ def claim_attribute_signature(app: 'Sphinx', what: str, name: str, # pylint: di return None +#: Every name :mod:`builtins` binds -- ``dict``, ``set``, ``type`` and the rest. +BUILTIN_NAMES = frozenset(dir(builtins)) + + +def resolve_builtins_exactly(app: 'Sphinx', doctree: 'document') -> None: # pylint: disable=unused-argument + """Stop a bare builtin name in a type being suffix-matched to a pcapkit member. + + The Python domain resolves a reference carrying a ``refspecific`` attribute + by *fuzzy* search when no exact match exists: any documented object whose + name ends in ``.dict`` will do. Every type it builds implicitly carries that + attribute -- the doc-field types (``:type:``, ``:vartype:``) set it to + :data:`True`, and an annotation sets it to :data:`False`, which counts all + the same, because the domain only asks whether the attribute is *present*. + So ``dict`` in an annotation found the registry members + :attr:`TCP.dict ` and + ``UDP.dict`` and linked to the first, with a ``more than one target found`` + warning; ``type`` and ``set`` went the same way. A registry growing one + member named like a builtin would mislink it *silently*. + + Dropping the attribute for a builtin name leaves only the exact lookup, by + the bare name and within the current module and class. That finds nothing + for ``dict``, so :mod:`~sphinx.ext.intersphinx` links it to the Python docs. + + An explicit role is left alone: ``:class:`.dict``` asks for the fuzzy + search by its leading dot, and only a role's node carries ``refwarn``. + + Args: + app: Sphinx application. + doctree: The document just read. + + """ + for node in doctree.findall(addnodes.pending_xref): + attributes = node.attributes + if (attributes.get('refdomain') == 'py' and 'refspecific' in attributes + and 'refwarn' not in attributes + and attributes.get('reftarget') in BUILTIN_NAMES): + del attributes['refspecific'] + + +#: Enum member docstrings as autodoc read them, held from +#: :func:`save_enum_member_docstring` until :func:`restore_enum_member_docstring`. +_ENUM_MEMBER_DOCSTRINGS = {} # type: Dict[str, List[str]] + + +def is_enum_member(name: str) -> bool: + """Whether ``name`` is the fully qualified name of an enum member. + + Decided from the name, because autodoc does not hand over the member: for + ``PayloadProtocolIdentifier.H_323`` Sphinx 9.1 passes the plain ``int`` 13. + + Args: + name: Fully qualified name of the object being documented. + + Returns: + :data:`True` if the name before the last dot is an enumeration and the + last part is one of its members. + + """ + parent_name, _, member = name.rpartition('.') + try: + parent = pkgutil.resolve_name(parent_name) + except (ImportError, AttributeError, ValueError): + return False + return isinstance(parent, enum.EnumMeta) and member in parent.__members__ + + +def save_enum_member_docstring(app: 'Sphinx', what: str, name: str, # pylint: disable=unused-argument + obj: 'Any', options: 'Any', lines: 'List[str]') -> None: # pylint: disable=unused-argument + """Keep an enum member's docstring before :mod:`~sphinx.ext.napoleon` reads it. + + Napoleon takes the first line of every attribute docstring as Google's + inline ``type: description`` form and splits it at the first colon. A + registry member's docstring is the registry's own text, so the colon is + usually a URL's: ``H.323 [http://standard.pictel.com/...]`` rendered as + *Type:* ``H.323 [http`` above the description ``//standard.pictel.com/...``, + on 51 members. The bogus type was also parsed as a cross-reference, which is + where the ``http`` and ``Echo`` ``more than one target found`` warnings came + from. + + 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. + lines: The docstring, which is not changed here. + + """ + if what == 'attribute' and is_enum_member(name): + _ENUM_MEMBER_DOCSTRINGS[name] = list(lines) + + +def restore_enum_member_docstring(app: 'Sphinx', what: str, name: str, # pylint: disable=unused-argument + obj: 'Any', options: 'Any', lines: 'List[str]') -> None: # pylint: disable=unused-argument + """Undo napoleon's inline type on an enum member, from the saved docstring. + + An enum member's type is its enumeration, which autodoc already shows, so a + ``:type:`` field napoleon added there is always a misread line. Only that + case is undone: a docstring napoleon rewrote in any other way keeps the + rewrite. + + 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. + lines: The docstring after napoleon, restored in place. + + """ + original = _ENUM_MEMBER_DOCSTRINGS.pop(name, None) + if original is None: + return + if (any(line.startswith(':type:') for line in lines) + and not any(line.startswith(':type:') for line in original)): + lines[:] = original + + # NB: four 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 @@ -422,3 +545,8 @@ def setup(app: 'Sphinx') -> None: # 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. + app.connect('autodoc-process-docstring', save_enum_member_docstring, priority=400) + app.connect('autodoc-process-docstring', restore_enum_member_docstring, priority=600) diff --git a/docs/source/pcapkit/corekit/context.rst b/docs/source/pcapkit/corekit/context.rst index 6488fb511..f59061384 100644 --- a/docs/source/pcapkit/corekit/context.rst +++ b/docs/source/pcapkit/corekit/context.rst @@ -75,5 +75,5 @@ Decoding an ESP tunnel end to end: Type Variables -------------- -.. data:: pcapkit.corekit.context._CT +.. data:: _CT :type: pcapkit.corekit.context.ProtocolContext diff --git a/docs/source/pcapkit/corekit/decorators.rst b/docs/source/pcapkit/corekit/decorators.rst index 2ae71d690..41789d052 100644 --- a/docs/source/pcapkit/corekit/decorators.rst +++ b/docs/source/pcapkit/corekit/decorators.rst @@ -31,11 +31,11 @@ decorators, including :func:`~pcapkit.corekit.decorators.seekset`, Type Variables -------------- -.. data:: pcapkit.corekit.decorators.R_seekset +.. data:: R_seekset :type: typing.Any -.. data:: pcapkit.corekit.decorators.R_beholder +.. data:: R_beholder :type: pcapkit.protocols.protocol.ProtocolBase -.. data:: pcapkit.corekit.decorators.R_prepare +.. data:: R_prepare :type: pcapkit.protocols.schema.schema.Schema diff --git a/docs/source/pcapkit/corekit/fields/collections.rst b/docs/source/pcapkit/corekit/fields/collections.rst index 972ea533b..7affc2e12 100644 --- a/docs/source/pcapkit/corekit/fields/collections.rst +++ b/docs/source/pcapkit/corekit/fields/collections.rst @@ -20,8 +20,8 @@ Option List Fields Type Variables ~~~~~~~~~~~~~~ -.. data:: pcapkit.corekit.fields.collections._TL +.. data:: _TL :type: pcapkit.protocols.schema.schema.Schema | pcapkit.corekit.fields.field.FieldBase | bytes -.. data:: pcapkit.corekit.fields.collections._TS +.. data:: _TS :type: pcapkit.protocols.schema.schema.Schema diff --git a/docs/source/pcapkit/corekit/fields/field.rst b/docs/source/pcapkit/corekit/fields/field.rst index c7b6012b2..054753331 100644 --- a/docs/source/pcapkit/corekit/fields/field.rst +++ b/docs/source/pcapkit/corekit/fields/field.rst @@ -36,5 +36,5 @@ Internal Definitions Type Variables -------------- -.. data:: pcapkit.corekit.fields.field._T +.. data:: _T :type: typing.Any diff --git a/docs/source/pcapkit/corekit/fields/ipaddress.rst b/docs/source/pcapkit/corekit/fields/ipaddress.rst index 04ad0266c..5301581ac 100644 --- a/docs/source/pcapkit/corekit/fields/ipaddress.rst +++ b/docs/source/pcapkit/corekit/fields/ipaddress.rst @@ -55,11 +55,11 @@ Internal Definitions Type Variables ~~~~~~~~~~~~~~ -.. data:: pcapkit.corekit.fields.ipaddress._T +.. data:: _T :type: ipaddress.IPv4Address | ipaddress.IPv6Address | ipaddress.IPv4Interface | ipaddress.IPv6Interface -.. data:: pcapkit.corekit.fields.ipaddress._AT +.. data:: _AT :type: ipaddress.IPv4Address | ipaddress.IPv6Address -.. data:: pcapkit.corekit.fields.ipaddress._IT +.. data:: _IT :type: ipaddress.IPv4Interface | ipaddress.IPv6Interface diff --git a/docs/source/pcapkit/corekit/fields/misc.rst b/docs/source/pcapkit/corekit/fields/misc.rst index 05f3afec0..fc8d2f158 100644 --- a/docs/source/pcapkit/corekit/fields/misc.rst +++ b/docs/source/pcapkit/corekit/fields/misc.rst @@ -36,14 +36,14 @@ Generic Data Fields Type Variables ~~~~~~~~~~~~~~ -.. data:: pcapkit.corekit.fields.misc._TC +.. data:: _TC :type: typing.Any -.. data:: pcapkit.corekit.fields.misc._TS +.. data:: _TS :type: pcapkit.protocols.schema.schema.Schema -.. data:: pcapkit.corekit.fields.misc._TP +.. data:: _TP :type: pcapkit.protocols.protocol.Protocol -.. data:: pcapkit.corekit.fields.misc._TN +.. data:: _TN :type: pcapkit.corekit.sentinels.NoValueType diff --git a/docs/source/pcapkit/corekit/fields/numbers.rst b/docs/source/pcapkit/corekit/fields/numbers.rst index 8a21fe1ce..ce1ca22d7 100644 --- a/docs/source/pcapkit/corekit/fields/numbers.rst +++ b/docs/source/pcapkit/corekit/fields/numbers.rst @@ -63,5 +63,5 @@ Internal Definitions Type Variables -------------- -.. data:: pcapkit.corekit.fields.numbers._T +.. data:: _T :type: int diff --git a/docs/source/pcapkit/corekit/fields/strings.rst b/docs/source/pcapkit/corekit/fields/strings.rst index 565b0ad3d..0db95fe72 100644 --- a/docs/source/pcapkit/corekit/fields/strings.rst +++ b/docs/source/pcapkit/corekit/fields/strings.rst @@ -35,5 +35,5 @@ Internal Definitions Type Variables -------------- -.. data:: pcapkit.corekit.fields.strings._T +.. data:: _T :type: str | bytes | python:dict[str, typing.Any] diff --git a/docs/source/pcapkit/corekit/infoclass.rst b/docs/source/pcapkit/corekit/infoclass.rst index 9a1ed3ee8..3a8ba3cd6 100644 --- a/docs/source/pcapkit/corekit/infoclass.rst +++ b/docs/source/pcapkit/corekit/infoclass.rst @@ -50,11 +50,11 @@ Internal Definitions Type Variables -------------- -.. data:: pcapkit.corekit.infoclass.KT +.. data:: KT :type: typing.Any -.. data:: pcapkit.corekit.infoclass.VT +.. data:: VT :type: typing.Any -.. data:: pcapkit.corekit.infoclass.ST +.. data:: ST :type: typing.Type[pcapkit.corekit.infoclass.Info] diff --git a/docs/source/pcapkit/corekit/module.rst b/docs/source/pcapkit/corekit/module.rst index 07dc211af..528414db6 100644 --- a/docs/source/pcapkit/corekit/module.rst +++ b/docs/source/pcapkit/corekit/module.rst @@ -27,5 +27,5 @@ with a class name. Type Variables -------------- -.. data:: pcapkit.corekit.module._T +.. data:: _T :type: typing.Any diff --git a/docs/source/pcapkit/corekit/multidict.rst b/docs/source/pcapkit/corekit/multidict.rst index 582be07b8..a1cd6042c 100644 --- a/docs/source/pcapkit/corekit/multidict.rst +++ b/docs/source/pcapkit/corekit/multidict.rst @@ -40,11 +40,11 @@ values per key, based on the `Werkzeug`_ project. Type Variables -------------- -.. data:: pcapkit.corekit.multidict._T +.. data:: _T :type: typing.Any -.. data:: pcapkit.corekit.multidict._KT +.. data:: _KT :type: typing.Any -.. data:: pcapkit.corekit.multidict._VT +.. data:: _VT :type: typing.Any diff --git a/docs/source/pcapkit/foundation/engines/engine.rst b/docs/source/pcapkit/foundation/engines/engine.rst index 20b07c268..c5186ff64 100644 --- a/docs/source/pcapkit/foundation/engines/engine.rst +++ b/docs/source/pcapkit/foundation/engines/engine.rst @@ -78,5 +78,5 @@ Internal Definitions Type Variables -------------- -.. data:: pcapkit.foundation.engines.engine._T +.. data:: _T :type: typing.Any diff --git a/docs/source/pcapkit/foundation/extraction.rst b/docs/source/pcapkit/foundation/extraction.rst index 0bcb5f2d1..06ec71141 100644 --- a/docs/source/pcapkit/foundation/extraction.rst +++ b/docs/source/pcapkit/foundation/extraction.rst @@ -104,7 +104,7 @@ extracts parameters from a PCAP file. Type Variables -------------- -.. data:: pcapkit.foundation.extraction._P +.. data:: _P :type: typing.Any .. |pypcap| replace:: ``pypcap`` diff --git a/docs/source/pcapkit/foundation/reassembly/ip/ip.rst b/docs/source/pcapkit/foundation/reassembly/ip/ip.rst index c6172d9a0..a377d0d50 100644 --- a/docs/source/pcapkit/foundation/reassembly/ip/ip.rst +++ b/docs/source/pcapkit/foundation/reassembly/ip/ip.rst @@ -25,8 +25,9 @@ Data Models :members: :show-inheritance: -.. data:: pcapkit.foundation.reassembly.data.ip.BufferID - :type: typing.Tuple[_AT, _AT, int, pcapkit.const.reg.transtype.TransType] +.. data:: BufferID + :module: pcapkit.foundation.reassembly.data.ip + :type: typing.Tuple[~pcapkit.foundation.reassembly.data.ip._AT, ~pcapkit.foundation.reassembly.data.ip._AT, int, pcapkit.const.reg.transtype.TransType] Data module for buffer ID. @@ -45,5 +46,6 @@ Data Models Type Variables -------------- -.. data:: pcapkit.foundation.reassembly.data.ip._AT +.. data:: _AT + :module: pcapkit.foundation.reassembly.data.ip :type: ipaddress.IPv4Address | ipaddress.IPv6Address diff --git a/docs/source/pcapkit/foundation/reassembly/reassembly.rst b/docs/source/pcapkit/foundation/reassembly/reassembly.rst index aabb7e59a..7867ad4af 100644 --- a/docs/source/pcapkit/foundation/reassembly/reassembly.rst +++ b/docs/source/pcapkit/foundation/reassembly/reassembly.rst @@ -102,22 +102,22 @@ Internal Definitions Type Variables -------------- -.. data:: pcapkit.foundation.reassembly.reassembly._PT +.. data:: _PT :type: pcapkit.corekit.infoclass.Info Packet data structure. -.. data:: pcapkit.foundation.reassembly.reassembly._DT +.. data:: _DT :type: pcapkit.corekit.infoclass.Info Datagram data structure. -.. data:: pcapkit.foundation.reassembly.reassembly._IT +.. data:: _IT :type: tuple Buffer ID data structure. -.. data:: pcapkit.foundation.reassembly.reassembly._BT +.. data:: _BT :type: pcapkit.corekit.infoclass.Info Buffer data structure. diff --git a/docs/source/pcapkit/foundation/reassembly/tcp.rst b/docs/source/pcapkit/foundation/reassembly/tcp.rst index caae6339e..5fadfe420 100644 --- a/docs/source/pcapkit/foundation/reassembly/tcp.rst +++ b/docs/source/pcapkit/foundation/reassembly/tcp.rst @@ -371,11 +371,13 @@ Data Models Type Variables ============== -.. data:: pcapkit.foundation.reassembly.data.tcp._AT +.. data:: _AT + :module: pcapkit.foundation.reassembly.data.tcp :type: ipaddress.IPv4Address | ipaddress.IPv6Address -.. data:: pcapkit.foundation.reassembly.data.tcp.BufferID - :type: typing.Tuple[_AT, int, _AT, int] +.. data:: BufferID + :module: pcapkit.foundation.reassembly.data.tcp + :type: typing.Tuple[~pcapkit.foundation.reassembly.data.tcp._AT, int, ~pcapkit.foundation.reassembly.data.tcp._AT, int] :no-index: Buffer ID data structure. diff --git a/docs/source/pcapkit/foundation/traceflow/tcp.rst b/docs/source/pcapkit/foundation/traceflow/tcp.rst index e62ff397a..3a4eae034 100644 --- a/docs/source/pcapkit/foundation/traceflow/tcp.rst +++ b/docs/source/pcapkit/foundation/traceflow/tcp.rst @@ -170,5 +170,6 @@ Data Structures Type Variables -------------- -.. data:: pcapkit.foundation.traceflow.data.tcp._AT +.. data:: _AT + :module: pcapkit.foundation.traceflow.data.tcp :type: ipaddress.IPv4Address | ipaddress.IPv6Address diff --git a/docs/source/pcapkit/protocols/application/index.rst b/docs/source/pcapkit/protocols/application/index.rst index 9208500f5..991e9cd54 100644 --- a/docs/source/pcapkit/protocols/application/index.rst +++ b/docs/source/pcapkit/protocols/application/index.rst @@ -30,6 +30,7 @@ function rather than encapsulation, see Protocol Registry ----------------- -.. data:: pcapkit.protocols.application.APPTYPE +.. data:: APPTYPE + :module: pcapkit.protocols.application alias of :class:`pcapkit.const.reg.apptype.apptype.AppType` diff --git a/docs/source/pcapkit/protocols/index.rst b/docs/source/pcapkit/protocols/index.rst index e5550e756..858ea8197 100644 --- a/docs/source/pcapkit/protocols/index.rst +++ b/docs/source/pcapkit/protocols/index.rst @@ -234,13 +234,13 @@ Internal Definitions Type Variables ~~~~~~~~~~~~~~ -.. data:: pcapkit.protocols.schema.schema._VT +.. data:: _VT :type: typing.Any -.. data:: pcapkit.protocols.schema.schema._ET +.. data:: _ET :type: enum.Enum -.. data:: pcapkit.protocols.schema.schema._ST +.. data:: _ST :type: typing.Type[pcapkit.protocols.schema.schema.Schema] Data Model diff --git a/docs/source/pcapkit/protocols/internet/index.rst b/docs/source/pcapkit/protocols/internet/index.rst index 1467239a8..09bf93617 100644 --- a/docs/source/pcapkit/protocols/internet/index.rst +++ b/docs/source/pcapkit/protocols/internet/index.rst @@ -35,6 +35,7 @@ internet layer, with detailed implementation and methods. Protocol Registry ----------------- -.. data:: pcapkit.protocols.internet.ETHERTYPE +.. data:: ETHERTYPE + :module: pcapkit.protocols.internet alias of :class:`pcapkit.const.reg.ethertype.EtherType` diff --git a/docs/source/pcapkit/protocols/link/index.rst b/docs/source/pcapkit/protocols/link/index.rst index d7862d6d5..57cb1d7df 100644 --- a/docs/source/pcapkit/protocols/link/index.rst +++ b/docs/source/pcapkit/protocols/link/index.rst @@ -28,6 +28,7 @@ link layer, with detailed implementation and methods. Protocol Registry ----------------- -.. data:: pcapkit.protocols.link.LINKTYPE +.. data:: LINKTYPE + :module: pcapkit.protocols.link alias of :class:`pcapkit.const.reg.linktype.LinkType` diff --git a/docs/source/pcapkit/protocols/protocol.rst b/docs/source/pcapkit/protocols/protocol.rst index caba2553b..a796a215c 100644 --- a/docs/source/pcapkit/protocols/protocol.rst +++ b/docs/source/pcapkit/protocols/protocol.rst @@ -259,8 +259,8 @@ Internal Definitions Type Variables -------------- -.. data:: pcapkit.protocols.protocol._PT +.. data:: _PT :type: pcapkit.protocols.data.data.Data -.. data:: pcapkit.protocols.protocol._ST +.. data:: _ST :type: pcapkit.protocols.schema.schema.Schema diff --git a/docs/source/pcapkit/protocols/transport/index.rst b/docs/source/pcapkit/protocols/transport/index.rst index 928186c11..b453bfe11 100644 --- a/docs/source/pcapkit/protocols/transport/index.rst +++ b/docs/source/pcapkit/protocols/transport/index.rst @@ -23,6 +23,7 @@ transport layer, with detailed implementation and methods. Protocol Registry ----------------- -.. data:: pcapkit.protocols.transport.TRANSTYPE +.. data:: TRANSTYPE + :module: pcapkit.protocols.transport alias of :class:`pcapkit.const.reg.transtype.TransType` diff --git a/docs/source/pcapkit/vendor/default.rst b/docs/source/pcapkit/vendor/default.rst index c405fbe96..d5001577e 100644 --- a/docs/source/pcapkit/vendor/default.rst +++ b/docs/source/pcapkit/vendor/default.rst @@ -74,7 +74,7 @@ Internal Definitions :no-members: :show-inheritance: -.. function:: pcapkit.vendor.default.LINE(NAME, DOCS, FLAG, ENUM, MISS, MODL) +.. function:: LINE(NAME, DOCS, FLAG, ENUM, MISS, MODL) Default constant template of enumeration registry from IANA CSV. diff --git a/tests/_dependency_gates.py b/tests/_dependency_gates.py index 51af6cf40..128d09277 100644 --- a/tests/_dependency_gates.py +++ b/tests/_dependency_gates.py @@ -230,6 +230,11 @@ # ``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 + # ``HAS_SPHINX`` gate on its hook tests (#1775) asks for. Only the ``docs`` + # extra carries either. + 'sphinx': ('Sphinx',), + 'sphinx_autodoc_typehints': ('sphinx-autodoc-typehints',), } #: Flags whose condition is not a distribution, with what it is instead. A flag @@ -616,6 +621,22 @@ class Exclusion(NamedTuple): 'and let the gap close on its own rather than to widen it.' ), ), + 'HAS_SPHINX': Exclusion( + 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.' + ), + ), } diff --git a/tests/project/test_docs_ambiguous_xrefs_1775_unit.py b/tests/project/test_docs_ambiguous_xrefs_1775_unit.py new file mode 100644 index 000000000..9bef58679 --- /dev/null +++ b/tests/project/test_docs_ambiguous_xrefs_1775_unit.py @@ -0,0 +1,282 @@ +# -*- coding: utf-8 -*- +"""Pins the fixes for the docs build's ambiguous cross-references. + +GitHub issue #1775: the docs build gave 36 ``more than one target found for +cross-reference`` warnings, and every one of those links landed on the wrong +object. They had three causes, each fixed where it starts rather than at the +references: + +* A bare builtin name in a type -- ``dict`` 21 times, ``type`` 3, ``set`` 1 -- was + suffix-matched to registry members such as ``TCP.dict``. + ``resolve_builtins_exactly`` in :file:`docs/source/conf.py` stops it. +* Napoleon read a URL's colon in a registry member's docstring as Google's inline + ``type: description`` form, and the bogus type was then parsed as references -- + ``http`` 3, ``Echo`` 1. ``save_enum_member_docstring`` and + ``restore_enum_member_docstring`` undo it. +* 44 hand-written ``.. data::`` and ``.. function::`` directives spelled out their + full dotted name under a ``.. module::`` that already supplied it, so each was + registered as ``..``. The exact lookup for a TypeVar such + as ``_AT`` then failed, and the fuzzy one found every ``_AT`` in the package -- + the other 7 warnings. + +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. + +""" + +from __future__ import annotations + +import importlib.util +import inspect +import os +import pathlib +import re +import types +import unittest +from typing import TYPE_CHECKING +from unittest import mock + +if TYPE_CHECKING: + from typing import Any + +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')) + +#: A Python domain directive, or one that sets the current module. +DIRECTIVE = re.compile(r'^\s*\.\. (?:py:)?(?P[a-z]+)::\s*(?P\S+)') + +#: Directives that set the current module for those after them. +MODULE_KINDS = frozenset({'module', 'currentmodule', 'automodule'}) + +#: Directives that register an object under the current module. +OBJECT_KINDS = frozenset({ + 'data', 'function', 'class', 'exception', 'attribute', 'method', + 'classmethod', 'staticmethod', 'property', 'decorator', 'type', +}) + +#: The module and class every node below is built in, as autodoc would set them. +REF_CONTEXT = {'py:module': 'pcapkit.foundation.extraction', 'py:class': 'Extractor'} + +#: Two registry members named like the builtin, as ``TCP`` and ``UDP`` have. +REGISTRY_MEMBERS = ('pcapkit.const.reg.apptype.tcp.TCP.dict', + 'pcapkit.const.reg.apptype.udp.UDP.dict') + +#: A registry member's docstring whose first line napoleon used to split. +H_323 = 'pcapkit.const.sctp.payload_protocol_identifier.PayloadProtocolIdentifier.H_323' +H_323_DOC = ['H.323 [http://standard.pictel.com/ftp/avc-site/0206 Bru/AVD-2198.zip][H.323', + 'over SCTP October 2002.]'] + + +def doubled_names() -> 'list[str]': + """Every object directive whose name repeats the module it is declared in.""" + found = [] # type: list[str] + for path in sorted(DOCS.rglob('*.rst')): + module = None + for number, line in enumerate(path.read_text(encoding='utf-8').splitlines(), 1): + match = DIRECTIVE.match(line) + if match is None: + continue + kind, name = match.group('kind'), match.group('name') + if kind in MODULE_KINDS: + module = None if name == 'None' else name + elif kind in OBJECT_KINDS and module is not None and name.startswith('pcapkit.'): + found.append(f'{path.relative_to(ROOT)}:{number}: {name} (under {module})') + return found + + +def load_conf() -> 'types.ModuleType': + """Execute :file:`docs/source/conf.py`, without keeping its environment change.""" + spec = importlib.util.spec_from_file_location('_docs_conf_1775', CONF) + if spec is None or spec.loader is None: + raise RuntimeError(f'unable to load {CONF}') + module = importlib.util.module_from_spec(spec) + with mock.patch.dict(os.environ): + spec.loader.exec_module(module) + return module + + +def connections(conf: 'types.ModuleType') -> 'list[tuple[str, Any, int]]': + """What ``conf.setup`` connects, as ``(event, handler, priority)`` in call order. + + The application is an autospec of :class:`~sphinx.application.Sphinx`, so any + other call ``setup`` makes is accepted, and a priority left out is read as the + default :meth:`~sphinx.application.Sphinx.connect` would give it. + + """ + from sphinx.application import Sphinx # pylint: disable=import-outside-toplevel + app = mock.create_autospec(Sphinx, instance=True) + conf.setup(app) + signature = inspect.signature(Sphinx.connect) + found = [] # type: list[tuple[str, Any, int]] + for call in app.connect.call_args_list: + bound = signature.bind(app, *call.args, **call.kwargs) + bound.apply_defaults() + found.append((bound.arguments['event'], bound.arguments['callback'], + bound.arguments['priority'])) + return found + + +class DirectiveNameTests(unittest.TestCase): + """A hand-written directive takes the bare name under its ``.. module::``.""" + + def test_no_directive_repeats_its_module(self) -> 'None': + """``.. data:: pcapkit.x._T`` under ``.. module:: pcapkit.x`` registers ``pcapkit.x.pcapkit.x._T``. + + The fix is ``.. data:: _T``, with a ``:module:`` option when the object + lives in a module other than the current one. + + """ + self.assertEqual(doubled_names(), []) + + +@unittest.skipUnless(HAS_SPHINX, 'Sphinx and sphinx-autodoc-typehints are not installed') +class BuiltinReferenceTests(unittest.TestCase): + """``resolve_builtins_exactly`` keeps builtin names off registry members.""" + + conf = None # type: Any + + @classmethod + def setUpClass(cls) -> 'None': + cls.conf = load_conf() + + def setUp(self) -> 'None': + # pylint: disable=import-outside-toplevel + from sphinx.domains.python import ObjectEntry, PythonDomain + from sphinx.util.docutils import new_document + + self.env = types.SimpleNamespace( + ref_context=dict(REF_CONTEXT), domaindata={}, + config=types.SimpleNamespace(python_use_unqualified_type_names=False), + current_document=types.SimpleNamespace(docname='index', default_role=None), + ) + self.domain = PythonDomain(self.env) + self.env.get_domain = lambda name: self.domain + for name in REGISTRY_MEMBERS: + self.domain.objects[name] = ObjectEntry('pcapkit/const/reg', name, 'attribute', False) + self.document = new_document('') + self.document.settings.env = self.env + + def annotation(self, target: 'str') -> 'Any': + """The node a signature annotation becomes.""" + from sphinx.domains.python._annotations import type_to_xref # pylint: disable=import-outside-toplevel + return type_to_xref(target, self.env) + + def field_type(self, target: 'str') -> 'Any': + """The node a ``:rtype:`` field becomes.""" + from sphinx.domains.python._object import PyField # pylint: disable=import-outside-toplevel + field = PyField('returntype', label='Return type', has_arg=False, + names=('rtype',), bodyrolename='class') + return field.make_xref('class', 'py', target, env=self.env) + + def role(self, text: 'str') -> 'Any': + """The node an explicit ``:py:class:`` role becomes.""" + from sphinx.domains.python import PyXRefRole # pylint: disable=import-outside-toplevel + inliner = types.SimpleNamespace(document=self.document, reporter=types.SimpleNamespace( + get_source_and_line=lambda lineno=None: ('', lineno))) + result, messages = PyXRefRole()('py:class', f':py:class:`{text}`', text, 1, inliner) + self.assertEqual(messages, []) + return result[0] + + def resolve(self, node: 'Any') -> 'Any': + """What the Python domain links ``node`` to, after the hook has seen it.""" + from docutils import nodes # pylint: disable=import-outside-toplevel + self.document += nodes.paragraph('', '', node) + self.conf.resolve_builtins_exactly(None, self.document) + builder = types.SimpleNamespace(name='html', get_relative_uri=lambda source, target: '') + with mock.patch('sphinx.domains.python.logger'): + return self.domain.resolve_xref(self.env, 'index', builder, node['reftype'], + node['reftarget'], node, nodes.literal('', 'x')) + + def test_annotation_builtin_is_left_to_intersphinx(self) -> 'None': + """``dict`` in an annotation used to link to ``TCP.dict``.""" + self.assertIsNone(self.resolve(self.annotation('dict'))) + + def test_field_type_builtin_is_left_to_intersphinx(self) -> 'None': + """``dict`` in a ``:rtype:`` field used to link to ``TCP.dict`` too.""" + self.assertIsNone(self.resolve(self.field_type('dict'))) + + def test_dotted_role_keeps_its_fuzzy_search(self) -> 'None': + """``:class:`.dict``` asks for the member by its leading dot, and still finds it.""" + reference = self.resolve(self.role('.dict')) + self.assertIsNotNone(reference) + self.assertEqual(reference['reftitle'], REGISTRY_MEMBERS[0]) + + def test_other_names_keep_their_fuzzy_search(self) -> 'None': + """A relative name such as ``_pcap_backend.Probe`` resolves only by suffix.""" + from sphinx.domains.python import ObjectEntry # pylint: disable=import-outside-toplevel + probe = 'pcapkit.foundation.engines._pcap_backend.Probe' + self.domain.objects[probe] = ObjectEntry('engines', probe, 'class', False) + reference = self.resolve(self.annotation('_pcap_backend.Probe')) + self.assertIsNotNone(reference) + self.assertEqual(reference['reftitle'], probe) + + def test_setup_connects_it_to_doctree_read(self) -> 'None': + """Unconnected, the hook never runs and the 25 builtin warnings come back.""" + wired = [(event, priority) for event, handler, priority in connections(self.conf) + if handler is self.conf.resolve_builtins_exactly] + self.assertEqual(wired, [('doctree-read', 500)]) + + +@unittest.skipUnless(HAS_SPHINX, 'Sphinx and sphinx-autodoc-typehints are not installed') +class EnumMemberDocstringTests(unittest.TestCase): + """Napoleon's inline type is undone on enum members, and only there.""" + + conf = None # type: Any + + @classmethod + def setUpClass(cls) -> 'None': + cls.conf = load_conf() + + def process(self, name: 'str', obj: 'object', lines: 'list[str]') -> 'list[str]': + """Run ``lines`` through the hooks and napoleon, in the build's order.""" + from sphinx.ext import napoleon # pylint: disable=import-outside-toplevel + settings = {key: getattr(self.conf, key) for key in dir(self.conf) if key.startswith('napoleon_')} + app = types.SimpleNamespace(config=napoleon.Config(**settings)) + lines = list(lines) + self.conf.save_enum_member_docstring(app, 'attribute', name, obj, None, lines) + napoleon._process_docstring(app, 'attribute', name, obj, None, lines) # pylint: disable=protected-access + self.conf.restore_enum_member_docstring(app, 'attribute', name, obj, None, lines) + return lines + + def test_registry_member_keeps_its_docstring(self) -> 'None': + """The URL's colon used to turn ``H.323 [http`` into the member's type. + + Autodoc hands over the plain value -- ``13``, not the member -- so that is + what is passed here. + + """ + self.assertEqual(self.process(H_323, 13, H_323_DOC), H_323_DOC) + + def test_attribute_keeps_napoleons_inline_type(self) -> 'None': + """An inline type on anything but an enum member is still napoleon's to read.""" + name = 'pcapkit.foundation.extraction.Extractor._flag_a' + self.assertEqual(self.process(name, 1, ['int: Number of frames.']), + ['Number of frames.', '', ':type: int']) + + def test_setup_runs_the_pair_either_side_of_napoleon(self) -> 'None': + """Napoleon connects at the default 500: the save runs before it, the restore after. + + :meth:`process` calls the three in that order by hand; this is what makes + the build call them in it too. + + """ + pair = (self.conf.save_enum_member_docstring, self.conf.restore_enum_member_docstring) + wired = sorted((priority, event, handler.__name__) + for event, handler, priority in connections(self.conf) if handler in pair) + self.assertEqual(wired, [ + (400, 'autodoc-process-docstring', 'save_enum_member_docstring'), + (600, 'autodoc-process-docstring', 'restore_enum_member_docstring'), + ]) + + +if __name__ == '__main__': + unittest.main()