From 3355d73171306591b428a4a0e82d75edaa726fb9 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Tue, 27 Oct 2026 18:37:12 -0400 Subject: [PATCH] refactor(foundation): declare _CODE_DESTINATIONS as a dunder on the enum registries themselves Follow-up to #2392/#2343. _CODE_DESTINATIONS was a module-level dict mapping an enum registry type to the protocol class(es) owning the __proto__ dispatch registry keyed by that enum, so register_protocol_code could infer a destination from a bare enum member. Mirrors __proto_key__, which #2392 moved the opposite direction (destination -> enum) onto the destination classes themselves. Each of the 4 enum registry classes (EtherType, TransType, LinkType, PayloadProtocolIdentifier) now declares __proto_dest__, a tuple of ModuleDescriptor naming its destination class(es) lazily -- const sits below protocols in the dependency order, so a real class reference would make const import protocols; ModuleDescriptor (already used for __proto__ dispatch tables elsewhere) avoids that without inventing a parallel string-path scheme. _iter_code_targets reads the dunder off type(code) via getattr() and resolves each descriptor's .klass. getattr(), not vars(): verified aenum refuses to extend an enumeration that already has members, so none of these four registries can gain a subclass with its own registry -- the hazard __proto_key__'s vars() call guards against (Loopback/IPX owning a differently-keyed registry) is structurally impossible here. The one enum registry that is subclassed, AppType, is irrelevant here only because it declares no __proto_dest__ at all -- a class-level destination would in fact be the wrong model for it, since register_apptype routes each member to the registry named by that member's own transport protocol, which no single shared destination could express. const/reg/*.py and const/sctp/payload_protocol_identifier.py are regenerated, not hand-edited: the vendor crawlers (vendor/reg/*.py, vendor/sctp/payload_protocol_identifier.py) gain a PROTO_DEST class attribute, threaded through vendor/default.py's LINE template and context(); a throwaway script recovered each committed file's ENUM/MISS blocks and re-rendered through the unmodified template first, asserting byte-identity with the committed file, before re-rendering with PROTO_DEST to confirm the diff is purely the template's effect. Tests: tests/vendor/test_vendor_proto_dest_2392_unit.py (new) plus ProtoDestDeclarationTests in test_protocol_code_registration_unit.py and an update to test_dispatch_reachability_unit.py, which read the dunder instead of the deleted dict. --- CHANGELOG.md | 2 +- docs/source/changelog/1.5.0.rst | 2 +- docs/source/pcapkit/const/reg.rst | 3 + docs/source/pcapkit/const/sctp.rst | 1 + docs/source/pcapkit/vendor/default.rst | 11 +- pcapkit/const/reg/ethertype.py | 12 + pcapkit/const/reg/linktype.py | 13 + pcapkit/const/reg/transtype.py | 12 + .../const/sctp/payload_protocol_identifier.py | 12 + pcapkit/foundation/registry/protocols.py | 52 ++-- pcapkit/vendor/default.py | 84 +++++- pcapkit/vendor/reg/ethertype.py | 5 + pcapkit/vendor/reg/linktype.py | 11 + pcapkit/vendor/reg/transtype.py | 5 + .../sctp/payload_protocol_identifier.py | 5 + .../test_dispatch_reachability_unit.py | 16 +- .../test_protocol_code_registration_unit.py | 182 +++++++++++- .../test_vendor_proto_dest_2392_unit.py | 260 ++++++++++++++++++ 18 files changed, 641 insertions(+), 47 deletions(-) create mode 100644 tests/vendor/test_vendor_proto_dest_2392_unit.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b2c96960e..7af8870156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -188,7 +188,7 @@ Preceded by `1.5.0a1` (2026-09-15), `1.5.0b1` to `1.5.0b3` (all 2026-09-19) and - the Mobility Header registry, completed ([#383](https://github.com/JarryShaw/PyPCAPKit/pull/383), [#437](https://github.com/JarryShaw/PyPCAPKit/pull/437)): the [RFC 5568](https://datatracker.ietf.org/doc/html/rfc5568) fast-handover messages and options, then every registered message data type, every registered option (with nested sub-option registries for the flow identification, access network identifier, quality-of-service and LMA-controlled MAG parameter families) and every CGA extension. The CGA Parameters option, the only option that can carry a CGA extension on the wire, was the last to come off the generic handler, which is what let the CGA extensions round-trip end to end. - dispatch entries for dissectors that existed but were reachable from no registry ([#436](https://github.com/JarryShaw/PyPCAPKit/pull/436)): FTP-DATA on TCP 20, HTTP/1 on TCP 8080, HTTP on UDP 8080, `L2TPv2` on UDP 1701 and OSPF at `TransType` 89. `VLAN` became an abstract base with `C_Tag` (802.1Q) and `S_Tag` (802.1ad) as concrete subclasses, so a Q-in-Q frame no longer collapses into one opaque `Raw`; `L2TP` likewise became a base, with `L2TPv2` carrying the [RFC 2661](https://datatracker.ietf.org/doc/html/rfc2661) implementation. - `Protocol`/`ProtocolBase` gain a `code=` class keyword for the next-layer dispatch registries (`Link.__proto__`, `Internet.__proto__`, `TCP.__proto__`, `UDP.__proto__`, `SCTP.__proto__`, `Frame.__proto__`, `PCAPNG.__proto__`), the opt-in treatment [#514](https://github.com/JarryShaw/PyPCAPKit/issues/514) gave `Engine`, `Reassembly`, `TraceFlow` and `Dumper` above, extended to the one family that needed a registration key invented. Omitting `code` leaves a subclass unregistered, as before; the built-in tables are still filled by literal assignment in each layer module, so nothing the library ships moves. `code` accepts a bare enum member, whose *type* infers the destination (`EtherType` means `Link`, `TransType` means `Internet`, `PayloadProtocolIdentifier` means `SCTP`, and `LinkType` means *both* `Frame` **and** `PCAPNG`, mirroring `register_linktype`), or a `{destination: key}` mapping, required for a raw `int` such as a port number, which cannot say which transport it belongs to. Either form may appear in an iterable, so one declaration can register a class into several registries (e.g. a `L2TP` subclass reachable by IP protocol number and by a UDP port), and the mapping form is accepted even where inference would work. Inference refuses rather than guesses: an enum member whose type names no known destination raises `RegistryError`, and an unrecognised class keyword raises `UnsupportedCall`, matching the other registrar families. Backed by `pcapkit.foundation.registry.protocols.register_protocol_code()`, also callable directly for a class that declined at definition time. It was written up as the mechanism [#548](https://github.com/JarryShaw/PyPCAPKit/issues/548) needs; [#548](https://github.com/JarryShaw/PyPCAPKit/issues/548) turned out to need a missing class instead (115 is [RFC 3931](https://datatracker.ietf.org/doc/html/rfc3931) L2TPv3 over IP, not [RFC 2661](https://datatracker.ietf.org/doc/html/rfc2661); see the corresponding **Fixed** entry below), so its worked example names `L2TPv3` ([#570](https://github.com/JarryShaw/PyPCAPKit/pull/570)). -- `tests/protocols/test_dispatch_reachability_unit.py`, the coverage [#548](https://github.com/JarryShaw/PyPCAPKit/issues/548) asked for: every `ProtocolBase` descendant whose `__index__` returns an enum member must be reachable under that code in the registry its enum *type* designates, read from the same `_CODE_DESTINATIONS` table backing `code=` so the two cannot drift. It walks the classes rather than the registry entries, so it catches a class nothing registered, the shape in which `OSPF` once shipped ([#548](https://github.com/JarryShaw/PyPCAPKit/issues/548)). +- `tests/protocols/test_dispatch_reachability_unit.py`, the coverage [#548](https://github.com/JarryShaw/PyPCAPKit/issues/548) asked for: every `ProtocolBase` descendant whose `__index__` returns an enum member must be reachable under that code in the registry its enum *type* designates, read from the same `__proto_dest__` declaration backing `code=` so the two cannot drift. It walks the classes rather than the registry entries, so it catches a class nothing registered, the shape in which `OSPF` once shipped ([#548](https://github.com/JarryShaw/PyPCAPKit/issues/548)). - `tests/protocols/transport/test_tcp_mptcp_join_flag_ordering_unit.py`, covering all three MP_JOIN layouts through the *public* constructor, the construct-pack-parse cycle for each, the stale-flags case that rules out a zero-valued default, the statement order, and controls for the parse path and the flag-independent options. The old cases bypassed the public path, so full coverage coexisted with a broken one. The stale `tcp-mptcp/MP_JOIN` entry is deleted from `EXPECTED_FAILURES` ([#587](https://github.com/JarryShaw/PyPCAPKit/issues/587)). - `tests/protocols/test_option_generator_tcp_base_unit.py`, asserting that every `TCP_BASE` key is a parameter `TCP.make()` declares (derived from `inspect.signature`) and that the built segment carries the stated header in both data model and packed octets ([#602](https://github.com/JarryShaw/PyPCAPKit/issues/602)). - ILNP nonce sizing coverage in `tests/protocols/internet/test_ipv6_extension_unit.py`, a test per protocol, asserting the declared length, the exact packed octets and the construct-pack-parse cycle over a spread of nonces, most with a bit length that is *not* a multiple of eight, where floor division and the ceiling disagree ([#601](https://github.com/JarryShaw/PyPCAPKit/issues/601)). diff --git a/docs/source/changelog/1.5.0.rst b/docs/source/changelog/1.5.0.rst index b4ef29685b..797938953c 100644 --- a/docs/source/changelog/1.5.0.rst +++ b/docs/source/changelog/1.5.0.rst @@ -1303,7 +1303,7 @@ Added * ``tests/protocols/test_dispatch_reachability_unit.py``, the coverage :issue:`548` asked for: every :class:`.ProtocolBase` descendant whose ``__index__`` returns an enum member must be reachable under that code in the registry its enum *type* - designates, read from the same ``_CODE_DESTINATIONS`` table backing ``code=`` so + designates, read from the same ``__proto_dest__`` declaration backing ``code=`` so the two cannot drift. It walks the classes rather than the registry entries, so it catches a class nothing registered, the shape in which :class:`~pcapkit.protocols.application.ospf.OSPF` once shipped (:issue:`548`). diff --git a/docs/source/pcapkit/const/reg.rst b/docs/source/pcapkit/const/reg.rst index fffa98590b..16ff8cfee1 100644 --- a/docs/source/pcapkit/const/reg.rst +++ b/docs/source/pcapkit/const/reg.rst @@ -30,6 +30,7 @@ which is automatically generated from :class:`pcapkit.vendor.reg.linktype.LinkTy :members: :undoc-members: :show-inheritance: + :special-members: __proto_dest__ Ethertype IEEE 802 Numbers ========================== @@ -43,6 +44,7 @@ which is automatically generated from :class:`pcapkit.vendor.reg.ethertype.Ether :members: :undoc-members: :show-inheritance: + :special-members: __proto_dest__ Transport Layer Protocol Numbers ================================ @@ -56,6 +58,7 @@ which is automatically generated from :class:`pcapkit.vendor.reg.transtype.Trans :members: :undoc-members: :show-inheritance: + :special-members: __proto_dest__ Application Layer Protocol Numbers ================================== diff --git a/docs/source/pcapkit/const/sctp.rst b/docs/source/pcapkit/const/sctp.rst index 01882a6a03..acf29b6cec 100644 --- a/docs/source/pcapkit/const/sctp.rst +++ b/docs/source/pcapkit/const/sctp.rst @@ -70,6 +70,7 @@ which is automatically generated from :class:`pcapkit.vendor.sctp.payload_protoc :members: :undoc-members: :show-inheritance: + :special-members: __proto_dest__ .. rubric:: Footnotes diff --git a/docs/source/pcapkit/vendor/default.rst b/docs/source/pcapkit/vendor/default.rst index 7458b9905b..012df5558a 100644 --- a/docs/source/pcapkit/vendor/default.rst +++ b/docs/source/pcapkit/vendor/default.rst @@ -16,6 +16,7 @@ only, which is the base class for all vendor crawlers. :no-value: .. autoattribute:: LINK :no-value: + .. autoattribute:: PROTO_DEST .. automethod:: count .. automethod:: process @@ -76,7 +77,7 @@ Internal Definitions :no-members: :show-inheritance: -.. function:: LINE(NAME, DOCS, FLAG, ENUM, MISS, MODL) +.. function:: LINE(NAME, DOCS, FLAG, ENUM, MISS, MODL, DEST=()) Default constant template of enumeration registry from IANA CSV. @@ -86,8 +87,16 @@ Internal Definitions :param str ENUM: enumeration data (class attributes) :param str MISS: missing value handler (default value) :param str MODL: module name of the constant enumeration class + :param DEST: the crawler's :attr:`~pcapkit.vendor.default.Vendor.PROTO_DEST`, + rendered as the class's ``__proto_dest__``; nothing is rendered when it is + empty, which is the case for every registry that keys no ``__proto__`` + :type DEST: tuple[~pcapkit.corekit.module.ModuleDescriptor, ...] :rtype: str +.. autodata:: pcapkit.vendor.default.PROTO_DEST_IMPORT + +.. autofunction:: pcapkit.vendor.default.render_proto_dest + .. autofunction:: pcapkit.vendor.default.get_proxies .. autofunction:: pcapkit.vendor.default.stdin_is_interactive diff --git a/pcapkit/const/reg/ethertype.py b/pcapkit/const/reg/ethertype.py index b430cafe1e..4310ce9d71 100644 --- a/pcapkit/const/reg/ethertype.py +++ b/pcapkit/const/reg/ethertype.py @@ -15,6 +15,7 @@ from aenum import IntEnum from pcapkit.corekit.enum import EnumRegistry +from pcapkit.corekit.module import ModuleDescriptor from pcapkit.utilities.exceptions import EnumValueError if TYPE_CHECKING: @@ -26,6 +27,17 @@ class EtherType(EnumRegistry, IntEnum): """[EtherType] Ethertype IEEE 802 Numbers""" + #: Protocol class(es) owning the ``__proto__`` dispatch registry keyed by + #: this enumeration, read by + #: :func:`~pcapkit.foundation.registry.protocols.register_protocol_code` + #: to resolve a bare member of it into its destination(s). The classes are + #: named as lazy :class:`~pcapkit.corekit.module.ModuleDescriptor` + #: references rather than imported, because :mod:`pcapkit.const` sits below + #: :mod:`pcapkit.protocols` and importing one from here would be circular. + __proto_dest__ = ( + ModuleDescriptor('pcapkit.protocols.link.link', 'Link'), + ) + #: Formerly XEROX PUP. Invalid as an Ethertype since 1983. Use 0x0A00. [Boggs, #: D., J. Shoch, E. Taft, and R. Metcalfe, "PUP: An Internetwork Architecture", #: XEROX Palo Alto Research Center, CSL-79-10, July 1979; also in IEEE diff --git a/pcapkit/const/reg/linktype.py b/pcapkit/const/reg/linktype.py index a1c92e4e1b..62665625ae 100644 --- a/pcapkit/const/reg/linktype.py +++ b/pcapkit/const/reg/linktype.py @@ -15,6 +15,7 @@ from aenum import IntEnum from pcapkit.corekit.enum import EnumRegistry +from pcapkit.corekit.module import ModuleDescriptor from pcapkit.utilities.exceptions import EnumValueError if TYPE_CHECKING: @@ -26,6 +27,18 @@ class LinkType(EnumRegistry, IntEnum): """[LinkType] Link-Layer Header Type Values""" + #: Protocol class(es) owning the ``__proto__`` dispatch registry keyed by + #: this enumeration, read by + #: :func:`~pcapkit.foundation.registry.protocols.register_protocol_code` + #: to resolve a bare member of it into its destination(s). The classes are + #: named as lazy :class:`~pcapkit.corekit.module.ModuleDescriptor` + #: references rather than imported, because :mod:`pcapkit.const` sits below + #: :mod:`pcapkit.protocols` and importing one from here would be circular. + __proto_dest__ = ( + ModuleDescriptor('pcapkit.protocols.misc.pcap.frame', 'Frame'), + ModuleDescriptor('pcapkit.protocols.misc.pcapng', 'PCAPNG'), + ) + #: [``DLT_NULL``] BSD loopback encapsulation. NULL = 0 diff --git a/pcapkit/const/reg/transtype.py b/pcapkit/const/reg/transtype.py index 76e9af4abc..e5d082caf2 100644 --- a/pcapkit/const/reg/transtype.py +++ b/pcapkit/const/reg/transtype.py @@ -15,6 +15,7 @@ from aenum import IntEnum from pcapkit.corekit.enum import EnumRegistry +from pcapkit.corekit.module import ModuleDescriptor from pcapkit.utilities.exceptions import EnumValueError if TYPE_CHECKING: @@ -26,6 +27,17 @@ class TransType(EnumRegistry, IntEnum): """[TransType] Transport Layer Protocol Numbers""" + #: Protocol class(es) owning the ``__proto__`` dispatch registry keyed by + #: this enumeration, read by + #: :func:`~pcapkit.foundation.registry.protocols.register_protocol_code` + #: to resolve a bare member of it into its destination(s). The classes are + #: named as lazy :class:`~pcapkit.corekit.module.ModuleDescriptor` + #: references rather than imported, because :mod:`pcapkit.const` sits below + #: :mod:`pcapkit.protocols` and importing one from here would be circular. + __proto_dest__ = ( + ModuleDescriptor('pcapkit.protocols.internet.internet', 'Internet'), + ) + #: IPv6 Hop-by-Hop Option [:rfc:`8200`] HOPOPT = 0 diff --git a/pcapkit/const/sctp/payload_protocol_identifier.py b/pcapkit/const/sctp/payload_protocol_identifier.py index e886da9e4f..b4bde9c83a 100644 --- a/pcapkit/const/sctp/payload_protocol_identifier.py +++ b/pcapkit/const/sctp/payload_protocol_identifier.py @@ -15,6 +15,7 @@ from aenum import IntEnum from pcapkit.corekit.enum import EnumRegistry +from pcapkit.corekit.module import ModuleDescriptor from pcapkit.utilities.exceptions import EnumValueError if TYPE_CHECKING: @@ -26,6 +27,17 @@ class PayloadProtocolIdentifier(EnumRegistry, IntEnum): """[PayloadProtocolIdentifier] SCTP Payload Protocol Identifiers""" + #: Protocol class(es) owning the ``__proto__`` dispatch registry keyed by + #: this enumeration, read by + #: :func:`~pcapkit.foundation.registry.protocols.register_protocol_code` + #: to resolve a bare member of it into its destination(s). The classes are + #: named as lazy :class:`~pcapkit.corekit.module.ModuleDescriptor` + #: references rather than imported, because :mod:`pcapkit.const` sits below + #: :mod:`pcapkit.protocols` and importing one from here would be circular. + __proto_dest__ = ( + ModuleDescriptor('pcapkit.protocols.transport.sctp', 'SCTP'), + ) + #: Reserved by SCTP [:rfc:`9260`] Reserved_by_SCTP = 0 diff --git a/pcapkit/foundation/registry/protocols.py b/pcapkit/foundation/registry/protocols.py index a910e76193..3a32851cfa 100644 --- a/pcapkit/foundation/registry/protocols.py +++ b/pcapkit/foundation/registry/protocols.py @@ -88,7 +88,6 @@ from pcapkit.const.reg.transtype import TransType from pcapkit.const.sctp.payload_protocol_identifier import \ PayloadProtocolIdentifier as SCTP_PayloadProtocolIdentifier - from pcapkit.corekit.enum import EnumRegistry from pcapkit.corekit.sentinels import NullType from pcapkit.protocols.application.httpv2 import FrameConstructor as HTTP_FrameConstructor from pcapkit.protocols.application.httpv2 import FrameParser as HTTP_FrameParser @@ -405,25 +404,6 @@ def _register_name(protocol: 'Type[ProtocolBase]', *targets: 'tuple[type, Any]') _CODE_LEASES[target] = lease -#: Destination classes for each enum type a ``code=`` key can carry. -#: -#: Maps an enum type to the class(es) owning the -#: :attr:`ProtocolBase.__proto__ ` -#: dispatch registry keyed by that enum type -- the "registry-of-registries" that -#: lets ``code=`` infer a destination from a key's own type, per :issue:`514`. It -#: is the same targeting :func:`register_ethertype`, :func:`register_transtype`, -#: :func:`register_linktype` and :func:`register_sctp` hard-code by hand, in one -#: table so :func:`register_protocol_code` can consult it. :class:`~pcapkit.const.reg.linktype.LinkType` naming -#: more than one class is deliberate, not ambiguous -- :func:`register_linktype` -#: already fans out to each of them. -_CODE_DESTINATIONS: 'dict[Type[EnumRegistry], tuple[Type[ProtocolBase], ...]]' = { - Enum_EtherType: (Link,), - Enum_TransType: (Internet,), - Enum_PayloadProtocolIdentifier: (SCTP,), - Enum_LinkType: (Frame, PCAPNG), -} - - def _iter_code_targets(code: 'Any') -> 'Iterator[tuple[Type[ProtocolBase], Any]]': """Flatten a ``code=`` argument into ``(destination, key)`` pairs. @@ -445,13 +425,29 @@ def _iter_code_targets(code: 'Any') -> 'Iterator[tuple[Type[ProtocolBase], Any]] return if isinstance(code, (enum.Enum, aenum.Enum)): - destinations = _CODE_DESTINATIONS.get(cast('Type[EnumRegistry]', type(code))) - if destinations is None: + # NOTE: read with ``getattr``, not out of the type's own ``vars()`` as + # the ``__proto_key__`` check in ``register_protocol_code`` does. That + # read is deliberately non-inheriting because a protocol subclass can + # own a registry keyed by something other than its parent's -- Loopback + # does. No enum registry can be in that position: declaring a + # destination means having members, and aenum refuses to extend an + # enumeration with members, so none of these registries has a subclass + # and none can gain one. The one enum registry that is subclassed is + # AppType, a memberless base partitioned by transport protocol, and its + # subclass relationship is irrelevant here only because AppType itself + # declares no ``__proto_dest__`` at all -- the inheriting read finds + # nothing to inherit, so none of its parts reaches this branch. A + # class-level destination would in fact be the wrong model for it: + # ``register_apptype`` routes each member to the registry named by that + # member's own transport protocol, which one shared destination for the + # whole family could not express. + destinations = getattr(type(code), '__proto_dest__', None) + if not destinations: raise RegistryError( f'no destination registry is known for enum type {type(code).__name__!r} ' f'(key {code!r}); pass an explicit destination, e.g. code={{SomeClass: {code!r}}}') - for destination in destinations: - yield destination, code + for descriptor in destinations: + yield descriptor.klass, code return if isinstance(code, (str, bytes)): @@ -479,8 +475,9 @@ def register_protocol_code(protocol: 'Type[ProtocolBase]', code: 'Any') -> 'None ``code`` is normalised into a flat sequence of targets, where each item is either: - * an enum member, whose *type* is looked up in a small table mapping - enum type to the class(es) owning the matching ``__proto__`` -- + * an enum member, whose *type* names the class(es) owning the matching + ``__proto__`` in its + :attr:`__proto_dest__ ` -- :class:`~pcapkit.const.reg.ethertype.EtherType` to :class:`~pcapkit.protocols.link.link.Link`, :class:`~pcapkit.const.reg.transtype.TransType` to @@ -527,7 +524,8 @@ def register_protocol_code(protocol: 'Type[ProtocolBase]', code: 'Any') -> 'None Raises: RegistryError: If a bare :class:`int` (or any other non-enum, non-mapping value) is given without an explicit destination, or if - an enum member's type names no known destination registry -- + an enum member's type declares no + :attr:`__proto_dest__ ` -- inference refuses rather than guesses; or if a key is not of the type its destination's registry is keyed by, as its typed registrar would reject it, e.g. ``{Frame: TransType.ICMP}``. What a registry is diff --git a/pcapkit/vendor/default.py b/pcapkit/vendor/default.py index cde5ed205e..89e78a906d 100644 --- a/pcapkit/vendor/default.py +++ b/pcapkit/vendor/default.py @@ -32,7 +32,9 @@ if TYPE_CHECKING: from collections import Counter - from typing import Callable, Optional + from typing import Any, Callable, Optional + + from pcapkit.corekit.module import ModuleDescriptor __all__ = ['Vendor'] @@ -70,8 +72,62 @@ #: under ``[project.urls]`` in :file:`pyproject.toml`. PROJECT_URL = 'https://github.com/JarryShaw/PyPCAPKit' +#: Import line the generated module needs for a non-empty ``__proto_dest__``. +#: :data:`LINE` renders it only when the crawler declares +#: :attr:`Vendor.PROTO_DEST`, so an enumeration naming no destination keeps the +#: import block it had. The trailing newline lives here rather than in the +#: f-string because an f-string replacement field may not contain a backslash +#: before Python 3.12, and this package still builds on 3.8. +PROTO_DEST_IMPORT = 'from pcapkit.corekit.module import ModuleDescriptor\n' + + +def render_proto_dest(descriptors: 'tuple[ModuleDescriptor[Any], ...]') -> 'str': + """Render the generated class's ``__proto_dest__`` declaration. + + :data:`LINE` calls this with the crawler's :attr:`Vendor.PROTO_DEST`. It is + a function rather than an expression inside the f-string for the reason + :data:`PROTO_DEST_IMPORT` gives: the block it returns contains newlines, + which an f-string replacement field cannot spell before Python 3.12. + + Args: + descriptors: The crawler's :attr:`Vendor.PROTO_DEST`. + + Returns: + The declaration, indented as a class body and followed by a blank line, + or the empty string when ``descriptors`` is empty -- which is what keeps + the template's output byte-identical for every enumeration that names no + destination. + + """ + if not descriptors: + return '' + entries = ''.join(f" ModuleDescriptor({module!r}, {name!r}),\n" + for module, name in descriptors) + return (' #: Protocol class(es) owning the ``__proto__`` dispatch registry keyed by\n' + ' #: this enumeration, read by\n' + ' #: :func:`~pcapkit.foundation.registry.protocols.register_protocol_code`\n' + ' #: to resolve a bare member of it into its destination(s). The classes are\n' + ' #: named as lazy :class:`~pcapkit.corekit.module.ModuleDescriptor`\n' + ' #: references rather than imported, because :mod:`pcapkit.const` sits below\n' + ' #: :mod:`pcapkit.protocols` and importing one from here would be circular.\n' + ' __proto_dest__ = (\n' + f'{entries}' + ' )\n' + '\n') + + #: Default constant template of enumeration registry from IANA CSV. -LINE = lambda NAME, DOCS, FLAG, ENUM, MISS, MODL: f'''\ +#: +#: ``DEST`` is the crawler's :attr:`Vendor.PROTO_DEST` and defaults to empty, so +#: a caller that renders an enumeration keying no ``__proto__`` -- every one +#: outside the handful :issue:`2392` names -- passes six arguments as before. +#: +#: NOTE: the type comment says ``Callable[..., str]`` rather than spelling the +#: parameters out, because mypy cannot infer a lambda's type against an explicit +#: ``Callable[[...], ...]`` once the lambda carries a default (measured: one +#: extra ``Cannot infer type of lambda`` error). The parameters are documented in +#: ``docs/source/pcapkit/vendor/default.rst`` instead. +LINE = lambda NAME, DOCS, FLAG, ENUM, MISS, MODL, DEST=(): f'''\ # -*- coding: utf-8 -*- # pylint: disable=line-too-long,consider-using-f-string """{(name := DOCS.split(' [', maxsplit=1)[0])} @@ -89,7 +145,7 @@ from aenum import IntEnum{', extend_enum' if 'extend_enum(' in MISS else ''} from pcapkit.corekit.enum import EnumRegistry -from pcapkit.utilities.exceptions import EnumValueError +{PROTO_DEST_IMPORT if DEST else ''}from pcapkit.utilities.exceptions import EnumValueError if TYPE_CHECKING: from typing import Any @@ -100,7 +156,7 @@ class {NAME}(EnumRegistry, IntEnum): """[{NAME}] {DOCS}""" - {ENUM} +{render_proto_dest(DEST)} {ENUM} @classmethod def _validate_value(cls, value: 'Any') -> 'None': @@ -136,7 +192,7 @@ def _missing_(cls, value: 'int') -> '{NAME}': raise ValueError('%r is not a valid %s' % (value, cls.__name__)) {MISS} {'' if (test := ''.join(MISS.splitlines()[-1:])).startswith('return') or test[8:].startswith('return') else 'return super()._missing_(value)'} -'''.strip() # type: Callable[[str, str, str, str, str, str], str] +'''.strip() # type: Callable[..., str] def get_proxies() -> 'dict[str, str]': @@ -264,6 +320,22 @@ class Vendor(metaclass=VendorMeta): #: Link to registry. LINK: 'str' = None # type: ignore[assignment] + #: Protocol class(es) owning the ``__proto__`` dispatch registry keyed by + #: the generated enumeration, rendered into its class body as + #: ``__proto_dest__`` -- the declaration + #: :func:`~pcapkit.foundation.registry.protocols.register_protocol_code` + #: reads to resolve a bare member of that enumeration into its + #: destination(s), per :issue:`2392`. + #: + #: Only the registries that key a ``__proto__`` declare one, and the + #: template renders nothing for a crawler that leaves this empty, so every + #: other generated module is unaffected. A class is named as a lazy + #: :class:`~pcapkit.corekit.module.ModuleDescriptor` rather than imported, + #: because :mod:`pcapkit.const` sits below :mod:`pcapkit.protocols`. + #: + #: .. versionadded:: 1.5.0 + PROTO_DEST: 'tuple[ModuleDescriptor[Any], ...]' = () + ############### # Processors ############### @@ -477,7 +549,7 @@ def context(self, data: 'list[str]') -> 'str': ENUM = '\n\n '.join(map(lambda s: s.rstrip(), enum)).strip() MISS = '\n '.join(map(lambda s: s.rstrip(), miss)).strip() - return LINE(self.NAME, self.DOCS, self.FLAG, ENUM, MISS, self.__module__) + return LINE(self.NAME, self.DOCS, self.FLAG, ENUM, MISS, self.__module__, self.PROTO_DEST) def request(self, text: 'Optional[str]' = None) -> 'list[str]': """Fetch CSV file. diff --git a/pcapkit/vendor/reg/ethertype.py b/pcapkit/vendor/reg/ethertype.py index 62a9b6e309..5468906d6a 100644 --- a/pcapkit/vendor/reg/ethertype.py +++ b/pcapkit/vendor/reg/ethertype.py @@ -15,6 +15,7 @@ import sys from typing import TYPE_CHECKING +from pcapkit.corekit.module import ModuleDescriptor from pcapkit.vendor.default import Vendor if TYPE_CHECKING: @@ -32,6 +33,10 @@ class EtherType(Vendor): #: Link to registry. LINK = 'https://www.iana.org/assignments/ieee-802-numbers/ieee-802-numbers-1.csv' + #: Destination of a bare ``EtherType`` member; see + #: :attr:`~pcapkit.vendor.default.Vendor.PROTO_DEST`. + PROTO_DEST = (ModuleDescriptor('pcapkit.protocols.link.link', 'Link'),) + #: Row labels that the historical Neil Sembower list itself marks as #: carrying no real assignment -- a company holding the block but naming #: nothing (``DEC Unassigned``), or a range the list says is dead/invalid diff --git a/pcapkit/vendor/reg/linktype.py b/pcapkit/vendor/reg/linktype.py index 1705124f20..dd089913fa 100644 --- a/pcapkit/vendor/reg/linktype.py +++ b/pcapkit/vendor/reg/linktype.py @@ -16,6 +16,7 @@ import bs4 +from pcapkit.corekit.module import ModuleDescriptor from pcapkit.vendor.default import Vendor if TYPE_CHECKING: @@ -34,6 +35,16 @@ class LinkType(Vendor): #: Link to registry. LINK = 'http://www.tcpdump.org/linktypes.html' + #: Destinations of a bare ``LinkType`` member; see + #: :attr:`~pcapkit.vendor.default.Vendor.PROTO_DEST`. Two of them, because a + #: link-layer header type keys the next-layer registry of both capture file + #: formats, and :func:`~pcapkit.foundation.registry.protocols.register_linktype` + #: already registers into both by hand. + PROTO_DEST = ( + ModuleDescriptor('pcapkit.protocols.misc.pcap.frame', 'Frame'), + ModuleDescriptor('pcapkit.protocols.misc.pcapng', 'PCAPNG'), + ) + def count(self, data: 'list[str]') -> 'Counter[str]': """Count field records.""" return collections.Counter() diff --git a/pcapkit/vendor/reg/transtype.py b/pcapkit/vendor/reg/transtype.py index 514504c34e..d5ed03adea 100644 --- a/pcapkit/vendor/reg/transtype.py +++ b/pcapkit/vendor/reg/transtype.py @@ -15,6 +15,7 @@ import sys from typing import TYPE_CHECKING +from pcapkit.corekit.module import ModuleDescriptor from pcapkit.vendor.default import Vendor if TYPE_CHECKING: @@ -31,6 +32,10 @@ class TransType(Vendor): #: Link to registry. LINK = 'https://www.iana.org/assignments/protocol-numbers/protocol-numbers-1.csv' + #: Destination of a bare ``TransType`` member; see + #: :attr:`~pcapkit.vendor.default.Vendor.PROTO_DEST`. + PROTO_DEST = (ModuleDescriptor('pcapkit.protocols.internet.internet', 'Internet'),) + def count(self, data: 'list[str]') -> 'Counter[str]': """Count field records. diff --git a/pcapkit/vendor/sctp/payload_protocol_identifier.py b/pcapkit/vendor/sctp/payload_protocol_identifier.py index bc1a74c38b..c755c24996 100644 --- a/pcapkit/vendor/sctp/payload_protocol_identifier.py +++ b/pcapkit/vendor/sctp/payload_protocol_identifier.py @@ -11,6 +11,7 @@ import sys +from pcapkit.corekit.module import ModuleDescriptor from pcapkit.vendor.default import Vendor __all__ = ['PayloadProtocolIdentifier'] @@ -24,6 +25,10 @@ class PayloadProtocolIdentifier(Vendor): #: Link to registry. LINK = 'https://www.iana.org/assignments/sctp-parameters/sctp-parameters-25.csv' + #: Destination of a bare ``PayloadProtocolIdentifier`` member; see + #: :attr:`~pcapkit.vendor.default.Vendor.PROTO_DEST`. + PROTO_DEST = (ModuleDescriptor('pcapkit.protocols.transport.sctp', 'SCTP'),) + if __name__ == '__main__': sys.exit(PayloadProtocolIdentifier()) # type: ignore[arg-type] diff --git a/tests/protocols/test_dispatch_reachability_unit.py b/tests/protocols/test_dispatch_reachability_unit.py index 3c77ba9f73..b140c5cdb0 100644 --- a/tests/protocols/test_dispatch_reachability_unit.py +++ b/tests/protocols/test_dispatch_reachability_unit.py @@ -14,11 +14,10 @@ :meth:`~pcapkit.protocols.protocol.Protocol.__index__` returns an enum member is *claiming* to be reachable under that code, so the claim is checked. -Which registry a code belongs in is not restated here. It is read from -:data:`~pcapkit.foundation.registry.protocols._CODE_DESTINATIONS`, the enum -type -> destination table that backs ``code=`` (#570), so the audit and the -registration mechanism cannot drift apart: an enum type added to that table -gets audited here the same day. +Which registry a code belongs in is not restated here. It is read off the enum +type's own ``__proto_dest__``, the declaration that backs ``code=`` (#570, +#2392), so the audit and the registration mechanism cannot drift apart: an enum +type that declares one gets audited here the same day. Nothing is parsed and no capture under :file:`examples/captures/` is read, so this belongs to the unit tier. @@ -88,7 +87,6 @@ def audit() -> 'tuple[list[Gap], int]': """ import pcapkit.protocols as protopkg - from pcapkit.foundation.registry.protocols import _CODE_DESTINATIONS from pcapkit.protocols.protocol import ProtocolBase # Import every protocol module, or ``__subclasses__`` sees only whatever @@ -108,11 +106,11 @@ def audit() -> 'tuple[list[Gap], int]': if code is None: continue # an abstract base, reached by nothing - destinations = _CODE_DESTINATIONS.get(type(code)) - if destinations is None: + descriptors = getattr(type(code), '__proto_dest__', None) + if not descriptors: continue # no registry is keyed by this enum type - for destination in destinations: + for destination in (descriptor.klass for descriptor in descriptors): entry = destination.__proto__.get(code) module, name = _resolve(entry) if entry is not None else (None, None) diff --git a/tests/protocols/test_protocol_code_registration_unit.py b/tests/protocols/test_protocol_code_registration_unit.py index cc757f6904..96c7f97ee1 100644 --- a/tests/protocols/test_protocol_code_registration_unit.py +++ b/tests/protocols/test_protocol_code_registration_unit.py @@ -148,7 +148,7 @@ class Collides(Internet, name='collides-with-ABCMeta-on-3.10'): @unittest.skipUnless(HAS_RUNTIME, 'runtime dependencies not installed') class RegisterProtocolCodeInferenceTests(unittest.TestCase): - """Unit tests for ``register_protocol_code``/its enum-type inference table. + """Unit tests for ``register_protocol_code``/the ``__proto_dest__`` it reads. Every destination class's ``register`` classmethod is mocked, so none of these touch a real, process-wide ``__proto__`` table. @@ -306,7 +306,7 @@ class FakeProtocol: register.assert_called_once_with(TransType.IGMP, FakeProtocol) def test_unknown_enum_type_refuses_rather_than_guessing(self) -> None: - """An enum type absent from the inference table raises, not a silent no-op. + """An enum type declaring no ``__proto_dest__`` raises, not a silent no-op. ``AppType`` is deliberately not a live ``__proto__`` key type -- ``register_apptype`` converts it to a port ``int`` before storing -- @@ -677,5 +677,183 @@ def test_a_rejection_names_the_destination_registry(self) -> None: register_protocol_code(Raw, {destination: key}) +@unittest.skipUnless(HAS_RUNTIME, 'runtime dependencies not installed') +class ProtoDestDeclarationTests(unittest.TestCase): + """``__proto_dest__``, the declaration #2392 replaced the lookup table with. + + Inference used to read a private lookup table in + :mod:`pcapkit.foundation.registry.protocols`, keyed by enum type. #2392 + moves it onto the enumeration registry classes, mirroring what the same + issue did in the other direction with ``__proto_key__``. These pin the + declarations themselves; the ``register_protocol_code`` behaviour they drive + is pinned by :class:`RegisterProtocolCodeInferenceTests` above, which is + unchanged by the move and is the point. + """ + + def setUp(self) -> None: + reimport_once_per_class(self) + + #: ``enum registry -> the destination class(es) it names``, as dotted paths + #: so the expectation does not import what it is checking. + EXPECTED = ( + ('pcapkit.const.reg.ethertype', 'EtherType', + (('pcapkit.protocols.link.link', 'Link'),)), + ('pcapkit.const.reg.transtype', 'TransType', + (('pcapkit.protocols.internet.internet', 'Internet'),)), + ('pcapkit.const.sctp.payload_protocol_identifier', 'PayloadProtocolIdentifier', + (('pcapkit.protocols.transport.sctp', 'SCTP'),)), + ('pcapkit.const.reg.linktype', 'LinkType', + (('pcapkit.protocols.misc.pcap.frame', 'Frame'), + ('pcapkit.protocols.misc.pcapng', 'PCAPNG'))), + ) + + def test_each_registry_declares_the_destinations_it_keys(self) -> None: + """Resolved, and in order -- ``LinkType`` still fans out to both formats. + + A declaration is read off the class's own :func:`vars`, since an + enumeration that inherited one would be declaring nothing. + """ + import importlib + + for module, name, expected in self.EXPECTED: + with self.subTest(registry=name): + klass = getattr(importlib.import_module(module), name) + declared = vars(klass).get('__proto_dest__') + self.assertIsNotNone(declared, f'{name} declares no __proto_dest__') + self.assertEqual( + [(entry.klass.__module__, entry.klass.__name__) for entry in declared], + [tuple(pair) for pair in expected]) + + def test_the_declarations_name_classes_lazily_rather_than_importing_them(self) -> None: + """:mod:`pcapkit.const` must not import :mod:`pcapkit.protocols`. + + That is why #2392 declares a + :class:`~pcapkit.corekit.module.ModuleDescriptor` rather than the class: + ``const`` sits strictly below ``protocols``, so holding a real class + reference would make the import circular. Pinned on the generated source + as well as on the value, since a regeneration is what would reintroduce + it. + """ + import importlib + import pathlib + + from pcapkit.corekit.module import ModuleDescriptor + + for module, name, _ in self.EXPECTED: + with self.subTest(registry=name): + imported = importlib.import_module(module) + klass = getattr(imported, name) + for entry in vars(klass)['__proto_dest__']: + self.assertIsInstance(entry, ModuleDescriptor) + self.assertNotIsInstance(entry, type) + + source = pathlib.Path( + imported.__file__ # type: ignore[arg-type] + ).read_text(encoding='utf-8') + self.assertNotIn('from pcapkit.protocols', source) + self.assertNotIn('import pcapkit.protocols', source) + + def test_only_these_registries_declare_a_destination(self) -> None: + """Every other generated enumeration is untouched by the template change. + + The template renders nothing for a crawler that declares no + ``PROTO_DEST``, so a declaration appearing anywhere else means either a + crawler gained one by accident or a generated file was hand-edited -- + and ``code=`` would then infer a destination for an enumeration that + keys no ``__proto__``. + """ + import importlib + import pkgutil + + import pcapkit.const as constpkg + from pcapkit.corekit.enum import EnumRegistry + + declaring = set() + for info in pkgutil.walk_packages(constpkg.__path__, prefix='pcapkit.const.'): + module = importlib.import_module(info.name) + for attr in vars(module).values(): + if (isinstance(attr, type) and issubclass(attr, EnumRegistry) + and '__proto_dest__' in vars(attr)): + declaring.add(f'{attr.__module__}.{attr.__name__}') + + self.assertEqual(declaring, + {f'{module}.{name}' for module, name, _ in self.EXPECTED}) + + def test_an_enum_declaring_none_refuses_with_the_same_error(self) -> None: + """Both shapes of "no declaration": absent, and present but empty. + + The real case is the absent one -- ``AppType``, covered by + :meth:`RegisterProtocolCodeInferenceTests.\ +test_unknown_enum_type_refuses_rather_than_guessing`. The empty one is the + template's own default, which a crawler could reach by declaring an + empty ``PROTO_DEST``, so it must refuse rather than register nothing and + report success. + """ + from aenum import IntEnum + + from pcapkit.corekit.enum import EnumRegistry + from pcapkit.foundation.registry.protocols import register_protocol_code + from pcapkit.utilities.exceptions import RegistryError + + class Undeclared(EnumRegistry, IntEnum): + """[Undeclared] An enumeration naming no destination.""" + + SPAM = 1 + + class DeclaredEmpty(EnumRegistry, IntEnum): + """[DeclaredEmpty] An enumeration naming an empty set of destinations.""" + + __proto_dest__ = () + + SPAM = 1 + + class FakeProtocol: + pass + + for klass in (Undeclared, DeclaredEmpty): + with self.subTest(registry=klass.__name__): + with self.assertRaises(RegistryError) as caught: + register_protocol_code(FakeProtocol, klass.SPAM) + self.assertIn('no destination registry is known', str(caught.exception)) + self.assertIn(klass.__name__, str(caught.exception)) + + def test_an_unresolvable_declaration_fails_loudly(self) -> None: + """A bad path raises rather than registering nothing and reporting success. + + The real declarations are hand-written, so this is defensive -- but + the failure it guards against is the quiet kind: a typo'd class name that + merely skipped its registration would leave the protocol unreachable with + nothing said, which is #548's shape exactly. + """ + from aenum import IntEnum + + from pcapkit.corekit.enum import EnumRegistry + from pcapkit.corekit.module import ModuleDescriptor + from pcapkit.foundation.registry.protocols import register_protocol_code + from pcapkit.utilities.exceptions import ProtocolError + + class Mistyped(EnumRegistry, IntEnum): + """[Mistyped] An enumeration naming a class that does not exist.""" + + __proto_dest__ = (ModuleDescriptor('pcapkit.protocols.link.link', 'Linkk'),) + + SPAM = 1 + + class Unimportable(EnumRegistry, IntEnum): + """[Unimportable] An enumeration naming a module that does not exist.""" + + __proto_dest__ = (ModuleDescriptor('pcapkit.protocols.link.no_such_module', 'Link'),) + + SPAM = 1 + + class FakeProtocol: + pass + + with self.assertRaises(ProtocolError): + register_protocol_code(FakeProtocol, Mistyped.SPAM) + with self.assertRaises(ModuleNotFoundError): + register_protocol_code(FakeProtocol, Unimportable.SPAM) + + if __name__ == '__main__': unittest.main() diff --git a/tests/vendor/test_vendor_proto_dest_2392_unit.py b/tests/vendor/test_vendor_proto_dest_2392_unit.py new file mode 100644 index 0000000000..53877a4652 --- /dev/null +++ b/tests/vendor/test_vendor_proto_dest_2392_unit.py @@ -0,0 +1,260 @@ +# -*- coding: utf-8 -*- +"""The ``__proto_dest__`` declaration the default template renders (#2392). + +GitHub issue #2392 moves the enum type -> destination pairing out of a private +lookup table in :mod:`pcapkit.foundation.registry.protocols` and onto the +enumeration registry classes themselves, as a ``__proto_dest__`` dunder -- the +mirror of what the same issue did for the opposite direction with +``__proto_key__``. + +The :file:`pcapkit/const/` modules that carry it are generated, so the +declaration is made in the crawler +(:attr:`~pcapkit.vendor.default.Vendor.PROTO_DEST`) and rendered by +:data:`~pcapkit.vendor.default.LINE`. That is what this file pins, from the +template side: + +* :class:`RenderProtoDestTests` -- :func:`~pcapkit.vendor.default.render_proto_dest` + renders a declaration that is valid, re-importable Python, and renders + *nothing at all* for an empty :attr:`~pcapkit.vendor.default.Vendor.PROTO_DEST`. +* :class:`TemplateProtoDestTests` -- the same through :data:`LINE`: the import + line and the class-body declaration both appear when a crawler declares a + destination, and the rendered module is byte-identical to the pre-#2392 + rendering when it does not. That second assertion is the load-bearing one: + only a handful of registries declare a destination, so a template change that + touched the other generated modules would rewrite most of :mod:`pcapkit.const` + on the next crawl. +* :class:`CrawlerProtoDestTests` -- the declaring crawlers name exactly the + destinations the registries need, and no other crawler declares one. + +Nothing here crawls: :meth:`Vendor.__init__` fetches from IANA and writes the +constant file as a side effect of construction, so the one instance built below +uses ``cls.__new__(cls)``, the same bypass +:file:`tests/vendor/test_vendor_dest_path_unit.py` uses for the same reason. + +""" +from __future__ import annotations + +import importlib +import importlib.util +import inspect +import pkgutil +import unittest + +from tests._support import reimport_once_per_class + +#: Test tier (#1958): the vendor extra is installed on latest alone, the same +#: reasoning as tests/vendor/test_vendor_dest_path_unit.py. +__tier__ = 'LATEST' + +#: Every distribution importing :mod:`pcapkit.vendor` needs; see +#: :file:`tests/vendor/test_vendor_dest_path_unit.py`. +VENDOR_DEPS = ('requests', 'bs4') + +#: Whether the crawlers are importable at all. +HAS_VENDOR_DEPS = all(importlib.util.find_spec(name) is not None for name in VENDOR_DEPS) + +#: ``crawler module -> ((module, class), ...)``, the destinations #2392 settled +#: on. The same pairings the lookup table held, with ``LinkType`` still +#: naming two: a link-layer header type keys the next-layer registry of +#: both capture file formats. +EXPECTED = { + 'pcapkit.vendor.reg.ethertype': (('pcapkit.protocols.link.link', 'Link'),), + 'pcapkit.vendor.reg.transtype': (('pcapkit.protocols.internet.internet', 'Internet'),), + 'pcapkit.vendor.reg.linktype': (('pcapkit.protocols.misc.pcap.frame', 'Frame'), + ('pcapkit.protocols.misc.pcapng', 'PCAPNG')), + 'pcapkit.vendor.sctp.payload_protocol_identifier': (('pcapkit.protocols.transport.sctp', + 'SCTP'),), +} + + +@unittest.skipUnless(HAS_VENDOR_DEPS, f'vendor extra not installed ({", ".join(VENDOR_DEPS)})') +class RenderProtoDestTests(unittest.TestCase): + """:func:`~pcapkit.vendor.default.render_proto_dest` renders re-importable Python.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_an_empty_declaration_renders_nothing(self) -> None: + """Not an empty tuple literal, not a blank line -- the empty string. + + Anything else would appear in every generated module, since only a + handful of the crawlers declare a destination. + """ + from pcapkit.vendor.default import render_proto_dest + + self.assertEqual(render_proto_dest(()), '') + + def test_the_rendered_declaration_executes_and_resolves(self) -> None: + """Rendered as a real class body, ``__proto_dest__`` resolves to the class. + + :func:`exec` rather than a string comparison, because what the template + owes the generated module is *valid Python naming the right class*, not + one particular spelling of it. A declaration rendered as, say, a bare + :class:`tuple` of strings would pass a substring check and fail here. + """ + from pcapkit.corekit.module import ModuleDescriptor + from pcapkit.vendor.default import render_proto_dest + from pcapkit.protocols.misc.pcap.frame import Frame + from pcapkit.protocols.misc.pcapng import PCAPNG + + descriptors = (ModuleDescriptor('pcapkit.protocols.misc.pcap.frame', 'Frame'), + ModuleDescriptor('pcapkit.protocols.misc.pcapng', 'PCAPNG')) + source = ('from pcapkit.corekit.module import ModuleDescriptor\n\n\n' + 'class Rendered:\n' + render_proto_dest(descriptors) + ' pass\n') + + namespace = {} # type: dict + exec(compile(source, '', 'exec'), namespace) # nosec B102 # pylint: disable=exec-used + + self.assertEqual([entry.klass for entry in namespace['Rendered'].__proto_dest__], + [Frame, PCAPNG]) + + def test_the_declaration_is_a_lazy_descriptor_not_an_import(self) -> None: + """The rendered text names no class to import. + + :mod:`pcapkit.const` sits strictly below :mod:`pcapkit.protocols`, so a + generated module that imported :class:`~pcapkit.protocols.link.link.Link` + to declare it would be a circular import. The lazy + :class:`~pcapkit.corekit.module.ModuleDescriptor` is what avoids that, + and is why #2392 settled on it. + """ + from pcapkit.corekit.module import ModuleDescriptor + from pcapkit.vendor.default import render_proto_dest + + rendered = render_proto_dest((ModuleDescriptor('pcapkit.protocols.link.link', 'Link'),)) + + self.assertIn("ModuleDescriptor('pcapkit.protocols.link.link', 'Link'),", rendered) + self.assertNotIn('import Link', rendered) + self.assertNotIn('from pcapkit.protocols', rendered) + + +@unittest.skipUnless(HAS_VENDOR_DEPS, f'vendor extra not installed ({", ".join(VENDOR_DEPS)})') +class TemplateProtoDestTests(unittest.TestCase): + """:data:`~pcapkit.vendor.default.LINE` renders the declaration, and only then.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + @staticmethod + def _render(dest: 'tuple' = ()) -> 'str': + from pcapkit.vendor.default import LINE + + return LINE('Hardware', 'Hardware Type [:rfc:`826`]', + 'isinstance(value, int) and 0 <= value <= 65535', + "#: Ethernet\n Ethernet = 1", 'return None', + 'pcapkit.vendor.arp.hardware', dest) + + def test_no_declaration_leaves_the_rendering_untouched(self) -> None: + """The pre-#2392 output, character for character. + + Pinned against the committed :mod:`pcapkit.const.arp.hardware` rather + than against a copy of the template: ``arp/hardware`` declares no + destination, so its import block and class body must still be what the + template rendered before #2392. + """ + rendered = self._render() + + self.assertNotIn('ModuleDescriptor', rendered) + self.assertNotIn('__proto_dest__', rendered) + self.assertIn('from pcapkit.corekit.enum import EnumRegistry\n' + 'from pcapkit.utilities.exceptions import EnumValueError\n', rendered) + self.assertIn('class Hardware(EnumRegistry, IntEnum):\n' + ' """[Hardware] Hardware Type [:rfc:`826`]"""\n' + '\n' + ' #: Ethernet\n', rendered) + + def test_a_declaration_adds_its_import_and_its_class_attribute(self) -> None: + """Both halves, and the import in its isort position. + + The import matters as much as the attribute: a class body naming + ``ModuleDescriptor`` without it is a generated module that raises + :exc:`NameError` on import. + """ + from pcapkit.corekit.module import ModuleDescriptor + + rendered = self._render((ModuleDescriptor('pcapkit.protocols.link.link', 'Link'),)) + + self.assertIn('from pcapkit.corekit.enum import EnumRegistry\n' + 'from pcapkit.corekit.module import ModuleDescriptor\n' + 'from pcapkit.utilities.exceptions import EnumValueError\n', rendered) + self.assertIn(' __proto_dest__ = (\n' + " ModuleDescriptor('pcapkit.protocols.link.link', 'Link'),\n" + ' )\n' + '\n' + ' #: Ethernet\n', rendered) + + def test_the_rendered_module_imports_and_declares_the_destination(self) -> None: + """The whole rendering executes, and the enumeration carries the dunder. + + An enumeration class body turns a plain assignment into a *member*, so + the name has to be a dunder for ``aenum`` to leave it as an attribute. + This is what pins that: a non-dunder spelling would make the rendered + module declare a member instead, and ``__proto_dest__`` would be absent. + """ + from pcapkit.corekit.module import ModuleDescriptor + from pcapkit.protocols.link.link import Link + + rendered = self._render((ModuleDescriptor('pcapkit.protocols.link.link', 'Link'),)) + + namespace = {} # type: dict + exec(compile(rendered, '', 'exec'), namespace) # nosec B102 # pylint: disable=exec-used + klass = namespace['Hardware'] + + self.assertEqual([entry.name for entry in klass], ['Ethernet']) + self.assertIn('__proto_dest__', vars(klass)) + self.assertEqual([entry.klass for entry in klass.__proto_dest__], [Link]) + + +@unittest.skipUnless(HAS_VENDOR_DEPS, f'vendor extra not installed ({", ".join(VENDOR_DEPS)})') +class CrawlerProtoDestTests(unittest.TestCase): + """Exactly the expected crawlers declare a destination, with the right values.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + @staticmethod + def _crawlers() -> 'dict': + """``module name -> crawler class`` for every crawler under :mod:`pcapkit.vendor`.""" + import pcapkit.vendor as vendorpkg + from pcapkit.vendor.default import Vendor + + found = {} # type: dict + for info in pkgutil.walk_packages(vendorpkg.__path__, prefix='pcapkit.vendor.'): + module = importlib.import_module(info.name) + for _, klass in inspect.getmembers(module, inspect.isclass): + if (issubclass(klass, Vendor) and klass is not Vendor + and klass.__module__ == info.name): + found[info.name] = klass + return found + + def test_the_declaring_crawlers_name_the_destinations_the_registries_need(self) -> None: + crawlers = self._crawlers() + for name, expected in EXPECTED.items(): + with self.subTest(crawler=name): + self.assertIn(name, crawlers) + self.assertEqual(tuple(tuple(entry) for entry in crawlers[name].PROTO_DEST), + expected) + + def test_no_other_crawler_declares_one(self) -> None: + """The rest inherit the empty default, so their output is unchanged. + + Not a style point: a crawler that gained a destination by accident would + add an import and an attribute to its generated module on the next + crawl, and -- worse -- would make ``code=`` infer a destination for an + enumeration that keys no ``__proto__`` at all. + """ + declaring = {name for name, klass in self._crawlers().items() if klass.PROTO_DEST} + + self.assertEqual(declaring, set(EXPECTED)) + + def test_every_declared_destination_resolves_to_a_protocol_class(self) -> None: + """A path that cannot be imported is a defect, and these are hand-written.""" + from pcapkit.protocols.protocol import ProtocolBase + + for name, klass in self._crawlers().items(): + for descriptor in klass.PROTO_DEST: + with self.subTest(crawler=name, destination=descriptor.name): + self.assertTrue(issubclass(descriptor.klass, ProtocolBase)) + + +if __name__ == '__main__': + unittest.main()