diff --git a/docs/source/conf.py b/docs/source/conf.py index 37d95451ad..8168d573e8 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -19,8 +19,10 @@ 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 from sphinx.util.inspect import safe_getattr, stringify_signature # NB: private modules of ``sphinx-autodoc-typehints``, used deliberately -- see @@ -178,6 +180,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 +196,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 +218,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 +1073,54 @@ 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. + + 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``. + 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 + 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'] = f'{page}#{labelid}' + 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 +1221,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..bb07ca5a23 --- /dev/null +++ b/tests/project/test_docs_pcap_module_link_1948_unit.py @@ -0,0 +1,315 @@ +# -*- 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. +* :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 +: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 io +import pathlib +import re +import tempfile +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 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: ( + 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) + + +#: 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, 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) + + +if __name__ == '__main__': + unittest.main()