From 998088a721650bd5c1ae887e989ac43f86d4b8dd Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Mon, 19 Oct 2026 13:56:44 -0400 Subject: [PATCH 1/3] docs: link bare pcap module mentions to the PyPCAP/pcap-ct note (#1948) - Label the note in engines/3rdparty.rst that explains both distributions install the same top-level pcap module, as pcap-module. - Define |pcap|_ in rst_epilog with that label's anchor as its target, and add a doctree-resolved hook, link_pcap_module, that points it at the note's page from every other page, so it renders as a linked literal like |pkg|_. - Write the rendered module mentions in docs pages and engine docstrings as |pcap|_; keep distribution names, code, engine and adapter names, the file format, and the note itself as literals. - Make index.rst's PCAP file-format link anonymous, since target names fold case and its named target clashed with the epilog's pcap target. - Pin the label, the epilog entry, the hook and the bare-literal scan in test_docs_pcap_module_link_1948_unit.py, and let the #1936 guard accept the internal target. Closes #1948 --- docs/source/conf.py | 53 +++- .../contributing/conventions/process.rst | 2 +- docs/source/contributing/pep.rst | 2 +- docs/source/ext.rst | 2 +- docs/source/index.rst | 12 +- .../pcapkit/foundation/engines/3rdparty.rst | 4 +- .../pcapkit/foundation/engines/index.rst | 6 +- pcapkit/foundation/engines/_pcap_backend.py | 26 +- pcapkit/foundation/engines/pcap_ct.py | 26 +- pcapkit/foundation/engines/pypcap.py | 20 +- pcapkit/toolkit/pcap_ct.py | 2 +- .../test_docs_epilog_links_1936_unit.py | 18 +- .../test_docs_pcap_module_link_1948_unit.py | 250 ++++++++++++++++++ 13 files changed, 364 insertions(+), 59 deletions(-) create mode 100644 tests/project/test_docs_pcap_module_link_1948_unit.py diff --git a/docs/source/conf.py b/docs/source/conf.py index 37d95451ad..5f0536eff6 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -21,6 +21,7 @@ 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 import logging as sphinx_logging from sphinx.util.inspect import safe_getattr, stringify_signature # NB: private modules of ``sphinx-autodoc-typehints``, used deliberately -- see @@ -178,6 +179,11 @@ # Those packages are therefore linked to their project pages instead, through # :data:`rst_epilog` below (:issue:`1936`). +#: The label on the note in :file:`pcapkit/foundation/engines/3rdparty.rst` that +#: says which distribution installs the ``pcap`` module, which ``|pcap|_`` links +#: to (:issue:`1948`). +PCAP_MODULE_LABEL = 'pcap-module' + #: Every third-party project page the docs link to, defined once for all pages. #: Sphinx appends this to every source file, and so to every page autodoc #: assembles from docstrings, so a page or a docstring must not define these @@ -189,9 +195,14 @@ #: and `Name`_ links the project by its own name. Target names are #: case-insensitive, so `PyShark`_ and ``|pyshark|_`` share one target. A name #: below a package, such as ``dpkt.dpkt.Packet.data``, stays a plain literal, -#: since it would link to the same project page. ``pcap`` has no substitution: -#: `PyPCAP`_ and `pcap-ct`_ both install that module, so no one project page is -#: the right target for it. +#: since it would link to the same project page. +#: +#: ``|pcap|_`` links inside these docs instead. `PyPCAP`_ and `pcap-ct`_ both +#: install that module, so no one project page is the right target for it, and +#: it links to the note labelled :data:`PCAP_MODULE_LABEL`, which says which +#: distribution is which. Its target is that label's anchor, right on the note's +#: own page; :func:`link_pcap_module` points it at the note's page from every +#: other one. rst_epilog = """ .. |aenum| replace:: ``aenum`` .. _aenum: https://github.com/ethanfurman/aenum @@ -206,6 +217,8 @@ .. _emoji: https://carpedm20.github.io/emoji/docs/ .. |jspcapy| replace:: ``jspcapy`` .. _jspcapy: https://github.com/JarryShaw/jspcapy +.. |pcap| replace:: ``pcap`` +.. _pcap: #pcap-module .. |pcap-ct| replace:: ``pcap-ct`` .. _pcap-ct: https://pypi.org/project/pcap-ct/ .. |pcapfile| replace:: ``pcapfile`` @@ -1059,6 +1072,39 @@ def run(self, **kwargs: 'Any') -> None: container['ids'] = [id_ for id_ in container['ids'] if id_ not in inner] +def link_pcap_module(app: 'Sphinx', doctree: 'document', docname: str) -> None: + """Point each ``|pcap|_`` at the note labelled :data:`PCAP_MODULE_LABEL`. + + The target :data:`rst_epilog` gives ``|pcap|_`` is the label's bare anchor, + since a hyperlink target can only name a URI or a target on the same page, + and the note is on one page while the epilog is on all of them. The anchor + is right on the note's own page; on every other page this prefixes it with + the URI of the note's page, as the builder spells it from ``docname`` + (:issue:`1948`). A :rst:role:`ref` would resolve across pages without this, + but renders its text as plain text rather than as the literal the other + ``|pkg|_`` links are. + + Args: + app: Sphinx application. + doctree: The resolved doctree of ``docname``. + docname: The document being written. + + """ + anchor = f'#{PCAP_MODULE_LABEL}' + refs = [ref for ref in doctree.findall(nodes.reference) if ref.get('refuri') == anchor] + if not refs: + return + target, labelid = app.env.domains.standard_domain.anonlabels.get(PCAP_MODULE_LABEL, ('', '')) + if not target: + sphinx_logging.getLogger(__name__).warning( + 'undefined label: %r, which |pcap|_ links to', PCAP_MODULE_LABEL, location=refs[0]) + return + uri = f'{app.builder.get_relative_uri(docname, target)}#{labelid}' + for ref in refs: + ref['refuri'] = uri + ref['internal'] = True + + 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`. @@ -1159,6 +1205,7 @@ def setup(app: 'Sphinx') -> None: app.connect('autodoc-before-process-signature', sign_wrapped_function, priority=700) app.connect('viewcode-follow-imported', follow_builtins_alias) app.add_post_transform(DropTableWrapperIds) + app.connect('doctree-resolved', link_pcap_module) 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/docs/source/contributing/conventions/process.rst b/docs/source/contributing/conventions/process.rst index d25c64cc81..dbdb748b93 100644 --- a/docs/source/contributing/conventions/process.rst +++ b/docs/source/contributing/conventions/process.rst @@ -67,7 +67,7 @@ back to the default engine rather than raising when a requested engine is absent .. note:: mypy types the engine packages that ship no types -- |dpkt|_, |pyshark|_, - ``pcap`` and |pcapfile|_ -- from local stubs under :file:`typings/`, which + |pcap|_ and |pcapfile|_ -- from local stubs under :file:`typings/`, which :file:`mypy.ini` registers through ``mypy_path`` and which ship in neither the wheel nor the sdist (:issue:`1878`). ``scapy`` ships its own. A stub declares only what :mod:`pcapkit` uses and cites the version it was written against, so a name diff --git a/docs/source/contributing/pep.rst b/docs/source/contributing/pep.rst index 519ae33985..73825e33d6 100644 --- a/docs/source/contributing/pep.rst +++ b/docs/source/contributing/pep.rst @@ -549,7 +549,7 @@ both of the following are worth knowing before reaching for them: works on every supported interpreter, verified on 3.10 and 3.14. A separate engine rather than a second backend because the two are **mutually - exclusive**: both distributions own the import name ``pcap``, and with both + exclusive**: both distributions own the import name |pcap|_, and with both installed ``pcap-ct`` wins the import while upstream's extension module is shadowed and unreachable. Each engine detects which backend it actually got and says so, rather than one ``engine=`` string silently meaning two different diff --git a/docs/source/ext.rst b/docs/source/ext.rst index 305a6a71e7..b1709348ab 100644 --- a/docs/source/ext.rst +++ b/docs/source/ext.rst @@ -438,7 +438,7 @@ The available engines and the file formats they read: .. note:: `PyPCAP`_ and `pcap-ct`_ are two distributions of one :manpage:`libpcap(3)` - interface, and both install a top-level ``pcap`` module, so they are two + interface, and both install a top-level |pcap|_ module, so they are two engines rather than one: ``engine='pypcap'`` and ``engine='pcap_ct'``. Upstream `PyPCAP`_ cannot be installed on Python 3.12 or newer, and `pcap-ct`_ can -- which is why both exist. See diff --git a/docs/source/index.rst b/docs/source/index.rst index b26218e45b..1e62cc1c06 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -5,8 +5,9 @@ PyPCAPKit - Comprehensive Network Packet Analysis Library ========================================================= -PyPCAPKit is an open source Python library for `PCAP`_ file extraction, -construction and analysis. +PyPCAPKit is an open source Python library for +`PCAP `__ file extraction, construction and +analysis. .. important:: @@ -132,7 +133,7 @@ spelled ``'pcapkit'``) is :mod:`pcapkit`'s own parser and the only one with no third-party requirement. `PyPCAP`_ and `pcap-ct`_ are two independent distributions of the same -:manpage:`libpcap(3)` interface and both install a top-level ``pcap`` module, +:manpage:`libpcap(3)` interface and both install a top-level |pcap|_ module, so they are two engines rather than one: upstream `PyPCAP`_ stops at Python 3.11, `pcap-ct`_ covers 3.10 and newer. **Install exactly one of them** -- with both present, ``pcap-ct`` wins the import and the other becomes unselectable. @@ -164,7 +165,7 @@ Every engine also answers a preflight check -- :meth:`Extractor.run ` consults before anything is imported -- so asking for one that cannot run in the current environment gives a single warning naming the actual cause (a Python version, a -missing :program:`tshark`, a missing :manpage:`libpcap(3)`, the wrong ``pcap`` +missing :program:`tshark`, a missing :manpage:`libpcap(3)`, the wrong |pcap|_ distribution) and a clean fall back, rather than an error from inside the third-party package. @@ -358,7 +359,7 @@ plug-in functions, you may want to install the optional ones: .. warning:: **Install only one of** ``pypcap`` **and** ``pcap-ct``. Both own the top-level - ``pcap`` module, and ``pip`` will install both without complaint. With both + |pcap|_ module, and ``pip`` will install both without complaint. With both present the ``pcap-ct`` package wins the import and ``pypcap``'s extension module is shadowed and unreachable, so ``engine='pypcap'`` stops working -- measured on Python 3.10. :mod:`pcapkit` detects that state and warns with an @@ -476,5 +477,4 @@ Indices and Tables * :ref:`modindex` * :ref:`search` -.. _PCAP: https://en.wikipedia.org/wiki/Pcap .. _libpcap: https://www.tcpdump.org diff --git a/docs/source/pcapkit/foundation/engines/3rdparty.rst b/docs/source/pcapkit/foundation/engines/3rdparty.rst index 2c6d9fabce..ed81edd626 100644 --- a/docs/source/pcapkit/foundation/engines/3rdparty.rst +++ b/docs/source/pcapkit/foundation/engines/3rdparty.rst @@ -183,6 +183,8 @@ support, as is used by :class:`pcapkit.foundation.extraction.Extractor`. expected to lift. Use :class:`~pcapkit.foundation.engines.pcap_ct.PCAP_CT` on 3.12 and newer. +.. _pcap-module: + .. important:: `pcap-ct`_ installs the same top-level ``pcap`` module as `PyPCAP`_. This @@ -435,7 +437,7 @@ Backend Detection .. module:: pcapkit.foundation.engines._pcap_backend -Unrelated PyPI distributions install a top-level module named ``pcap`` -- +Unrelated PyPI distributions install a top-level module named |pcap|_ -- `PyPCAP`_, a Cython binding shipped as a single extension module, and `pcap-ct`_, a :mod:`ctypes` reimplementation shipped as a package. They therefore collide, and ``import pcap`` resolves to whichever the import system finds first. diff --git a/docs/source/pcapkit/foundation/engines/index.rst b/docs/source/pcapkit/foundation/engines/index.rst index d05513ee31..a07641ee44 100644 --- a/docs/source/pcapkit/foundation/engines/index.rst +++ b/docs/source/pcapkit/foundation/engines/index.rst @@ -173,7 +173,7 @@ one warning naming the actual cause and a fall back to the built-in parser. ``tshark``, :class:`~pcapkit.foundation.engines.pypcapfile.PyPCAPFile` its Python ceiling, :class:`~pcapkit.foundation.engines.pcap_ct.PCAP_CT` for a system ``libpcap``, and :class:`~pcapkit.foundation.engines.pypcap.PyPCAP` and -:class:`~pcapkit.foundation.engines.pcap_ct.PCAP_CT` which distribution owns ``pcap``. +:class:`~pcapkit.foundation.engines.pcap_ct.PCAP_CT` which distribution owns |pcap|_. :class:`~pcapkit.foundation.engines.pypcap.PyPCAP` deliberately has no Python ceiling check, even though upstream cannot be installed on 3.12 or newer: what matters is which distribution is present, and a version check would refuse a working build. @@ -190,7 +190,7 @@ Choosing Between PyPCAP and PCAP_CT :class:`~pcapkit.foundation.engines.pypcap.PyPCAP` and :class:`~pcapkit.foundation.engines.pcap_ct.PCAP_CT` read the same :manpage:`libpcap(3)` interface from two independent distributions, both of which -install a top-level ``pcap`` module. They are separate engines because they +install a top-level |pcap|_ module. They are separate engines because they are separate projects, with different authors, different install requirements and different interpreter coverage: @@ -223,7 +223,7 @@ installed.** Pick on availability, not on features. otherwise be. **The two distributions collide, so install exactly one.** Both own the top-level -``pcap`` module, and pip will happily install both -- on Python 3.10, +|pcap|_ module, and pip will happily install both -- on Python 3.10, the ``pcap-ct`` package then wins the import and upstream's extension module is shadowed and unreachable. Each engine therefore detects which distribution it actually got, via diff --git a/pcapkit/foundation/engines/_pcap_backend.py b/pcapkit/foundation/engines/_pcap_backend.py index a4fa447b23..0278825545 100644 --- a/pcapkit/foundation/engines/_pcap_backend.py +++ b/pcapkit/foundation/engines/_pcap_backend.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- -"""Which distribution owns the ``pcap`` module? +"""Which distribution owns the |pcap|_ module? ===================================================== .. module:: pcapkit.foundation.engines._pcap_backend -Unrelated PyPI distributions install a top-level module named ``pcap``: +Unrelated PyPI distributions install a top-level module named |pcap|_: * `PyPCAP`_ -- a Cython binding, shipped as a single extension module (:file:`pcap.cpython-*.so`), installable only up to Python 3.11. @@ -48,7 +48,7 @@ PYPCAP = 'pypcap' #: Distribution name of `pcap-ct`_. PCAP_CT = 'pcap-ct' -#: Every distribution known to provide ``pcap``, in a fixed order so that +#: Every distribution known to provide |pcap|_, in a fixed order so that #: messages naming several of them read the same way every time. DISTRIBUTIONS = (PYPCAP, PCAP_CT) #: The ``engine=`` string that drives each distribution. Kept here rather than in @@ -62,7 +62,7 @@ @info_final class Probe(Info): - """What one attempt to import ``pcap`` found.""" + """What one attempt to import |pcap|_ found.""" #: Distribution that provided the imported module -- :data:`PYPCAP`, #: :data:`PCAP_CT`, or :data:`None` when the import did not succeed. @@ -101,9 +101,9 @@ def describe(self) -> 'str': def identify(module: 'ModuleType') -> 'str': - """Which distribution does an imported ``pcap`` module come from? + """Which distribution does an imported |pcap|_ module come from? - `pcap-ct`_ ships ``pcap`` as a package whose ``__init__`` does + `pcap-ct`_ ships |pcap|_ as a package whose ``__init__`` does ``from ._pcap import *``, which binds the submodule as an attribute; upstream `PyPCAP`_ ships a single extension module, which has no such attribute. That is a structural difference rather than a cosmetic one, which is why it is @@ -116,7 +116,7 @@ def identify(module: 'ModuleType') -> 'str': present on upstream ``pypcap`` 1.3.0 as well. Args: - module: An already-imported ``pcap`` module. + module: An already-imported |pcap|_ module. Returns: :data:`PCAP_CT` or :data:`PYPCAP`. @@ -128,7 +128,7 @@ def identify(module: 'ModuleType') -> 'str': def installed_distributions() -> 'tuple[str, ...]': """Which of :data:`DISTRIBUTIONS` are installed, per package metadata. - Asked of the metadata rather than of ``pcap`` itself, because that is the + Asked of the metadata rather than of |pcap|_ itself, because that is the only way to see the distribution the import did *not* resolve to -- which is exactly the collision worth reporting. @@ -152,7 +152,7 @@ def installed_distributions() -> 'tuple[str, ...]': def _purge() -> 'None': - """Drop the ``pcap`` and ``libpcap`` module trees from :data:`sys.modules`. + """Drop the |pcap|_ and ``libpcap`` module trees from :data:`sys.modules`. Called after a failed import so that the *next* probe reproduces the same failure. Without it a second attempt reports something else entirely, which @@ -170,8 +170,8 @@ def _purge() -> 'None': That message names neither the missing library nor the package it came from, and it is what the user would otherwise be shown. ``libpcap`` is purged as - well as ``pcap`` because the residue is in whichever of the two got part-way: - purging only ``pcap`` moved the ``NameError`` from one to the other rather + well as |pcap|_ because the residue is in whichever of the two got part-way: + purging only |pcap|_ moved the ``NameError`` from one to the other rather than removing it. """ @@ -184,7 +184,7 @@ def _purge() -> 'None': def probe() -> 'Probe': - """Import ``pcap`` and report what was found. + """Import |pcap|_ and report what was found. Not cached. The cost after a successful first call is a :data:`sys.modules` lookup, and a cache would make the answer depend on when it was first asked -- @@ -228,7 +228,7 @@ def probe() -> 'Probe': def wrong_backend_reason(wanted: 'str', found: 'Probe') -> 'Optional[str]': - """Why ``wanted`` cannot run, when some *other* distribution owns ``pcap``. + """Why ``wanted`` cannot run, when some *other* distribution owns |pcap|_. Args: wanted: The distribution the calling engine drives -- :data:`PYPCAP` or diff --git a/pcapkit/foundation/engines/pcap_ct.py b/pcapkit/foundation/engines/pcap_ct.py index af9b7cb757..164787c6ec 100644 --- a/pcapkit/foundation/engines/pcap_ct.py +++ b/pcapkit/foundation/engines/pcap_ct.py @@ -29,7 +29,7 @@ from pcapkit.foundation.extraction import Extractor class _PCAPCTPackage(Protocol): - """``pcap``, as :class:`PCAP_CT` reads it through :attr:`PCAP_CT._expkg` (#1878).""" + """|pcap|_, as :class:`PCAP_CT` reads it through :attr:`PCAP_CT._expkg` (#1878).""" @property def pcap(self) -> 'Type[Handle]': ... @@ -49,7 +49,7 @@ class PCAP_CT(EngineBase['RawFrame']): the `libpcap`_ package, which supplies the :manpage:`libpcap(3)` bindings (and ships a vendored copy of the library that it does not, by default, use -- see the warning below). Both distributions install a - top-level ``pcap`` module and expose the same interface, so this engine is + top-level |pcap|_ module and expose the same interface, so this engine is a sibling of :class:`~pcapkit.foundation.engines.pypcap.PyPCAP` rather than a replacement for it -- see :attr:`__engine_module__` for how the two are told apart, and :doc:`the engine documentation @@ -82,7 +82,7 @@ class PCAP_CT(EngineBase['RawFrame']): rely on it. Setting ``LIBPCAP = tcpdump`` in :file:`libpcap.cfg` selects the vendored copy instead -- verified loading, and reporting libpcap 1.10.6. - Being the same interface, it has the same limits. Offline, ``pcap`` is a + Being the same interface, it has the same limits. Offline, |pcap|_ is a savefile reader and nothing more: iteration yields the ``(timestamp, bytes)`` pair from :c:func:`pcap_next_ex` and no protocol dissection is performed. Consequently this engine @@ -113,7 +113,7 @@ class PCAP_CT(EngineBase['RawFrame']): """ if TYPE_CHECKING: - #: Engine extraction package, i.e. ``pcap``. + #: Engine extraction package, i.e. |pcap|_. _expkg: '_PCAPCTPackage' #: Capture handle, kept separately from the iterator it is read through #: so that :meth:`close` does not depend on ``iter(handle) is handle``. @@ -124,7 +124,7 @@ class PCAP_CT(EngineBase['RawFrame']): _dlink: 'Enum_LinkType' #: Closed flag, so that the handle is not closed twice. _closed: 'bool' - #: What the ``pcap`` import actually found, cf. :attr:`backend`. + #: What the |pcap|_ import actually found, cf. :attr:`backend`. _backend: '_pcap_backend.Probe' ########################################################################## @@ -134,13 +134,13 @@ class PCAP_CT(EngineBase['RawFrame']): #: Engine name. __engine_name__ = 'PCAP_CT' - #: Engine module name. Note that this is ``pcap._pcap``, not ``pcap``: - #: `pcap-ct`_ and upstream `PyPCAP`_ both install a top-level ``pcap``, + #: Engine module name. Note that this is ``pcap._pcap``, not |pcap|_: + #: `pcap-ct`_ and upstream `PyPCAP`_ both install a top-level |pcap|_, #: so importing that name cannot tell which of the two is present, and #: :meth:`Extractor.import_test #: ` decides engine #: availability by import alone. ``pcap._pcap`` is the `pcap-ct`_ - #: implementation module; upstream ships ``pcap`` as a single extension + #: implementation module; upstream ships |pcap|_ as a single extension #: module rather than a package, so the submodule import fails there and the #: two engines stay distinguishable. __engine_module__ = 'pcap._pcap' @@ -185,7 +185,7 @@ def unsupported_reason(cls) -> 'Optional[str]': Consulted by :meth:`pcapkit.foundation.extraction.Extractor.run` *before* the import test, and it answers questions the import test cannot. - **Which distribution owns** ``pcap``. Upstream `PyPCAP`_ owns that name + **Which distribution owns** |pcap|_. Upstream `PyPCAP`_ owns that name just as legitimately, and ``import pcap._pcap`` -- what :attr:`__engine_module__` names -- separates them only as long as upstream keeps shipping a single extension module. Asking @@ -214,7 +214,7 @@ def unsupported_reason(cls) -> 'Optional[str]': which the import test then reports correctly on its own. Returns: - A phrase naming the real cause, or :data:`None`. A ``pcap`` that is + A phrase naming the real cause, or :data:`None`. A |pcap|_ that is merely absent returns :data:`None`, since the import test reports that case in its own words. @@ -236,7 +236,7 @@ def dlink(self) -> 'Enum_LinkType': def backend(self) -> 'str': """The distribution, version and location this engine is driving. - More than one distribution provides ``pcap``, so "the pcap-ct engine failed" is + More than one distribution provides |pcap|_, so "the pcap-ct engine failed" is not a complete statement of what ran. This says which one it was, e.g. ``pcap-ct 1.3.0b3 (/.../pcap/__init__.py)``. @@ -251,7 +251,7 @@ def __init__(self, extractor: 'Extractor') -> 'None': """Initialise the engine. Warns: - EngineWarning: If both distributions that provide ``pcap`` are + EngineWarning: If both distributions that provide |pcap|_ are installed. Only one of them can win the import -- measured on Python 3.10 with both present, the ``pcap-ct`` package wins and upstream's extension module is shadowed -- so the other is @@ -307,7 +307,7 @@ def run(self) -> 'None': """Call ``pcap.pcap`` to extract PCAP files. This method assigns :attr:`self._expkg ` - as ``pcap``, :attr:`self._handle ` as the + as |pcap|_, :attr:`self._handle ` as the ``pcap.pcap`` capture handle and :attr:`self._extmp ` as an iterator over it. diff --git a/pcapkit/foundation/engines/pypcap.py b/pcapkit/foundation/engines/pypcap.py index e6ecbac77c..7a3c637893 100644 --- a/pcapkit/foundation/engines/pypcap.py +++ b/pcapkit/foundation/engines/pypcap.py @@ -35,7 +35,7 @@ from pcapkit.foundation.extraction import Extractor class _PyPCAPPackage(Protocol): - """``pcap``, as :class:`PyPCAP` reads it through :attr:`PyPCAP._expkg` (#1878).""" + """|pcap|_, as :class:`PyPCAP` reads it through :attr:`PyPCAP._expkg` (#1878).""" @property def pcap(self) -> 'Type[Handle]': ... @@ -73,7 +73,7 @@ class PyPCAP(EngineBase['RawFrame']): interface that does install there, and :class:`~pcapkit.foundation.engines.pcap_ct.PCAP_CT` is the engine for it. - Because `pcap-ct`_ installs the same top-level ``pcap`` module, this + Because `pcap-ct`_ installs the same top-level |pcap|_ module, this engine checks which distribution it got and refuses the other one rather than running under the wrong name -- see :meth:`__init__`. @@ -81,12 +81,12 @@ class PyPCAP(EngineBase['RawFrame']): extractor: :class:`~pcapkit.foundation.extraction.Extractor` instance. Raises: - UnsupportedCall: If the installed ``pcap`` is `pcap-ct`_ rather than + UnsupportedCall: If the installed |pcap|_ is `pcap-ct`_ rather than upstream `PyPCAP`_. """ if TYPE_CHECKING: - #: Engine extraction package, i.e. ``pcap``. + #: Engine extraction package, i.e. |pcap|_. _expkg: '_PyPCAPPackage' #: Engine extraction temporary storage. _extmp: 'Iterator[RawFrame]' @@ -94,7 +94,7 @@ class PyPCAP(EngineBase['RawFrame']): _dlink: 'Enum_LinkType' #: Closed flag, so that the handle is not closed twice. _closed: 'bool' - #: What the ``pcap`` import actually found, cf. :attr:`backend`. + #: What the |pcap|_ import actually found, cf. :attr:`backend`. _backend: '_pcap_backend.Probe' ########################################################################## @@ -151,7 +151,7 @@ def unsupported_reason(cls) -> 'Optional[str]': distributions install happily and either could be the one in place. Returns: - A phrase naming the real cause, or :data:`None`. An absent ``pcap`` + A phrase naming the real cause, or :data:`None`. An absent |pcap|_ returns :data:`None`, since the import test reports that case in its own words and duplicating it would produce two warnings for one problem. @@ -172,7 +172,7 @@ def dlink(self) -> 'Enum_LinkType': def backend(self) -> 'str': """The distribution, version and location this engine is driving. - More than one distribution provides ``pcap``, so "the PyPCAP engine failed" is + More than one distribution provides |pcap|_, so "the PyPCAP engine failed" is not a complete statement of what ran. This says which one it was, e.g. ``pypcap 1.3.0 (/.../pcap.cpython-310-x86_64-linux-gnu.so)``. @@ -187,14 +187,14 @@ def __init__(self, extractor: 'Extractor') -> 'None': """Initialise the engine. Warns: - EngineWarning: If both distributions that provide ``pcap`` are + EngineWarning: If both distributions that provide |pcap|_ are installed. Only one of them can win the import, so the other is shadowed and unreachable -- a state worth naming, because ``engine=`` then selects an engine that cannot run and nothing else would say why. Raises: - UnsupportedCall: If the installed ``pcap`` is `pcap-ct`_ rather + UnsupportedCall: If the installed |pcap|_ is `pcap-ct`_ rather than upstream `PyPCAP`_. This is the same condition :meth:`unsupported_reason` reports, kept here as well because that hook is only consulted by @@ -243,7 +243,7 @@ def run(self) -> 'None': """Call ``pcap.pcap`` to extract PCAP files. This method assigns :attr:`self._expkg ` - as ``pcap`` and :attr:`self._extmp ` + as |pcap|_ and :attr:`self._extmp ` as an iterator from ``pcap.pcap``. Warns: diff --git a/pcapkit/toolkit/pcap_ct.py b/pcapkit/toolkit/pcap_ct.py index 55c600ef4d..ac6e4276ae 100644 --- a/pcapkit/toolkit/pcap_ct.py +++ b/pcapkit/toolkit/pcap_ct.py @@ -25,7 +25,7 @@ :mod:`pcapkit.toolkit.pypcap` is the same adapter for upstream `PyPCAP`_, kept apart from this module because the engines are: the distributions are - independent projects that happen to share the ``pcap`` module name, and + independent projects that happen to share the |pcap|_ module name, and each engine names its own adapter so that a change made for one cannot quietly alter the other. diff --git a/tests/project/test_docs_epilog_links_1936_unit.py b/tests/project/test_docs_epilog_links_1936_unit.py index 331a1a1580..b6fde49b42 100644 --- a/tests/project/test_docs_epilog_links_1936_unit.py +++ b/tests/project/test_docs_epilog_links_1936_unit.py @@ -31,10 +31,14 @@ ROOT = pathlib.Path(__file__).resolve().parents[2] CONF = ROOT / 'docs' / 'source' / 'conf.py' -#: Packages linked as ``|pkg|_``. ``pcap`` is not one: two distributions install -#: it, so no one project page is its target, and it stays a literal. +#: Packages linked as ``|pkg|_``. ``pcap`` is linked too, but two distributions +#: install it, so no one project page is its target: it links to a note in these +#: docs instead, pinned by :file:`test_docs_pcap_module_link_1948_unit.py`. LINKED = ('aenum', 'cryptography', 'dpkt', 'emoji', 'pcapfile', 'pyshark') +#: Epilog targets that are not a project page, with the URI each one has. +INTERNAL = {'pcap': '#pcap-module'} + #: A list of literals that names engines, adapters or toolkits. ENGINE_LIST = re.compile(r'``\w+``(?:,\s+``\w+``)*(?:,?\s+(?:and|or)\s+``\w+``)?' r'\s+(?:engines?|toolkits?|adapters?)\b') @@ -129,14 +133,16 @@ def test_each_substitution_has_a_target(self) -> 'None': for name in SUBST.findall(epilog): self.assertIn(_norm(name), targets, name) for name, url in targets.items(): - self.assertTrue(url.startswith('https://'), f'{name}: {url}') + if name in INTERNAL: + self.assertEqual(url, INTERNAL[name], name) + else: + self.assertTrue(url.startswith('https://'), f'{name}: {url}') def test_each_linked_package_has_a_substitution(self) -> 'None': - """Every name in :data:`LINKED`, and not ``pcap``.""" + """Every name in :data:`LINKED` and :data:`INTERNAL`.""" names = set(SUBST.findall(_epilog())) - for name in LINKED: + for name in (*LINKED, *INTERNAL): self.assertIn(name, names) - self.assertNotIn('pcap', names) def test_no_target_is_defined_twice(self) -> 'None': """Docutils folds case, so ``_DPKT`` and ``_dpkt`` would be one name.""" diff --git a/tests/project/test_docs_pcap_module_link_1948_unit.py b/tests/project/test_docs_pcap_module_link_1948_unit.py new file mode 100644 index 0000000000..85f1380ec0 --- /dev/null +++ b/tests/project/test_docs_pcap_module_link_1948_unit.py @@ -0,0 +1,250 @@ +# -*- coding: utf-8 -*- +"""Pins the ``|pcap|_`` link to the note on which distribution owns ``pcap`` (#1948). + +PyPCAP and pcap-ct both install a top-level ``pcap`` module, so a bare +``pcap`` literal does not say which of them it means, and neither project page +is the right target for it. The docs explain the collision in a note in +:file:`docs/source/pcapkit/foundation/engines/3rdparty.rst`, labelled +``pcap-module``, and ``|pcap|_`` links the module to that note from every page. + +* :class:`LabelTests` -- the label exists once, on that note, and is the one + :file:`conf.py` names. +* :class:`EpilogTests` -- ``rst_epilog`` defines ``|pcap|`` and its target. +* :class:`BareLiteralTests` -- no rendered ``pcap`` literal that names the module + is left bare. +* :class:`LinkHookTests` -- ``link_pcap_module`` in :file:`conf.py` points the + target at the note's page from any other page. These need Sphinx and the + pinned sphinx-autodoc-typehints, and skip without them, but run no build. + +Only the text Sphinx renders is scanned, read as +:file:`test_docs_epilog_links_1936_unit.py` reads it: ``.rst`` pages under +:file:`docs/` apart from the changelog, and docstrings and ``#:`` comments under +:file:`pcapkit/`. A dotted name such as ``pcap.pcap`` or a code expression such as +``import pcap`` is a different literal, and is not matched. + +""" + +from __future__ import annotations + +import ast +import re +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) +from tests.project.test_docs_epilog_links_1936_unit import (CONF, ENGINE_LIST, ROOT, SUBST, TARGET, + _epilog, _line, _norm, _rel, _rendered, + _sources) + +if TYPE_CHECKING: + from typing import Any + +#: The label ``|pcap|_`` links to. +LABEL = 'pcap-module' + +#: The page that carries the note, relative to :data:`ROOT`. +NOTE_PAGE = 'docs/source/pcapkit/foundation/engines/3rdparty.rst' + +#: The page's docname, as Sphinx names it. +NOTE_DOC = 'pcapkit/foundation/engines/3rdparty' + +#: A bare ``pcap`` literal, and not ``pcap.pcap`` or ``import pcap``. +BARE = re.compile(r'``pcap``') + +#: Bare literals kept on purpose, by path. Each pattern must still match, so an +#: entry cannot outlive the text it was written for. +KEPT = { + # The note the label marks: a link there would point at itself. + NOTE_PAGE: ( + r'`pcap-ct`_ installs the same top-level ``pcap`` module as `PyPCAP`_', + ), + # The dump formats table: ``pcap`` is a format name there, not the module. + 'docs/source/ext.rst': ( + r'PCAP \(``cap``/``pcap``\)', + ), +} + +#: Pages and modules that name the module, each of which links it. +LINKING = ( + 'docs/source/contributing/conventions/process.rst', + 'docs/source/contributing/pep.rst', + 'docs/source/ext.rst', + 'docs/source/index.rst', + NOTE_PAGE, + 'docs/source/pcapkit/foundation/engines/index.rst', + 'pcapkit/foundation/engines/_pcap_backend.py', + 'pcapkit/foundation/engines/pcap_ct.py', + 'pcapkit/foundation/engines/pypcap.py', + 'pcapkit/toolkit/pcap_ct.py', +) + + +def _conf_label() -> 'str': + """``PCAP_MODULE_LABEL``, read from :file:`conf.py` without importing it.""" + for node in ast.parse(CONF.read_text(encoding='utf-8')).body: + if isinstance(node, ast.Assign) and any( + isinstance(target, ast.Name) and target.id == 'PCAP_MODULE_LABEL' for target in node.targets): + return ast.literal_eval(node.value) + raise AssertionError('conf.py assigns no PCAP_MODULE_LABEL') + + +def bare_literals() -> 'list[str]': + """Every rendered bare ``pcap`` literal outside the kept contexts.""" + offenders = [] + for path in _sources(): + rel = _rel(path) + text = _rendered(path) + # An engine or an adapter named ``pcap`` is pcapkit's own, not the module. + spans = [m.span() for m in ENGINE_LIST.finditer(text)] + for pattern in KEPT.get(rel, ()): + found = [m.span() for m in re.finditer(pattern, text)] + if not found: + offenders.append(f'{rel}: stale exemption {pattern!r}') + spans += found + for match in BARE.finditer(text): + if not any(start <= match.start() < end for start, end in spans): + offenders.append(f'{rel}:{_line(text, match.start())}: {match.group(0)}') + return offenders + + +class LabelTests(unittest.TestCase): + """The label is on the note, once.""" + + def test_conf_names_the_label(self) -> 'None': + """``PCAP_MODULE_LABEL`` is the label this module pins.""" + self.assertEqual(_conf_label(), LABEL) + + def test_label_marks_the_note(self) -> 'None': + """It sits directly above the note that says both distributions install ``pcap``.""" + text = (ROOT / NOTE_PAGE).read_text(encoding='utf-8') + match = re.search(r'(?m)^\.\. _%s:\n\n\.\. important::\n\n(.*)$' % re.escape(LABEL), text) + self.assertIsNotNone(match, f'{NOTE_PAGE} has no .. _{LABEL}: above an important note') + self.assertIn('installs the same top-level ``pcap`` module as', match.group(1)) + + def test_label_is_defined_once(self) -> 'None': + """A second label of one name is a Sphinx warning, and an ambiguous target.""" + label = re.compile(r'(?m)^[ \t]*\.\. _%s:[ \t]*$' % re.escape(LABEL)) + found = [_rel(path) for path in sorted((ROOT / 'docs').rglob('*.rst')) + for _ in label.finditer(path.read_text(encoding='utf-8'))] + self.assertEqual(found, [NOTE_PAGE]) + + +class EpilogTests(unittest.TestCase): + """``rst_epilog`` defines ``|pcap|_``.""" + + def test_substitution_is_the_literal(self) -> 'None': + """``|pcap|`` renders as ``pcap``, like the other ``|pkg|`` names.""" + self.assertIn('pcap', SUBST.findall(_epilog())) + self.assertIn('.. |pcap| replace:: ``pcap``\n', _epilog()) + + def test_target_is_the_label_anchor(self) -> 'None': + """The anchor, which ``link_pcap_module`` qualifies with the note's page.""" + targets = {_norm(name): url for name, url in TARGET.findall(_epilog())} + self.assertEqual(targets.get('pcap'), f'#{LABEL}') + + +class BareLiteralTests(unittest.TestCase): + """A rendered ``pcap`` that names the module is written ``|pcap|_``.""" + + def test_the_scan_reads_the_sources_it_claims_to(self) -> 'None': + """An empty or misrooted scan would pass the next test vacuously.""" + names = {_rel(path) for path in _sources()} + for expected in LINKING: + self.assertIn(expected, names) + self.assertFalse(any(name.startswith('docs/source/changelog/') for name in names)) + + def test_no_bare_module_literal(self) -> 'None': + """A ``pcap`` literal outside :data:`KEPT` and engine lists fails here.""" + self.assertEqual(bare_literals(), [], 'write the module as |pcap|_, defined in rst_epilog') + + def test_each_page_that_names_the_module_links_it(self) -> 'None': + """So a revert to the literal is not mistaken for a page that never named it.""" + missing = [rel for rel in LINKING if '|pcap|_' not in _rendered(ROOT / rel)] + self.assertEqual(missing, []) + + def test_kept_contexts_are_narrow(self) -> 'None': + """An engine list is kept, prose naming the module is not.""" + self.assertTrue(ENGINE_LIST.search('the ``default``, ``pcap`` and ``pcapng`` engines')) + self.assertIsNone(ENGINE_LIST.search('both install a top-level ``pcap`` module')) + self.assertIsNone(BARE.search('``pcap.pcap`` and ``import pcap``')) + + +@unittest.skipUnless(HAS_SPHINX, SPHINX_SKIP_REASON) +class LinkHookTests(unittest.TestCase): + """``link_pcap_module`` points ``|pcap|_`` at the note's page.""" + + conf = None # type: Any + + @classmethod + def setUpClass(cls) -> 'None': + cls.conf = load_conf() + + def app(self, *, labelled: 'bool' = True) -> 'Any': + """An application whose HTML builder spells URIs as Sphinx's does.""" + from sphinx.util.osutil import relative_uri # pylint: disable=import-outside-toplevel + app = mock.Mock() + app.env.domains.standard_domain.anonlabels = {LABEL: (NOTE_DOC, LABEL)} if labelled else {} + app.builder.get_relative_uri.side_effect = lambda src, dst: relative_uri(f'{src}.html', f'{dst}.html') + return app + + @staticmethod + def doctree(*uris: 'str') -> 'Any': + """A document holding a reference to each of ``uris``, as docutils resolves them.""" + from docutils import nodes # pylint: disable=import-outside-toplevel + from docutils.utils import new_document # pylint: disable=import-outside-toplevel + document = new_document('') + para = nodes.paragraph() + for uri in uris: + para += nodes.reference('', '', nodes.literal('pcap', 'pcap'), name='pcap', refuri=uri) + document += para + return document + + def refs(self, document: 'Any') -> 'list[Any]': + from docutils import nodes # pylint: disable=import-outside-toplevel + return list(document.findall(nodes.reference)) + + def test_another_page_links_the_note_page(self) -> 'None': + """From the page of :mod:`pcapkit.foundation.extraction`, a directory above the note's.""" + document = self.doctree(f'#{LABEL}') + self.conf.link_pcap_module(self.app(), document, 'pcapkit/foundation/extraction') + ref, = self.refs(document) + self.assertEqual(ref['refuri'], f'engines/3rdparty.html#{LABEL}') + self.assertTrue(ref['internal']) + + def test_the_note_page_keeps_its_anchor(self) -> 'None': + """On its own page the anchor is already right.""" + document = self.doctree(f'#{LABEL}') + self.conf.link_pcap_module(self.app(), document, NOTE_DOC) + ref, = self.refs(document) + self.assertEqual(ref['refuri'], f'#{LABEL}') + + def test_other_references_are_left_alone(self) -> 'None': + """Only the label's anchor is rewritten.""" + document = self.doctree('https://pypi.org/project/pcap-ct/', '#elsewhere') + self.conf.link_pcap_module(self.app(), document, 'index') + self.assertEqual([ref['refuri'] for ref in self.refs(document)], + ['https://pypi.org/project/pcap-ct/', '#elsewhere']) + + def test_a_missing_label_warns(self) -> 'None': + """Rather than leave each page linking an anchor it does not have, silently.""" + document = self.doctree(f'#{LABEL}') + with self.assertLogs('sphinx', level='WARNING') as logs: + self.conf.link_pcap_module(self.app(labelled=False), document, 'index') + self.assertIn(LABEL, logs.output[0]) + + def test_setup_connects_it_on_doctree_resolved(self) -> 'None': + """Each written page is resolved for its own docname, which the URI depends on.""" + wired = [event for event, handler, _ in connections(self.conf) + if handler is self.conf.link_pcap_module] + self.assertEqual(wired, ['doctree-resolved']) + + def test_epilog_target_is_the_hook_anchor(self) -> 'None': + """The two sides of the link agree on the label.""" + self.assertEqual(self.conf.PCAP_MODULE_LABEL, LABEL) + self.assertIn(f'.. _pcap: #{LABEL}\n', self.conf.rst_epilog) + + +if __name__ == '__main__': + unittest.main() From bc9c279d8cdf7749bbcf92d7b5741a4a48417f57 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Mon, 19 Oct 2026 14:12:04 -0400 Subject: [PATCH 2/3] docs: keep the pcap module link safe on non-HTML builders (#1948) - link_pcap_module leaves the bare anchor alone when the builder's URI for the note's page is itself a fragment, as singlehtml's is, instead of writing a second '#' into it. - It replaces each link with its literal when the builder raises NoUri for the note's page, as latex does when its document set leaves the note out, instead of failing the build. - Build a scratch project under singlehtml and latex in the #1948 test, so the hook is exercised by real builders rather than a mock. Closes #1948 --- docs/source/conf.py | 20 +++++- .../test_docs_pcap_module_link_1948_unit.py | 65 +++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 5f0536eff6..8168d573e8 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -19,6 +19,7 @@ from docutils import nodes from sphinx import addnodes +from sphinx.errors import NoUri from sphinx.ext.autodoc.preserve_defaults import DefaultValue, update_default_value from sphinx.transforms.post_transforms import SphinxPostTransform from sphinx.util import logging as sphinx_logging @@ -1084,6 +1085,14 @@ def link_pcap_module(app: 'Sphinx', doctree: 'document', docname: str) -> None: but renders its text as plain text rather than as the literal the other ``|pkg|_`` links are. + Two kinds of builder are left to themselves. One whose URI for the note's + page is itself a fragment, as singlehtml's ``#document-`` is, writes + every page into one output, where the bare anchor already resolves and a + second ``#`` would break it. One that raises :exc:`~sphinx.errors.NoUri` has + no page for the note, as latex has not when its document set leaves the note + out, so each link is replaced by its literal, as Sphinx replaces a + cross-reference with no URI. + Args: app: Sphinx application. doctree: The resolved doctree of ``docname``. @@ -1099,9 +1108,16 @@ def link_pcap_module(app: 'Sphinx', doctree: 'document', docname: str) -> None: sphinx_logging.getLogger(__name__).warning( 'undefined label: %r, which |pcap|_ links to', PCAP_MODULE_LABEL, location=refs[0]) return - uri = f'{app.builder.get_relative_uri(docname, target)}#{labelid}' + try: + page = app.builder.get_relative_uri(docname, target) + except NoUri: + for ref in refs: + ref.replace_self(ref.children) + return + if '#' in page: + return for ref in refs: - ref['refuri'] = uri + ref['refuri'] = f'{page}#{labelid}' ref['internal'] = True diff --git a/tests/project/test_docs_pcap_module_link_1948_unit.py b/tests/project/test_docs_pcap_module_link_1948_unit.py index 85f1380ec0..918d2ceb51 100644 --- a/tests/project/test_docs_pcap_module_link_1948_unit.py +++ b/tests/project/test_docs_pcap_module_link_1948_unit.py @@ -15,6 +15,9 @@ * :class:`LinkHookTests` -- ``link_pcap_module`` in :file:`conf.py` points the target at the note's page from any other page. These need Sphinx and the pinned sphinx-autodoc-typehints, and skip without them, but run no build. +* :class:`BuilderTests` -- the hook under real builders that spell a page's URI + differently: singlehtml as a fragment, latex not at all for a page its + document set leaves out. These build a scratch project, under the same gate. Only the text Sphinx renders is scanned, read as :file:`test_docs_epilog_links_1936_unit.py` reads it: ``.rst`` pages under @@ -27,7 +30,10 @@ from __future__ import annotations import ast +import io +import pathlib import re +import tempfile import unittest from typing import TYPE_CHECKING from unittest import mock @@ -246,5 +252,64 @@ def test_epilog_target_is_the_hook_anchor(self) -> 'None': self.assertIn(f'.. _pcap: #{LABEL}\n', self.conf.rst_epilog) +#: The scratch project :class:`BuilderTests` builds: a root page, a page that +#: links the module, and the note, which is in the root's toctree only when +#: asked for. +PAGES = { + 'index': 'Index\n=====\n\n.. toctree::\n\n%s\n', + 'other': 'Other\n=====\n\nThe |pcap|_ module.\n', + 'note': ':orphan:\n\nNote\n====\n\n.. _pcap-module:\n\n.. important::\n\n The note.\n', +} + + +@unittest.skipUnless(HAS_SPHINX, SPHINX_SKIP_REASON) +class BuilderTests(unittest.TestCase): + """``link_pcap_module`` under builders whose URIs are not one file per page.""" + + conf = None # type: Any + + @classmethod + def setUpClass(cls) -> 'None': + cls.conf = load_conf() + + def build(self, builder: 'str', *, with_note: 'bool') -> 'pathlib.Path': + """Build :data:`PAGES` with ``builder``, failing on any warning, and return the output.""" + # pylint: disable=import-outside-toplevel + from sphinx.application import Sphinx + from sphinx.util.docutils import docutils_namespace + tmp = tempfile.TemporaryDirectory() # pylint: disable=consider-using-with + self.addCleanup(tmp.cleanup) + root = pathlib.Path(tmp.name) + src = root / 'src' + src.mkdir() + toctree = ' other\n note' if with_note else ' other' + for name, text in PAGES.items(): + (src / f'{name}.rst').write_text(text % toctree if name == 'index' else text, encoding='utf-8') + warnings = io.StringIO() + # NB: as sphinx-build does, so a second application in this process does + # not warn that the first registered its nodes already. + with docutils_namespace(): + app = Sphinx(str(src), None, str(root / 'out'), str(root / 'doctrees'), builder, + confoverrides={'project': 'x', 'rst_epilog': self.conf.rst_epilog, + 'latex_documents': [('index', 'x.tex', 'X', 'x', 'manual')]}, + status=None, warning=warnings, freshenv=True) + app.connect('doctree-resolved', self.conf.link_pcap_module) + app.build() + self.assertEqual(warnings.getvalue(), '') + return root / 'out' + + def test_singlehtml_keeps_the_bare_anchor(self) -> 'None': + """Every page is in one file there, so the anchor resolves and a page URI would add a second ``#``.""" + html = (self.build('singlehtml', with_note=True) / 'index.html').read_text(encoding='utf-8') + self.assertIn(f'href="#{LABEL}"', html) + self.assertNotIn(f'#document-note#{LABEL}', html) + + def test_latex_without_the_note_writes_the_literal(self) -> 'None': + """latex has no URI for a page outside its document set, and the build used to die on it.""" + tex = (self.build('latex', with_note=False) / 'x.tex').read_text(encoding='utf-8') + self.assertIn(r'\sphinxcode{\sphinxupquote{pcap}}', tex) + self.assertNotIn(LABEL, tex) + + if __name__ == '__main__': unittest.main() From 559956780ac7f9f651c6801dc1b5d79c4cb0ce56 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Mon, 19 Oct 2026 14:19:24 -0400 Subject: [PATCH 3/3] test(docs): state the latex NoUri case as a fact (#1948) - Restate the latex builder test's docstring as what it pins: latex has no URI for a page outside its document set, so each link is written as its literal. - Word the KEPT allowlist comment without "still". Closes #1948 --- tests/project/test_docs_pcap_module_link_1948_unit.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/project/test_docs_pcap_module_link_1948_unit.py b/tests/project/test_docs_pcap_module_link_1948_unit.py index 918d2ceb51..bb07ca5a23 100644 --- a/tests/project/test_docs_pcap_module_link_1948_unit.py +++ b/tests/project/test_docs_pcap_module_link_1948_unit.py @@ -59,8 +59,8 @@ #: A bare ``pcap`` literal, and not ``pcap.pcap`` or ``import pcap``. BARE = re.compile(r'``pcap``') -#: Bare literals kept on purpose, by path. Each pattern must still match, so an -#: entry cannot outlive the text it was written for. +#: Bare literals kept on purpose, by path. Each pattern must match its file, so +#: an entry cannot outlive the text it names. KEPT = { # The note the label marks: a link there would point at itself. NOTE_PAGE: ( @@ -305,7 +305,7 @@ def test_singlehtml_keeps_the_bare_anchor(self) -> 'None': self.assertNotIn(f'#document-note#{LABEL}', html) def test_latex_without_the_note_writes_the_literal(self) -> 'None': - """latex has no URI for a page outside its document set, and the build used to die on it.""" + """latex has no URI for a page outside its document set, so each link is written as its literal.""" tex = (self.build('latex', with_note=False) / 'x.tex').read_text(encoding='utf-8') self.assertIn(r'\sphinxcode{\sphinxupquote{pcap}}', tex) self.assertNotIn(LABEL, tex)