From b6350a74df83bb724fa7df63c96782d8629a4a1d Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Mon, 19 Oct 2026 15:10:39 -0400 Subject: [PATCH 1/2] docs: add a glossary for terms with several meanings (#1949) - add docs/source/glossary.rst for registry, schema, data model, info and default engine; each entry names its senses and links to the page that explains them, and the page sits between demo and changelog - point the ambiguous mentions in the engine, extraction, constants, test-coverage and protocol pages and docstrings at it with :term: - say Protocol.info returns a data model rather than an "Info dict", and fix the matching info_name, schema and NoPayload wording - label the extension guide's New Protocol section so the glossary links it - add tests/project/test_docs_glossary_1949_unit.py, which pins the page, its entries and links, and that every :term: role names a defined term Closes #1949 --- .../conventions/test-coverage.rst | 7 +- docs/source/ext.rst | 2 + docs/source/glossary.rst | 59 +++++ docs/source/index.rst | 1 + docs/source/pcapkit/const/index.rst | 2 +- .../pcapkit/foundation/engines/3rdparty.rst | 2 +- .../pcapkit/foundation/engines/builtin.rst | 2 + .../pcapkit/foundation/engines/index.rst | 2 +- docs/source/pcapkit/foundation/extraction.rst | 4 +- docs/source/pcapkit/protocols/protocol.rst | 2 +- pcapkit/foundation/extraction.py | 4 +- pcapkit/foundation/reassembly/ip.py | 2 +- pcapkit/foundation/reassembly/reassembly.py | 2 +- pcapkit/protocols/misc/null.py | 2 +- pcapkit/protocols/protocol.py | 18 +- tests/project/test_docs_glossary_1949_unit.py | 230 ++++++++++++++++++ 16 files changed, 318 insertions(+), 23 deletions(-) create mode 100644 docs/source/glossary.rst create mode 100644 tests/project/test_docs_glossary_1949_unit.py diff --git a/docs/source/contributing/conventions/test-coverage.rst b/docs/source/contributing/conventions/test-coverage.rst index 405365c0d4..f4904c1e83 100644 --- a/docs/source/contributing/conventions/test-coverage.rst +++ b/docs/source/contributing/conventions/test-coverage.rst @@ -8,9 +8,10 @@ rules on what the test suite has to prove, and what a change has to bring to it. owner set the direction on :issue:`1203`, paraphrased here as on the other pages: every round trip the library offers must pass and match exactly, the tests must cover all of them, and the rules belong on a page rather than in review threads. :issue:`1202` -extended round-trip coverage past the registries, to the :mod:`pcapkit.corekit` fields, -schemas and ``Info``, the :mod:`pcapkit.foundation` reassembly, flow tracing, engines and -registries, and the output formats. +extended round-trip coverage past the :term:`registries `, to the +:mod:`pcapkit.corekit` fields, schemas and ``Info``, the :mod:`pcapkit.foundation` +reassembly, flow tracing, engines and :term:`registries `, and the output +formats. Round-Trip Invariants ~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/source/ext.rst b/docs/source/ext.rst index b1709348ab..f3430d8571 100644 --- a/docs/source/ext.rst +++ b/docs/source/ext.rst @@ -101,6 +101,8 @@ The protocol classes :mod:`pcapkit` ships: | | :class:`pcapkit.protocols.misc.null.NoPayload` | +------------------------------------------------------------------+----------------+-----------------------+-------------------------------------------------------------+ +.. _ext-new-protocol: + New Protocol ------------ diff --git a/docs/source/glossary.rst b/docs/source/glossary.rst new file mode 100644 index 0000000000..acfeda3403 --- /dev/null +++ b/docs/source/glossary.rst @@ -0,0 +1,59 @@ +Glossary +======== + +Some words in these docs carry more than one meaning. Each entry says which +sense applies where, and links to the page that explains it. + +.. glossary:: + + data model + A :class:`~pcapkit.protocols.data.data.Data` subclass, or an instance of + one: a protocol's fields as parsed values, which :attr:`Protocol.info + ` returns, as against the raw + octets of :attr:`Protocol.data `. + Its base, :class:`~pcapkit.corekit.infoclass.Info`, models the library's + other records too, such as the reassembly ones; :ref:`ext-new-protocol` + explains it. + + default engine + The engine that ``engine='default'`` (also spelled ``'pcapkit'``) selects, + and the one an unknown or unavailable engine falls back to: the built-in + :class:`~pcapkit.foundation.engines.pcap.PCAP` and + :class:`~pcapkit.foundation.engines.pcapng.PCAPNG` readers, picked by the + input's magic number (:doc:`pcapkit/foundation/engines/builtin`). + + info + On a protocol, :attr:`Protocol.info + `: its :term:`data model`, which + the enclosing layer's holds under :attr:`Protocol.info_name + ` + (:doc:`pcapkit/protocols/protocol`). Elsewhere, an + :class:`~pcapkit.corekit.infoclass.Info` instance, such as the + :term:`reasm.tcp.packet` record that reassembly takes as ``info`` + (:doc:`pcapkit/corekit/infoclass`). + + registry + One of these, told apart by context: + + * an upstream registry, such as an IANA protocol number registry, that + the crawlers fetch (:doc:`pcapkit/vendor/index`); + * the :mod:`pcapkit.const` enumeration generated from one, such as + :class:`~pcapkit.const.reg.ethertype.EtherType` + (:doc:`pcapkit/const/index`); + * a dispatch table from a code or name to the class that handles it, + such as :attr:`Protocol.__proto__ + ` or + :attr:`Extractor.__engine__ + `, and the + registration functions that write to one + (:doc:`pcapkit/foundation/registry`); + * :attr:`EnumMeta.registry + `, the mapping from + an enumeration member to the :term:`schema` that parses it. + + schema + A :class:`~pcapkit.protocols.schema.schema.Schema` subclass, which lays + out a protocol header's fields as they sit on the wire, or an instance of + one: the header as read or built, which :attr:`Protocol.schema + ` returns. It is not the + :term:`data model`; :ref:`ext-new-protocol` explains the pair. diff --git a/docs/source/index.rst b/docs/source/index.rst index 1e62cc1c06..bd848a7a23 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -33,6 +33,7 @@ analysis. ext demo + glossary changelog .. toctree:: diff --git a/docs/source/pcapkit/const/index.rst b/docs/source/pcapkit/const/index.rst index 3a2cd2e519..b540741ad3 100644 --- a/docs/source/pcapkit/const/index.rst +++ b/docs/source/pcapkit/const/index.rst @@ -11,7 +11,7 @@ automatically generated from the :mod:`pcapkit.vendor` module. Unrecognised Values ------------------- -Nearly every registry below departs from :mod:`enum` in one deliberate way, and it +Nearly every :term:`registry` below departs from :mod:`enum` in one deliberate way, and it is the behaviour to know about before using any of them: looking one up by a value it does not define does **not** raise. Its ``_missing_`` override returns a member-like object for a value inside the registry's valid range, named for the diff --git a/docs/source/pcapkit/foundation/engines/3rdparty.rst b/docs/source/pcapkit/foundation/engines/3rdparty.rst index ed81edd626..71be92925c 100644 --- a/docs/source/pcapkit/foundation/engines/3rdparty.rst +++ b/docs/source/pcapkit/foundation/engines/3rdparty.rst @@ -294,7 +294,7 @@ support, as is used by :class:`pcapkit.foundation.extraction.Extractor`. extraction. :meth:`PCAP_CT.unsupported_reason ` detects it and reports it as an ordinary "engine unavailable", so the extraction falls back to - the default engine with a warning naming the missing library. + the :term:`default engine` with a warning naming the missing library. .. warning:: diff --git a/docs/source/pcapkit/foundation/engines/builtin.rst b/docs/source/pcapkit/foundation/engines/builtin.rst index 05677ded98..6bbb0d276d 100644 --- a/docs/source/pcapkit/foundation/engines/builtin.rst +++ b/docs/source/pcapkit/foundation/engines/builtin.rst @@ -2,6 +2,8 @@ Built-in Engines ================ +Together, the readers below are the :term:`default engine`. + PCAP Support ============ diff --git a/docs/source/pcapkit/foundation/engines/index.rst b/docs/source/pcapkit/foundation/engines/index.rst index a07641ee44..6d0837a5f9 100644 --- a/docs/source/pcapkit/foundation/engines/index.rst +++ b/docs/source/pcapkit/foundation/engines/index.rst @@ -219,7 +219,7 @@ installed.** Pick on availability, not on features. system library at all ``import pcap`` raises :exc:`OSError` rather than :exc:`ImportError`; :meth:`PCAP_CT.unsupported_reason ` catches that and turns - it into an ordinary fall back to the default engine, instead of the hard error it would + it into an ordinary fall back to the :term:`default engine`, instead of the hard error it would otherwise be. **The two distributions collide, so install exactly one.** Both own the top-level diff --git a/docs/source/pcapkit/foundation/extraction.rst b/docs/source/pcapkit/foundation/extraction.rst index f29f19c94a..8f42e24c99 100644 --- a/docs/source/pcapkit/foundation/extraction.rst +++ b/docs/source/pcapkit/foundation/extraction.rst @@ -15,8 +15,8 @@ and extracts parameters from a PCAP file. :class:`pcapkit.foundation.engines.pypcap.PyPCAP` (``engine='pypcap'``), :class:`pcapkit.foundation.engines.pcap_ct.PCAP_CT` (``engine='pcap_ct'``) and :class:`pcapkit.foundation.engines.pypcapfile.PyPCAPFile` - (``engine='pypcapfile'``). Each supports less than the ``default`` - engine does; :doc:`engines/index` tabulates the gaps, and the + (``engine='pypcapfile'``). Each supports less than the + :term:`default engine` does; :doc:`engines/index` tabulates the gaps, and the :doc:`main page <../../index>` documents their installation prerequisites. .. autoclass:: pcapkit.foundation.extraction.Extractor diff --git a/docs/source/pcapkit/protocols/protocol.rst b/docs/source/pcapkit/protocols/protocol.rst index 5a086a3a1c..420b9185bc 100644 --- a/docs/source/pcapkit/protocols/protocol.rst +++ b/docs/source/pcapkit/protocols/protocol.rst @@ -123,7 +123,7 @@ VLAN tags and IPv6 extension headers alike. What follows is kept as :class:`~pcapkit.protocols.misc.raw.Raw`, with one :exc:`~pcapkit.utilities.warnings.ProtocolWarning` (:issue:`1610`), and is marked :attr:`~pcapkit.protocols.protocol.Protocol._past_layer_limit`, so the -default engine's TCP reassembly and flow tracing do not read a TCP segment +:term:`default engine`'s TCP reassembly and flow tracing do not read a TCP segment there back out, as they do one the TCP parser rejected (:issue:`1518`). A protocol constructed from its own octets is not counted itself, so the count starts at the first layer it dissects. One chain of extension headers is also bounded on diff --git a/pcapkit/foundation/extraction.py b/pcapkit/foundation/extraction.py index 483a186b64..f8ba29eeb8 100644 --- a/pcapkit/foundation/extraction.py +++ b/pcapkit/foundation/extraction.py @@ -800,7 +800,7 @@ def run(self) -> 'None': # pylint: disable=inconsistent-return-statements Raises: FormatError: If the selected engine cannot read the input's format -- - for the default engine, if it is neither PCAP nor PCAP-NG -- or if + for the :term:`default engine`, if it is neither PCAP nor PCAP-NG -- or if the built-in PCAP-NG engine reads a PCAP-NG input to PCAP output. :rtype: None @@ -837,7 +837,7 @@ def _select_engine(self) -> 'Optional[Type[EngineBase]]': An engine that is unknown, not installed, or rules itself out via :meth:`~pcapkit.foundation.engines.engine.EngineBase.unsupported_reason` - is replaced by the default one, and :attr:`self._exnam + is replaced by the :term:`default engine`, and :attr:`self._exnam ` is updated to match. :meth:`__init__` calls this before choosing the output format, so that the choice reflects the engine that runs rather than the one requested. diff --git a/pcapkit/foundation/reassembly/ip.py b/pcapkit/foundation/reassembly/ip.py index 26638f1b62..a3dae0ab45 100644 --- a/pcapkit/foundation/reassembly/ip.py +++ b/pcapkit/foundation/reassembly/ip.py @@ -117,7 +117,7 @@ def reassembly(self, info: 'Packet[_AT]') -> 'None': """Reassembly procedure. Arguments: - info: info dict of packets to be reassembled + info: :term:`info` record of packets to be reassembled Note: A completed datagram's buffer is freed, as in the diff --git a/pcapkit/foundation/reassembly/reassembly.py b/pcapkit/foundation/reassembly/reassembly.py index feb3ffdc0a..7848c019ef 100644 --- a/pcapkit/foundation/reassembly/reassembly.py +++ b/pcapkit/foundation/reassembly/reassembly.py @@ -240,7 +240,7 @@ def reassembly(self, info: '_PT') -> 'None': """Reassembly procedure. Arguments: - info: info dict of packets to be reassembled + info: :term:`info` record of packets to be reassembled """ # clear cache diff --git a/pcapkit/protocols/misc/null.py b/pcapkit/protocols/misc/null.py index 344ab67cc0..5de98c1f0a 100644 --- a/pcapkit/protocols/misc/null.py +++ b/pcapkit/protocols/misc/null.py @@ -116,7 +116,7 @@ def __post_init__(self, file: 'Optional[IO[bytes] | bytes]' = None, # pylint: d self._data = b'' #: io.BytesIO: Source data stream. self._file = io.BytesIO() - #: pcapkit.protocols.data.misc.null.NoPayload: Info dict of current instance. + #: pcapkit.protocols.data.misc.null.NoPayload: Data model of current instance. self._info = Data_NoPayload() #: pcapkit.protocols.misc.null.NoPayload: Payload of current instance. diff --git a/pcapkit/protocols/protocol.py b/pcapkit/protocols/protocol.py index bb9446abc8..3d4ffbecdd 100644 --- a/pcapkit/protocols/protocol.py +++ b/pcapkit/protocols/protocol.py @@ -595,16 +595,16 @@ def alias(self) -> 'str': """Acronym of current protocol.""" return self.__class__.__name__ - # key name for the info dict + # key name for the data model @property def info_name(self) -> 'str': - """Key name of the :attr:`info` dict.""" + """Key the enclosing layer's :term:`data model` holds :attr:`info` under.""" return self.__class__.__name__.lower() - # info dict of current instance + # data model of current instance @property def info(self) -> '_PT': - """Info dict of current instance.""" + """Parsed :term:`data model` of current instance.""" return self._info # binary packet data if current instance @@ -669,10 +669,10 @@ def packet(self) -> 'Data_Packet': payload=self._read_packet(), ) - # schema data + # header schema @cached_property def schema(self) -> '_ST': - """Schema data of the protocol.""" + """Header :term:`schema` instance of the protocol.""" return self.__header__ # caller supplied parsing context @@ -1361,7 +1361,7 @@ def __init__(self, file: 'Optional[IO[bytes] | bytes]' = None, length: 'Optional # post-init customisations self.__post_init__(file, length, **kwargs) # type: ignore[arg-type] - # inject packet payload to the info dict + # inject packet payload into the data model self._info.__update__(packet=self.packet.payload) @overload # pragma: no cover @@ -1415,7 +1415,7 @@ def __init_subclass__(cls, /, schema: 'Optional[Type[_ST]]' = None, This method is called when a subclass of :class:`Protocol` is defined. It is used to set the :attr:`self.__schema__ ` attribute of the subclass, and, if ``code`` is given, to register the - subclass into the next-layer dispatch registry (or registries) that + subclass into the next-layer dispatch :term:`registry` (or registries) that ``code`` names -- e.g. :attr:`Link.__proto__ `. @@ -2233,7 +2233,7 @@ def _lookup_nested(data: 'Data') -> 'list[str]': @staticmethod def _lookup_registry(registry: 'DefaultDict[Any, _VT]', code: 'Any') -> '_VT': - """Look up a dispatch registry entry without recording a miss. + """Look up a dispatch :term:`registry` entry without recording a miss. Arguments: registry: dispatch registry to read, i.e. :attr:`self.__proto__ diff --git a/tests/project/test_docs_glossary_1949_unit.py b/tests/project/test_docs_glossary_1949_unit.py new file mode 100644 index 0000000000..e3b7e034e4 --- /dev/null +++ b/tests/project/test_docs_glossary_1949_unit.py @@ -0,0 +1,230 @@ +# -*- coding: utf-8 -*- +"""Pins the glossary of words that carry several meanings in the docs (#1949). + +"Registry", "schema", "data model", "info" and "default engine" each mean +different things on different pages, so a link to any one explanation cannot +resolve them. :file:`docs/source/glossary.rst` says which sense applies where +and links to the page that explains it, and the ambiguous mentions point there +with ``:term:``. + +* :class:`PageTests` -- the page exists and sits in the "Guides & Changelog" + toctree between ``demo`` and ``changelog``. +* :class:`TermTests` -- the page defines exactly the listed terms, each one once + across the ``.rst`` pages and the :file:`pcapkit/` sources, and each entry links + to a page or label that exists. +* :class:`RoleTests` -- every ``:term:`` role names a defined term. Sphinx warns + on an unknown term, but CI's plain ``make html`` does not fail on a warning, so + a broken role would otherwise build green. +* :class:`DocstringTests` -- :attr:`Protocol.info + ` says it returns a data model, not + an "Info dict". + +Everything is read as text or parsed with :mod:`ast`; nothing is imported or built. + +""" + +from __future__ import annotations + +import ast +import pathlib +import re +import unittest +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Iterator + +ROOT = pathlib.Path(__file__).resolve().parents[2] +DOCS = ROOT / 'docs' / 'source' +PACKAGE = ROOT / 'pcapkit' + +GLOSSARY = DOCS / 'glossary.rst' +INDEX = DOCS / 'index.rst' +PROTOCOL = PACKAGE / 'protocols' / 'protocol.py' + +#: The terms the glossary page holds, and nothing else. +TERMS = frozenset({'registry', 'schema', 'data model', 'info', 'default engine'}) + +#: Pages and labels each entry must link to: ``doc:`` names a document relative +#: to :data:`DOCS`, ``ref:`` a label. +REQUIRED_LINKS = { + 'registry': {'doc:pcapkit/const/index', 'doc:pcapkit/vendor/index', 'doc:pcapkit/foundation/registry'}, + 'schema': {'ref:ext-new-protocol'}, + 'data model': {'ref:ext-new-protocol'}, + 'info': {'doc:pcapkit/protocols/protocol', 'doc:pcapkit/corekit/infoclass'}, + 'default engine': {'doc:pcapkit/foundation/engines/builtin'}, +} + +#: Terms defined outside this project and resolved through intersphinx: Python's glossary. +EXTERNAL_TERMS = frozenset({'bytes-like object'}) + +GLOSSARY_DIRECTIVE = re.compile(r'^(?P[ \t]*)\.\. glossary::[ \t]*$', re.MULTILINE) +TERM_ROLE = re.compile(r':term:`(?P[^`]+)`') +LINK_ROLE = re.compile(r':(?Pdoc|ref):`(?P[^`]+)`') +LABEL = re.compile(r'^[ \t]*\.\. _(?P[^:`]+):[ \t]*$', re.MULTILINE) + + +def _norm(text: str) -> str: + """Fold case and whitespace as Sphinx does when matching a term.""" + return ' '.join(text.split()).lower() + + +def _target(body: str) -> str: + """The target of a role body: the part in angle brackets, or the whole body.""" + match = re.search(r'<([^<>]+)>\s*$', body) + return _norm(match.group(1) if match else body) + + +def _indent(line: str) -> int: + return len(line) - len(line.lstrip()) + + +def sources() -> Iterator[pathlib.Path]: + """Every ``.rst`` page under :data:`DOCS` and every module under :data:`PACKAGE`.""" + yield from sorted(DOCS.rglob('*.rst')) + yield from sorted(PACKAGE.rglob('*.py')) + + +def glossary_entries(text: str) -> Iterator[tuple[str, str]]: + """Yield ``(term, definition)`` for each term in every ``.. glossary::`` block of ``text``.""" + lines = text.splitlines() + for match in GLOSSARY_DIRECTIVE.finditer(text): + base = len(match.group('indent')) + start = text.count('\n', 0, match.start()) + 1 + block = [] + for line in lines[start:]: + if line.strip() and _indent(line) <= base: + break + block.append(line) + content = [line for line in block if line.strip() and not line.strip().startswith(':')] + if not content: + continue + level = _indent(content[0]) + # Stacked term lines share the definition that follows them. + groups = [] # type: list[tuple[list[str], list[str]]] + for line in block: + if line.strip() and _indent(line) == level and not line.strip().startswith(':'): + if not groups or groups[-1][1]: + groups.append(([], [])) + groups[-1][0].append(line.strip()) + elif groups and line.strip(): + groups[-1][1].append(line) + for terms, body in groups: + for term in terms: + yield term, '\n'.join(body) + + +def definitions() -> dict[str, list[str]]: + """Map each defined term to the files that define it.""" + found = {} # type: dict[str, list[str]] + for path in sources(): + text = path.read_text(encoding='utf-8') + if '.. glossary::' not in text: + continue + for term, _ in glossary_entries(text): + found.setdefault(_norm(term), []).append(path.relative_to(ROOT).as_posix()) + return found + + +class PageTests(unittest.TestCase): + """The glossary page exists and is in the guides toctree.""" + + def test_page_exists(self) -> None: + self.assertTrue(GLOSSARY.is_file(), f'{GLOSSARY.relative_to(ROOT)} is missing') + + def test_page_sits_between_demo_and_changelog(self) -> None: + text = INDEX.read_text(encoding='utf-8') + match = re.search(r'^\.\. toctree::\n(?:[ \t]+:[^\n]*\n)*?[ \t]+:caption: Guides & Changelog\n' + r'(?:[ \t]+:[^\n]*\n)*\n(?P(?:[ \t]+\S[^\n]*\n)+)', text, re.MULTILINE) + self.assertIsNotNone(match, 'no "Guides & Changelog" toctree in index.rst') + assert match is not None + entries = match.group('entries').split() + self.assertIn('glossary', entries) + self.assertEqual(entries[entries.index('glossary') - 1:entries.index('glossary') + 2], + ['demo', 'glossary', 'changelog']) + + +class TermTests(unittest.TestCase): + """The page defines exactly the listed terms, once each, and every entry links out.""" + + def setUp(self) -> None: + self.assertTrue(GLOSSARY.is_file(), f'{GLOSSARY.relative_to(ROOT)} is missing') + self.entries = {_norm(term): body for term, body in glossary_entries(GLOSSARY.read_text(encoding='utf-8'))} + + def test_page_holds_only_the_listed_terms(self) -> None: + self.assertEqual(set(self.entries), set(TERMS)) + + def test_each_term_is_defined_once(self) -> None: + found = definitions() + for term in sorted(TERMS): + with self.subTest(term=term): + self.assertEqual(found.get(term), [GLOSSARY.relative_to(ROOT).as_posix()]) + + def test_no_term_is_defined_twice_anywhere(self) -> None: + for term, files in sorted(definitions().items()): + with self.subTest(term=term): + self.assertEqual(len(files), 1, f'{term!r} is defined in {files}') + + def test_each_entry_links_to_the_page_that_explains_it(self) -> None: + labels = {match.group('name').lower() for path in DOCS.rglob('*.rst') + for match in LABEL.finditer(path.read_text(encoding='utf-8'))} + for term in sorted(TERMS): + with self.subTest(term=term): + body = self.entries.get(term, '') + links = {f"{match.group('kind')}:{_target(match.group('body'))}" for match in LINK_ROLE.finditer(body)} + self.assertLessEqual(REQUIRED_LINKS[term], links) + for link in sorted(links): + kind, _, target = link.partition(':') + if kind == 'doc': + self.assertTrue((DOCS / f'{target.lstrip("/")}.rst').is_file(), f'{term}: no page {target}') + else: + self.assertIn(target, labels, f'{term}: no label {target}') + + +class RoleTests(unittest.TestCase): + """Every ``:term:`` role in the docs and the sources names a defined term.""" + + def test_every_term_role_resolves(self) -> None: + defined = set(definitions()) | EXTERNAL_TERMS + roles = 0 + for path in sources(): + text = path.read_text(encoding='utf-8') + for match in TERM_ROLE.finditer(text): + roles += 1 + target = _target(match.group('body')) + line = text.count('\n', 0, match.start()) + 1 + with self.subTest(site=f'{path.relative_to(ROOT).as_posix()}:{line}'): + self.assertIn(target, defined) + self.assertGreater(roles, 0, 'no :term: role found, so this test checks nothing') + + def test_glossary_terms_are_used(self) -> None: + used = set() + for path in sources(): + if path == GLOSSARY: + continue + used.update(_target(match.group('body')) for match in TERM_ROLE.finditer(path.read_text(encoding='utf-8'))) + for term in sorted(TERMS): + with self.subTest(term=term): + self.assertIn(term, used, f'nothing outside the glossary links to {term!r}') + + +class DocstringTests(unittest.TestCase): + """``Protocol.info`` says what it returns.""" + + def _docstring(self, name: str) -> str: + tree = ast.parse(PROTOCOL.read_text(encoding='utf-8')) + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef) and node.name == 'ProtocolBase': + for item in node.body: + if isinstance(item, ast.FunctionDef) and item.name == name: + return ast.get_docstring(item) or '' + self.fail(f'ProtocolBase.{name} not found') + + def test_info_is_not_called_a_dict(self) -> None: + doc = self._docstring('info') + self.assertNotIn('info dict', doc.lower()) + self.assertIn(':term:`data model`', doc) + + +if __name__ == '__main__': + unittest.main() From f3a65c5360a575168c58543f4533f72d6f67194e Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Mon, 19 Oct 2026 15:21:16 -0400 Subject: [PATCH 2/2] docs: fix the glossary's info entry wording (#1949) - supply the missing noun in the info entry: the data model is stored in the enclosing layer's data model under info_name - say what the extension guide explains in the data model entry, rather than an unclear "it" --- docs/source/glossary.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/glossary.rst b/docs/source/glossary.rst index acfeda3403..e550ad5e5a 100644 --- a/docs/source/glossary.rst +++ b/docs/source/glossary.rst @@ -13,7 +13,7 @@ sense applies where, and links to the page that explains it. octets of :attr:`Protocol.data `. Its base, :class:`~pcapkit.corekit.infoclass.Info`, models the library's other records too, such as the reassembly ones; :ref:`ext-new-protocol` - explains it. + explains data models. default engine The engine that ``engine='default'`` (also spelled ``'pcapkit'``) selects, @@ -24,8 +24,8 @@ sense applies where, and links to the page that explains it. info On a protocol, :attr:`Protocol.info - `: its :term:`data model`, which - the enclosing layer's holds under :attr:`Protocol.info_name + `: its :term:`data model`, stored + in the enclosing layer's data model under :attr:`Protocol.info_name ` (:doc:`pcapkit/protocols/protocol`). Elsewhere, an :class:`~pcapkit.corekit.infoclass.Info` instance, such as the