From 3f8820adeaf44a7c7a95bc62af5b42acde0b2694 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Mon, 21 Sep 2026 00:37:43 -0400 Subject: [PATCH] feat(protocols): opt-in code= registration with enum-type inference for Protocol/ProtocolBase (#514) * add a `code=` class keyword to `ProtocolBase`/`Protocol.__init_subclass__` that registers a subclass into the next-layer dispatch registry (or registries) it names -- `Link.__proto__`, `Internet.__proto__`, `TCP.__proto__`, `UDP.__proto__`, `SCTP.__proto__`, `Frame.__proto__` and `PCAPNG.__proto__`. Omitting `code` leaves the class unregistered, exactly as before this keyword existed: the built-in tables are still populated by literal assignment in each layer module, not by this hook, so nothing the library ships moves. * add `register_protocol_code` in `foundation/registry/protocols.py`, backed by a small enum-type -> destination table: `EtherType` infers `Link`, `TransType` infers `Internet`, `PayloadProtocolIdentifier` infers `SCTP`, and `LinkType` infers *both* `Frame` and `PCAPNG` -- mirroring the fan-out `register_linktype` already does by hand. A raw `int` (e.g. a TCP/UDP port) has no inferable type and must name its destination explicitly via a `{destination: key}` mapping; either form may appear in an iterable to register one class into several registries from a single declaration. The explicit form is accepted even for a key whose type could be inferred. Inference refuses rather than guesses: an enum type absent from the table raises `RegistryError` instead of silently doing nothing or picking an arbitrary registry. * reject unrecognised class keywords with `UnsupportedCall`, matching the guard #547 added to `Engine`/`Reassembly`/`TraceFlow`/`Dumper`. * mirror the new name into the `__all__` lists of `pcapkit.foundation.registry`, `pcapkit.foundation` and `pcapkit.all`, which each re-declare every registry function by hand. * document the keyword in `docs/source/ext.rst`'s "New Protocol" example and in `docs/source/changelog/1.5.0.rst` (regenerating `CHANGELOG.md`). Verified backward compatible by measurement: `Protocol`/`ProtocolBase` descendant counts (0/43) and the byte contents of all seven `__proto__` tables are unchanged before and after, since no built-in class passes `code=`. New unit tests in `tests/protocols/test_protocol_code_registration_unit.py` (19 cases) proven to fail without this change (18 of 19 fail on the pre-fix tree, the remaining one being a before/after invariant that must hold both ways) and pass with it. Full unit tier: 1279 passed, 17 skipped, 2850 subtests, exit 0. mypy unchanged at its 4 pre-existing errors. --- CHANGELOG.md | 1 + docs/source/changelog/1.5.0.rst | 30 ++ docs/source/ext.rst | 29 ++ pcapkit/all.py | 1 + pcapkit/foundation/__init__.py | 1 + pcapkit/foundation/registry/__init__.py | 1 + pcapkit/foundation/registry/protocols.py | 133 +++++- pcapkit/protocols/protocol.py | 86 +++- .../test_protocol_code_registration_unit.py | 400 ++++++++++++++++++ 9 files changed, 676 insertions(+), 6 deletions(-) create mode 100644 tests/protocols/test_protocol_code_registration_unit.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ccc99bac9..720fe902b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The largest release since 1.0, and the first recorded here as it happened rather - **Changed** -- `Probe`, `CipherSuite` and `IntegritySuite` are `Info` subclasses rather than `typing.NamedTuple`, and no `NamedTuple` remains in the package. They are Mappings now, so `len()` and iteration yield field names rather than values. - **Changed** -- renames with no compatibility alias left behind: `HoleDiscriptor` is spelled `HoleDescriptor` and its package alias `TCP_HoleDiscriptor` is `TCP_HoleDescriptor` (#350); PCAP-NG `Option` subclasses spell the namespace class keyword `ns=` instead of `namespace=` (#439); and `examples/sample` and `examples/samples` -- one letter apart, holding different things -- are now `examples/captures` and `examples/generators`. - **Changed** -- subclass registration is **opt-in** for `Engine`, `Reassembly`, `TraceFlow` and `dumpkit`'s `Dumper` (#514). Each registers if and only if its registry keyword is given -- `engine=` for `Engine`, `protocol=` for `Reassembly` and `TraceFlow`, `fmt=` for `Dumper`. Previously an absent keyword fell back to the class' own name, so *every* subclass of the public class was registered, and declining meant subclassing the parallel `*Base` class under an alias -- which is what every built-in does, and why the public classes had **0** subclasses between them against the `*Base` classes' 9, 5, 2 and 3. **This breaks out-of-tree code that subclasses one of the four and relies on the derived key**; pass the keyword, or call the matching `register_*` function. Nothing the library ships is affected, and the `*Base` classes remain importable. Two things that were silent are now loud: an unrecognised class keyword raises `UnsupportedCall` instead of being swallowed by `**kwargs` -- which used to register the class under its own name, so passing `name=` to a `Reassembly` subclass silently ignored the key it was given, `protocol=` being the real one -- and `Dumper`'s `ext=` without `fmt=` likewise. A class attribute is not an opt-in: `__engine_name__` and `__protocol_name__` still set the name a class reports, registered or not. Each metaclass also gained a class-level `registry` property mirroring `EnumSchema.registry`. As a side effect a `Dumper` subclass no longer touches the filesystem while its `class` statement runs: inferring `fmt` from the `kind` property meant instantiating the class against a `NamedTemporaryFile` mid-definition. `Engine`'s keyword is `engine=` rather than the `name=` this first shipped with, because `name` cannot be passed as a class keyword at all on Python 3.10: `mcls`, `name`, `bases` and `namespace` collide with `abc.ABCMeta.__new__`'s own parameters, which are positional-or-keyword before 3.11 and positional-only from 3.11, so a class statement naming any of the four raises `TypeError` from the metaclass before the hook is reached. Those four are the whole of the `ABCMeta.__new__` collision surface, measured on 3.10.21, 3.11.15 and 3.14.7; `engine=`, `protocol=` and `fmt=` are all outside it, so the documented registration path works on every supported version. There is no `name=` alias -- a keyword that worked on some interpreters and not others is the trap being removed, not a compatibility measure. +- **Added** -- `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__` and `PCAPNG.__proto__` -- the same opt-in treatment #514 gave `Engine`, `Reassembly`, `TraceFlow` and `Dumper` above, extended to the one family that needed a registration key invented rather than merely un-guarded. Omitting `code` leaves a subclass unregistered, exactly as before this keyword existed: the built-in dispatch tables are still populated by literal assignment in each layer module, not by `__init_subclass__`, 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`, deterministically, mirroring what `register_linktype` already does by hand -- or a `{destination: key}` mapping, required for a raw `int` such as a TCP/UDP port number, which cannot say by itself which transport it belongs to. Either form may appear in an iterable, so one declaration can register a class into several registries at once, e.g. a `L2TP` subclass reachable both by IP protocol number and by a UDP port. The explicit mapping form is accepted even for a key whose type could be inferred -- being more explicit than required is never an error. Inference refuses rather than guesses: an enum member whose type names no known destination raises `RegistryError` instead of silently doing nothing or picking an arbitrary registry, and an unrecognised class keyword raises `UnsupportedCall`, matching the other four families. Backed by the new `pcapkit.foundation.registry.protocols.register_protocol_code`, which can also be called directly to register a class that declined at class-definition time. This is the mechanism #548 (`TransType.L2TP` registered nowhere) needs and does not yet use -- fixing it is now a one-declaration change, left for its own issue rather than folded in here. - **Changed** -- extraction is around 46% faster on a 1,117-frame HTTP capture, with byte-identical output (#420). A reassembled datagram's payload is now analysed on first read rather than eagerly, which cuts IP reassembly's own cost by 90.7% and TCP's by 23.7% -- IP reassembly submits a datagram for every frame, fragmented or not (#424). Flow tracing over the same capture went from 1416.6 ms to 744.0 ms, because the flow dumper had been handing each record to a `Frame` constructor that re-dissected the whole protocol stack to return bytes it had just been given; options are no longer parsed twice either (#427). All output compared byte-for-byte across the sample captures in each case. - **Fixed** -- next-layer, option, chunk, block and parameter dispatch all read `defaultdict` registries, so a lookup miss inserted the key into class-level state shared by every later instance, after which a legitimate `register_*` call warned that the code was already registered. Every read now goes through a lookup that does not grow the table, and `IPv4.__option__` and `HIP.__parameter__` became inspectable class attributes rather than names assembled at call time (#426, #428, #429, #434). One break comes with it: a tuple-registered handler pair written to the documented `OptionParser`/`OptionConstructor` signature now works where it could previously never be called at all, and a pair written with an explicit leading `self` -- the only shape that used to work -- now does not. - **Fixed** -- the identical defect one layer up, in the schema layer's own `EnumSchema.registry`: `Option.registry[code]` for an unregistered `code` inserted the default schema under that code, so a single lookup made an unassigned TCP option number, e.g. `156`, read back as registered for the rest of the process. `EnumSchema.__enum__` is now built (or, when a subclass seeds it manually in its own class body -- `PCAPNG.Option`'s namespaced mapping, `TCP.MPTCP`'s plain one) as a retention-safe mapping that still returns the registered default on a miss, it just stops recording it; `.registry` keeps returning the same object it always did, so nothing that held a reference to it is affected (#555). diff --git a/docs/source/changelog/1.5.0.rst b/docs/source/changelog/1.5.0.rst index 4e230831a..6927c709b 100644 --- a/docs/source/changelog/1.5.0.rst +++ b/docs/source/changelog/1.5.0.rst @@ -171,6 +171,36 @@ pull requests between #326 and #509. so the documented registration path works on every supported version. There is no ``name=`` alias -- a keyword that worked on some interpreters and not others is the trap being removed, not a compatibility measure. +* **Added** -- ``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__`` and ``PCAPNG.__proto__`` -- the same + opt-in treatment #514 gave ``Engine``, ``Reassembly``, ``TraceFlow`` and + ``Dumper`` above, extended to the one family that needed a registration key + invented rather than merely un-guarded. Omitting ``code`` leaves a subclass + unregistered, exactly as before this keyword existed: the built-in dispatch + tables are still populated by literal assignment in each layer module, not by + ``__init_subclass__``, 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``, + deterministically, mirroring what ``register_linktype`` already does by + hand -- or a ``{destination: key}`` mapping, required for a raw ``int`` such + as a TCP/UDP port number, which cannot say by itself which transport it + belongs to. Either form may appear in an iterable, so one declaration can + register a class into several registries at once, e.g. a ``L2TP`` subclass + reachable both by IP protocol number and by a UDP port. The explicit + mapping form is accepted even for a key whose type could be inferred -- + being more explicit than required is never an error. Inference refuses + rather than guesses: an enum member whose type names no known destination + raises ``RegistryError`` instead of silently doing nothing or picking an + arbitrary registry, and an unrecognised class keyword raises + ``UnsupportedCall``, matching the other four families. Backed by the new + ``pcapkit.foundation.registry.protocols.register_protocol_code``, which can + also be called directly to register a class that declined at class-definition + time. This is the mechanism #548 (``TransType.L2TP`` registered nowhere) + needs and does not yet use -- fixing it is now a one-declaration change, left + for its own issue rather than folded in here. * **Changed** -- extraction is around 46% faster on a 1,117-frame HTTP capture, with byte-identical output (#420). A reassembled datagram's payload is now analysed on first read rather than eagerly, which cuts IP reassembly's own diff --git a/docs/source/ext.rst b/docs/source/ext.rst index 3af78adf4..d12d27cf0 100644 --- a/docs/source/ext.rst +++ b/docs/source/ext.rst @@ -227,6 +227,35 @@ The following code snippet shows how to create a new protocol class: # register protocol class register_ethertype(EtherType.Internet_Protocol_version_4, MyIPv4) +.. note:: + + Registering after the fact, as above, is one option. The other is passing + ``code=`` at class definition, which is **opt-in** -- a class that omits it + stays unregistered, exactly as if no ``register_*`` call had been made + either: + + .. code-block:: python + + class MyIPv4(Internet[IPv4Data, IPv4Schema], + schema=IPv4Schema, data=IPv4Data, + code=EtherType.Internet_Protocol_version_4): + ... + + The key's own type decides which registry it goes to -- an + :class:`~pcapkit.const.reg.ethertype.EtherType` member always means + :class:`~pcapkit.protocols.link.link.Link`, a + :class:`~pcapkit.const.reg.transtype.TransType` member always means + :class:`~pcapkit.protocols.internet.internet.Internet`, and so on -- so a + raw :class:`int`, such as a TCP or UDP port, cannot say which registry it + belongs to and must be given explicitly: ``code={TCP: 21}``. Either form + may appear in an iterable to register one class into several registries + at once. See + :meth:`ProtocolBase.__init_subclass__ + ` for the full + rule, and + :func:`~pcapkit.foundation.registry.protocols.register_protocol_code` for + what runs underneath it. + Extending Existing Protocol --------------------------- diff --git a/pcapkit/all.py b/pcapkit/all.py index 6f86e9eaf..ea38b61bf 100644 --- a/pcapkit/all.py +++ b/pcapkit/all.py @@ -80,6 +80,7 @@ # pcapkit.foundation.registry 'register_protocol', + 'register_protocol_code', 'register_linktype', 'register_pcap', 'register_pcapng', 'register_ethertype', diff --git a/pcapkit/foundation/__init__.py b/pcapkit/foundation/__init__.py index 3c3433681..b60523b63 100644 --- a/pcapkit/foundation/__init__.py +++ b/pcapkit/foundation/__init__.py @@ -27,6 +27,7 @@ 'TraceFlowManager', 'register_protocol', + 'register_protocol_code', 'register_linktype', 'register_pcap', 'register_pcapng', 'register_ethertype', diff --git a/pcapkit/foundation/registry/__init__.py b/pcapkit/foundation/registry/__init__.py index e25dd6c38..55157a3ee 100644 --- a/pcapkit/foundation/registry/__init__.py +++ b/pcapkit/foundation/registry/__init__.py @@ -25,6 +25,7 @@ 'register_extractor_reassembly', 'register_extractor_traceflow', 'register_protocol', + 'register_protocol_code', 'register_linktype', 'register_pcap', 'register_pcapng', diff --git a/pcapkit/foundation/registry/protocols.py b/pcapkit/foundation/registry/protocols.py index 53f4b1659..f47361f16 100644 --- a/pcapkit/foundation/registry/protocols.py +++ b/pcapkit/foundation/registry/protocols.py @@ -7,10 +7,19 @@ This module provides the protocol registries for :mod:`pcapkit`. """ +import collections.abc +import enum from typing import TYPE_CHECKING, cast, overload +import aenum + from pcapkit.const.reg.apptype import AppType as Enum_AppType from pcapkit.const.reg.apptype import TransportProtocol +from pcapkit.const.reg.ethertype import EtherType as Enum_EtherType +from pcapkit.const.reg.linktype import LinkType as Enum_LinkType +from pcapkit.const.reg.transtype import TransType as Enum_TransType +from pcapkit.const.sctp.payload_protocol_identifier import \ + PayloadProtocolIdentifier as Enum_PayloadProtocolIdentifier from pcapkit.corekit.module import ModuleDescriptor from pcapkit.protocols import __proto__ as protocol_registry from pcapkit.protocols.application.httpv2 import HTTP as HTTPv2 @@ -49,7 +58,7 @@ from pcapkit.utilities.logging import get_logger if TYPE_CHECKING: - from typing import Optional, Type + from typing import Any, Iterator, Optional, Type from pcapkit.const.hip.parameter import Parameter as HIP_Parameter from pcapkit.const.http.frame import Frame as HTTP_Frame @@ -104,6 +113,7 @@ __all__ = [ 'register_protocol', + 'register_protocol_code', 'register_linktype', 'register_pcap', 'register_pcapng', @@ -151,6 +161,127 @@ def register_protocol(protocol: 'Type[Protocol]') -> 'None': logger.debug('registered protocol: %s', protocol.__name__) +#: Enum type -> 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 GH-514. This is +#: not an invention: it is exactly the targeting +#: :func:`register_ethertype`, :func:`register_transtype`, +#: :func:`register_linktype` and :func:`register_sctp` already hard-code by +#: hand, moved into one table so :func:`register_protocol_code` can consult +#: it. ``LinkType`` naming two classes is deliberate, not ambiguous -- +#: :func:`register_linktype` already fans out to both. +_CODE_DESTINATIONS: 'dict[type, tuple[Type[Protocol], ...]]' = { + Enum_EtherType: (Link,), + Enum_TransType: (Internet,), + Enum_PayloadProtocolIdentifier: (SCTP,), + Enum_LinkType: (Frame, PCAPNG), +} + + +def _iter_code_targets(code: 'Any') -> 'Iterator[tuple[Type[Protocol], Any]]': + """Flatten a ``code=`` argument into ``(destination, key)`` pairs. + + Args: + code: See :func:`register_protocol_code`. + + Yields: + One ``(destination, key)`` pair per registration the caller asked + for -- possibly several, e.g. a single :class:`~pcapkit.const.reg.\ +linktype.LinkType` member yields both :class:`Frame` and :class:`PCAPNG`. + + Raises: + RegistryError: If a value has no destination it can name or infer; + see :func:`register_protocol_code`. + + """ + if isinstance(code, dict): + yield from code.items() + return + + if isinstance(code, (enum.Enum, aenum.Enum)): + destinations = _CODE_DESTINATIONS.get(type(code)) + if destinations is None: + 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 + return + + if isinstance(code, (str, bytes)): + raise RegistryError(f'code must be an enum member, a {{destination: key}} mapping, or an ' + f'iterable thereof, not {code!r}') + + if isinstance(code, collections.abc.Iterable): + for item in code: + yield from _iter_code_targets(item) + return + + raise RegistryError(f'raw key {code!r} has no destination registry it can infer; pass an ' + f'explicit destination, e.g. code={{TCP: {code!r}}}') + + +def register_protocol_code(protocol: 'Type[Protocol]', code: 'Any') -> 'None': + r"""Register ``protocol`` into the next-layer dispatch registry (or + registries) named by ``code``. + + This is what backs the ``code`` keyword of + :meth:`ProtocolBase.__init_subclass__ + `; it can also + be called directly to register a class that declined at class-definition + time. + + ``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__`` -- + :class:`~pcapkit.const.reg.ethertype.EtherType` to + :class:`~pcapkit.protocols.link.link.Link`, + :class:`~pcapkit.const.reg.transtype.TransType` to + :class:`~pcapkit.protocols.internet.internet.Internet`, + :class:`~pcapkit.const.sctp.payload_protocol_identifier.PayloadProtocolIdentifier` + to :class:`~pcapkit.protocols.transport.sctp.SCTP`, and + :class:`~pcapkit.const.reg.linktype.LinkType` to *both* + :class:`~pcapkit.protocols.misc.pcap.frame.Frame` **and** + :class:`~pcapkit.protocols.misc.pcapng.PCAPNG`; or + * a :class:`dict` mapping an explicit destination class to a key -- the + only form accepted for a bare :class:`int`, since e.g. a port number + does not say by itself whether it means + :class:`~pcapkit.protocols.transport.tcp.TCP` or + :class:`~pcapkit.protocols.transport.udp.UDP`; + + or an iterable of either, to register ``protocol`` into several + registries from one call -- e.g. a + :class:`~pcapkit.protocols.link.l2tp.L2TP` subclass reachable both by its + IP protocol number and by a UDP port: + + .. code-block:: python + + register_protocol_code(L2TPv2, [TransType.L2TP, {UDP: 1701}]) + + The explicit mapping form is accepted for any key, even one whose type + could be inferred -- being more explicit than required is never an + error. + + Args: + protocol: Protocol class to register. + code: Registration key(s); see above. + + 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 -- + inference refuses rather than guesses. + + """ + for destination, key in _iter_code_targets(code): + destination.register(key, protocol) + logger.debug('registered %s into %s.__proto__: %s', protocol.__name__, + destination.__name__, key) + + ############################################################################### # Top-Level Registries ############################################################################### diff --git a/pcapkit/protocols/protocol.py b/pcapkit/protocols/protocol.py index 34ec9c08c..916d7f229 100644 --- a/pcapkit/protocols/protocol.py +++ b/pcapkit/protocols/protocol.py @@ -678,18 +678,28 @@ def __post_init__(self, file: 'Optional[IO[bytes] | bytes]' = None, self._info = self.unpack(length, **kwargs) def __init_subclass__(cls, /, schema: 'Optional[Type[_ST]]' = None, - data: 'Optional[Type[_PT]]' = None, *args: 'Any', **kwargs: 'Any') -> 'None': + data: 'Optional[Type[_PT]]' = None, + code: 'Any' = None, + *args: 'Any', **kwargs: 'Any') -> 'None': """Initialisation for subclasses. Args: schema: Schema class. data: Data class. + code: Next-layer dispatch registration key(s). :data:`None` (the + default) skips registration entirely -- see below. *args: Arbitrary positional arguments. **kwargs: Arbitrary keyword arguments. + Raises: + UnsupportedCall: If any unrecognised class keyword is given. + 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. + attribute of the subclass, and, if ``code`` is given, to register the + subclass into the next-layer dispatch registry (or registries) that + ``code`` names -- e.g. :attr:`Link.__proto__ + `. Notes: When ``schema`` and/or ``data`` is not specified, the method will first @@ -699,7 +709,57 @@ def __init_subclass__(cls, /, schema: 'Optional[Type[_ST]]' = None, :class:`~pcapkit.protocols.schema.misc.raw.Raw` and :class:`~pcapkit.protocols.data.misc.raw.Raw` classes will be used. + Dispatch registration is **opt-in**, exactly like the ``name=``/``protocol=``/ + ``fmt=`` keywords of :class:`~pcapkit.foundation.engines.engine.Engine`, + :class:`~pcapkit.foundation.reassembly.reassembly.Reassembly`, + :class:`~pcapkit.foundation.traceflow.traceflow.TraceFlow` and + :class:`~pcapkit.dumpkit.common.Dumper`. Omitting ``code`` is a + deliberate, documented way for a subclass to decline registration, not + an oversight -- it is exactly what every built-in protocol class does + today, since the built-in dispatch tables (e.g. ``Link.__proto__``) + are populated by literal assignment in each layer module, not by this + hook, so leaving ``code`` unset here changes nothing about them. A + subclass that declines can still be registered later, on demand, via + the owning class's :meth:`register` classmethod or the matching + ``register_*`` helper in :mod:`pcapkit.foundation.registry.protocols`. + + ``code`` accepts: + + * a single enum member, whose *type* determines the destination + registry (or registries) -- e.g. any + :class:`~pcapkit.const.reg.ethertype.EtherType` member always means + :class:`~pcapkit.protocols.link.link.Link`, and any + :class:`~pcapkit.const.reg.linktype.LinkType` member means *both* + :class:`~pcapkit.protocols.misc.pcap.frame.Frame` **and** + :class:`~pcapkit.protocols.misc.pcapng.PCAPNG`; + * a :class:`dict` mapping a destination class to a key, for a key + that cannot name its own destination -- a raw :class:`int` port, + for instance, is ambiguous between + :class:`~pcapkit.protocols.transport.tcp.TCP` and + :class:`~pcapkit.protocols.transport.udp.UDP`, and *must* use this + form; + * an iterable mixing either of the above, to register the same class + into several registries from a single declaration -- e.g. a + :class:`~pcapkit.protocols.link.l2tp.L2TP` subclass reachable both + by its IP protocol number and by a UDP port. + + The explicit mapping form is accepted even for a key whose type could + be inferred: being more explicit than required is never an error. + + Inference refuses rather than guesses: an enum member whose type + names no known destination raises + :exc:`~pcapkit.utilities.exceptions.RegistryError` instead of + silently doing nothing or picking an arbitrary registry. + + See Also: + :func:`pcapkit.foundation.registry.protocols.register_protocol_code` + implements the resolution described above. + """ + if args or kwargs: + unexpected = ', '.join([*map(repr, args), *sorted(kwargs)]) + raise UnsupportedCall(f'{cls.__name__}: unexpected class keyword(s): {unexpected}') + super().__init_subclass__() if schema is None: @@ -710,6 +770,12 @@ def __init_subclass__(cls, /, schema: 'Optional[Type[_ST]]' = None, cls.__schema__ = schema cls.__data__ = data + if code is not None: + from pcapkit.foundation.registry.protocols import \ + register_protocol_code # pylint: disable=import-outside-toplevel + + register_protocol_code(cls, code) + def __repr__(self) -> 'str': """Returns representation of parsed protocol data. @@ -1410,12 +1476,18 @@ class Protocol(ProtocolBase, Generic[_PT, _ST]): """Abstract base class for all protocol family.""" def __init_subclass__(cls, /, schema: 'Optional[Type[_ST]]' = None, - data: 'Optional[Type[_PT]]' = None, *args: 'Any', **kwargs: 'Any') -> 'None': + data: 'Optional[Type[_PT]]' = None, + code: 'Any' = None, + *args: 'Any', **kwargs: 'Any') -> 'None': """Initialisation for subclasses. Args: schema: Schema class. data: Data class. + code: Next-layer dispatch registration key(s). :data:`None` (the + default) skips registration entirely. See + :meth:`ProtocolBase.__init_subclass__` for the accepted + shapes and the enum-type inference rule. *args: Arbitrary positional arguments. **kwargs: Arbitrary keyword arguments. @@ -1432,7 +1504,11 @@ def __init_subclass__(cls, /, schema: 'Optional[Type[_ST]]' = None, :class:`~pcapkit.protocols.data.misc.raw.Raw` classes will be used. This method also registers the subclass to the protocol registry, - i.e., :attr:`pcapkit.protocols.__proto__`. + i.e., :attr:`pcapkit.protocols.__proto__`. That registration is + unconditional -- it is the name-keyed identity registry, unrelated to + the ``code`` keyword -- whereas ``code``'s next-layer dispatch + registration is opt-in; see + :meth:`ProtocolBase.__init_subclass__` for the latter. See Also: For more information on the registry, please refer to @@ -1442,4 +1518,4 @@ def __init_subclass__(cls, /, schema: 'Optional[Type[_ST]]' = None, from pcapkit.foundation.registry.protocols import register_protocol register_protocol(cls) - return super().__init_subclass__(schema, data, *args, **kwargs) + return super().__init_subclass__(schema, data, code, *args, **kwargs) diff --git a/tests/protocols/test_protocol_code_registration_unit.py b/tests/protocols/test_protocol_code_registration_unit.py new file mode 100644 index 000000000..948e65cdd --- /dev/null +++ b/tests/protocols/test_protocol_code_registration_unit.py @@ -0,0 +1,400 @@ +# -*- coding: utf-8 -*- +"""``code=`` opt-in next-layer dispatch registration. + +GitHub issue #514 part (b): :class:`~pcapkit.protocols.protocol.Protocol`/ +:class:`~pcapkit.protocols.protocol.ProtocolBase` gain a ``code`` class +keyword that registers a subclass into the next-layer dispatch registry (or +registries) it names -- e.g. :attr:`Link.__proto__ +`. Registration is opt-in, +exactly like the ``name=``/``protocol=``/``fmt=`` keywords part (a) (#547) +gave :class:`~pcapkit.foundation.engines.engine.Engine`, +:class:`~pcapkit.foundation.reassembly.reassembly.Reassembly`, +:class:`~pcapkit.foundation.traceflow.traceflow.TraceFlow` and +:class:`~pcapkit.dumpkit.common.Dumper`: omitting ``code`` leaves the +subclass unregistered, which is exactly what every built-in protocol class +does today, since the built-in dispatch tables are populated by literal +assignment in each layer module rather than by ``__init_subclass__``. + +Most tests here mock the destination classes' ``register`` classmethod (or +:func:`~pcapkit.foundation.registry.protocols.register_protocol_code` +itself) rather than mutating the real, process-wide ``__proto__`` registries +-- those are shared, module-level singletons, and a leaked entry would +corrupt :file:`test_dispatch_registry_unit.py`'s exact-count assertions for +the rest of the test session. The handful of tests that do exercise a real +table use :func:`unittest.mock.patch.dict` so the entry is guaranteed gone +again once the ``with`` block exits, even on failure. + +""" +from __future__ import annotations + +import importlib.util +import sys +import unittest +from unittest import mock + +from tests._support import purge_modules + +RUNTIME_DEPS = ('tbtrim', 'aenum', 'chardet', 'dictdumper') +HAS_RUNTIME = all(importlib.util.find_spec(name) is not None for name in RUNTIME_DEPS) + + +@unittest.skipUnless(HAS_RUNTIME, 'runtime dependencies not installed') +class ProtocolCodeOptInTests(unittest.TestCase): + """The opt-in gate itself: ``register_protocol_code`` runs iff ``code`` is given.""" + + def setUp(self) -> None: + purge_modules(['pcapkit']) + + def test_code_omitted_leaves_next_layer_dispatch_unregistered(self) -> None: + """No ``code=`` -> :func:`register_protocol_code` is never called.""" + from pcapkit.protocols.internet.internet import Internet + + with mock.patch('pcapkit.foundation.registry.protocols.register_protocol_code') as register: + class NoCode(Internet): + __layer__ = 'Internet' + + register.assert_not_called() + # ``schema``/``data`` resolution still ran -- opting out of dispatch + # registration does not opt out of anything else. + self.assertTrue(hasattr(NoCode, '__schema__')) + self.assertTrue(hasattr(NoCode, '__data__')) + + def test_code_given_calls_register_protocol_code(self) -> None: + """A bare enum member is forwarded to :func:`register_protocol_code` unchanged.""" + from pcapkit.const.reg.transtype import TransType + from pcapkit.protocols.internet.internet import Internet + + with mock.patch('pcapkit.foundation.registry.protocols.register_protocol_code') as register: + class WithCode(Internet, code=TransType.GRE): + __layer__ = 'Internet' + + register.assert_called_once_with(WithCode, TransType.GRE) + + def test_public_protocol_class_forwards_code_the_same_way(self) -> None: + """Subclassing the public :class:`Protocol` reaches the same hook. + + ``Protocol.__init_subclass__`` also runs ``register_protocol`` (the + name-keyed identity registry), which is unconditional and unrelated + to ``code``; this only asserts the dispatch side. + """ + from pcapkit.const.reg.ethertype import EtherType + from pcapkit.protocols.protocol import Protocol + + with mock.patch('pcapkit.foundation.registry.protocols.register_protocol_code') as register: + class WithCode(Protocol, code=EtherType.XEROX_NS_IDP): + __layer__ = None + + @property + def name(self) -> str: + return 'WithCode' + + @classmethod + def __index__(cls): + return 0 + + register.assert_called_once_with(WithCode, EtherType.XEROX_NS_IDP) + + def test_unrecognised_class_keyword_raises_unsupportedcall(self) -> None: + """A misspelled keyword raises rather than being silently swallowed. + + Before this hook existed, extra keywords fell into ``**kwargs`` and + were dropped by the bare ``super().__init_subclass__()`` -- no + exception, no warning. ``codes=`` (plural) is the natural typo for + ``code=``, and does not collide with :meth:`abc.ABCMeta.__new__`. + """ + from pcapkit.protocols.internet.internet import Internet + from pcapkit.utilities.exceptions import UnsupportedCall + + with mock.patch('pcapkit.foundation.registry.protocols.register_protocol_code') as register: + with self.assertRaises(UnsupportedCall) as caught: + class Typo(Internet, codes=['wrong-keyword']): + __layer__ = 'Internet' + + register.assert_not_called() + self.assertIn('codes', str(caught.exception)) + + def test_name_keyword_collision_is_pinned_per_python_version(self) -> None: + """``name=`` collides with :meth:`abc.ABCMeta.__new__` on 3.10, not 3.11+. + + Mirrors ``test_reassembly_subclass_rejects_unrecognised_keyword`` in + ``tests/foundation/reassembly/test_reassembly_base.py``: ``mcls``, + ``name``, ``bases`` and ``namespace`` are positional-or-keyword on + 3.10 and positional-only from 3.11, so the same misspelled keyword + surfaces a different exception depending on interpreter version. + ``Protocol``/``ProtocolBase`` never gave ``name`` a meaning, so this + is purely about which exception reaches the caller, not a real + keyword clash in the API. + """ + from pcapkit.protocols.internet.internet import Internet + from pcapkit.utilities.exceptions import UnsupportedCall + + expected = UnsupportedCall if sys.version_info >= (3, 11) else TypeError + with mock.patch('pcapkit.foundation.registry.protocols.register_protocol_code') as register: + with self.assertRaises(expected): + class Collides(Internet, name='collides-with-ABCMeta-on-3.10'): + __layer__ = 'Internet' + + register.assert_not_called() + + +@unittest.skipUnless(HAS_RUNTIME, 'runtime dependencies not installed') +class RegisterProtocolCodeInferenceTests(unittest.TestCase): + """Unit tests for ``register_protocol_code``/its enum-type inference table. + + Every destination class's ``register`` classmethod is mocked, so none of + these touch a real, process-wide ``__proto__`` table. + """ + + def setUp(self) -> None: + purge_modules(['pcapkit']) + + def test_bare_ethertype_member_infers_link(self) -> None: + from pcapkit.const.reg.ethertype import EtherType + from pcapkit.foundation.registry.protocols import register_protocol_code + from pcapkit.protocols.link.link import Link + + class FakeProtocol: + pass + + with mock.patch.object(Link, 'register') as register: + register_protocol_code(FakeProtocol, EtherType.XEROX_NS_IDP) + register.assert_called_once_with(EtherType.XEROX_NS_IDP, FakeProtocol) + + def test_bare_transtype_member_infers_internet(self) -> None: + from pcapkit.const.reg.transtype import TransType + from pcapkit.foundation.registry.protocols import register_protocol_code + from pcapkit.protocols.internet.internet import Internet + + class FakeProtocol: + pass + + with mock.patch.object(Internet, 'register') as register: + register_protocol_code(FakeProtocol, TransType.GRE) + register.assert_called_once_with(TransType.GRE, FakeProtocol) + + def test_bare_payload_protocol_identifier_infers_sctp(self) -> None: + from pcapkit.const.sctp.payload_protocol_identifier import \ + PayloadProtocolIdentifier + from pcapkit.foundation.registry.protocols import register_protocol_code + from pcapkit.protocols.transport.sctp import SCTP + + class FakeProtocol: + pass + + with mock.patch.object(SCTP, 'register') as register: + register_protocol_code(FakeProtocol, PayloadProtocolIdentifier.IUA) + register.assert_called_once_with(PayloadProtocolIdentifier.IUA, FakeProtocol) + + def test_bare_linktype_member_infers_both_frame_and_pcapng(self) -> None: + """``LinkType`` names two destinations deterministically, not ambiguously. + + Mirrors what :func:`~pcapkit.foundation.registry.protocols.register_linktype` + already does by hand (calls both ``Frame.register`` and + ``PCAPNG.register`` on the same code). + """ + from pcapkit.const.reg.linktype import LinkType + from pcapkit.foundation.registry.protocols import register_protocol_code + from pcapkit.protocols.misc.pcap.frame import Frame + from pcapkit.protocols.misc.pcapng import PCAPNG + + class FakeProtocol: + pass + + with mock.patch.object(Frame, 'register') as reg_frame, \ + mock.patch.object(PCAPNG, 'register') as reg_pcapng: + register_protocol_code(FakeProtocol, LinkType.RAW) + reg_frame.assert_called_once_with(LinkType.RAW, FakeProtocol) + reg_pcapng.assert_called_once_with(LinkType.RAW, FakeProtocol) + + def test_raw_int_without_explicit_destination_refuses(self) -> None: + """A bare ``int`` cannot say whether it means TCP or UDP -- it must refuse. + + This is the genuine residue the enum-type rule cannot cover: no + ``EtherType``/``TransType``/``LinkType``/``PayloadProtocolIdentifier`` + member exists for ``0x8137`` (IPX) or for a TCP/UDP port, so there is + nothing to infer from. + """ + from pcapkit.foundation.registry.protocols import register_protocol_code + from pcapkit.utilities.exceptions import RegistryError + + class FakeProtocol: + pass + + with self.assertRaises(RegistryError) as caught: + register_protocol_code(FakeProtocol, 54321) + self.assertIn('destination', str(caught.exception)) + + def test_explicit_dict_registers_named_destination(self) -> None: + from pcapkit.foundation.registry.protocols import register_protocol_code + from pcapkit.protocols.transport.tcp import TCP + + class FakeProtocol: + pass + + with mock.patch.object(TCP, 'register') as register: + register_protocol_code(FakeProtocol, {TCP: 54322}) + register.assert_called_once_with(54322, FakeProtocol) + + def test_explicit_dict_spanning_two_destinations(self) -> None: + """One declaration, e.g. an application port meaningful on both transports.""" + from pcapkit.foundation.registry.protocols import register_protocol_code + from pcapkit.protocols.transport.tcp import TCP + from pcapkit.protocols.transport.udp import UDP + + class FakeProtocol: + pass + + with mock.patch.object(TCP, 'register') as reg_tcp, \ + mock.patch.object(UDP, 'register') as reg_udp: + register_protocol_code(FakeProtocol, {TCP: 54323, UDP: 54323}) + reg_tcp.assert_called_once_with(54323, FakeProtocol) + reg_udp.assert_called_once_with(54323, FakeProtocol) + + def test_iterable_mixes_inferred_and_explicit_targets(self) -> None: + """The L2TPv2 shape: one class, an inferred entry and an explicit one. + + ``L2TPv2`` is a ``Link``-layer class reachable both by an IP protocol + number (inferred: ``TransType`` -> ``Internet``) and by a UDP port + (explicit, since a bare port cannot say TCP or UDP) -- see GH-514's + design thread and the adjacent GH-548. + """ + from pcapkit.const.reg.transtype import TransType + from pcapkit.foundation.registry.protocols import register_protocol_code + from pcapkit.protocols.internet.internet import Internet + from pcapkit.protocols.transport.udp import UDP + + class FakeL2TPLike: + pass + + with mock.patch.object(Internet, 'register') as reg_internet, \ + mock.patch.object(UDP, 'register') as reg_udp: + register_protocol_code(FakeL2TPLike, [TransType.L2TP, {UDP: 1701}]) + reg_internet.assert_called_once_with(TransType.L2TP, FakeL2TPLike) + reg_udp.assert_called_once_with(1701, FakeL2TPLike) + + def test_explicit_form_accepted_for_an_inferable_key(self) -> None: + """Q12: ``{Internet: TransType.X}`` is legal, not an error, though inferable. + + Refusing it would punish someone for being clearer than required -- + the design thread's own reasoning for accepting the redundancy. + """ + from pcapkit.const.reg.transtype import TransType + from pcapkit.foundation.registry.protocols import register_protocol_code + from pcapkit.protocols.internet.internet import Internet + + class FakeProtocol: + pass + + with mock.patch.object(Internet, 'register') as register: + register_protocol_code(FakeProtocol, {Internet: TransType.IGMP}) + 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. + + ``AppType`` is deliberately not a live ``__proto__`` key type -- + ``register_apptype`` converts it to a port ``int`` before storing -- + so it must not be treated as inferable. + """ + from pcapkit.const.reg.apptype import AppType + from pcapkit.foundation.registry.protocols import register_protocol_code + from pcapkit.utilities.exceptions import RegistryError + + class FakeProtocol: + pass + + with self.assertRaises(RegistryError) as caught: + register_protocol_code(FakeProtocol, AppType.tcpmux) + self.assertIn('no destination registry is known', str(caught.exception)) + + def test_string_code_refuses_instead_of_iterating_characters(self) -> None: + """A ``str`` is technically ``Iterable``, but iterating it is never intended.""" + from pcapkit.foundation.registry.protocols import register_protocol_code + from pcapkit.utilities.exceptions import RegistryError + + class FakeProtocol: + pass + + with self.assertRaises(RegistryError): + register_protocol_code(FakeProtocol, 'not-a-real-code') + + +@unittest.skipUnless(HAS_RUNTIME, 'runtime dependencies not installed') +class ProtocolCodeEndToEndTests(unittest.TestCase): + """A handful of tests through the real ``class`` statement and real tables. + + ``mock.patch.dict`` snapshots each registry before mutating it and + restores it verbatim afterwards, so these cannot leak a stray entry into + ``test_dispatch_registry_unit.py``'s exact-count assertions even if an + assertion here fails first. + """ + + def setUp(self) -> None: + purge_modules(['pcapkit']) + + def test_end_to_end_class_statement_registers_the_real_table(self) -> None: + from pcapkit.const.reg.transtype import TransType + from pcapkit.protocols.internet.internet import Internet + + with mock.patch.dict(Internet.__proto__, {}, clear=False): + class MyGre(Internet, code=TransType.GRE): + __layer__ = 'Internet' + + self.assertIs(Internet.__proto__[TransType.GRE], MyGre) + self.assertNotIn(TransType.GRE, Internet.__proto__) + + def test_subclass_without_code_does_not_inherit_parent_registration(self) -> None: + """Opting in is per-subclass, not inherited from a registered parent. + + Mirrors part (a)'s ``Parent``/``Derived``/``DerivedOptIn`` measurement + on the issue thread: a subclass of a registered class that itself + passes no ``code=`` must not re-register under its own name -- there + is no name-derived fallback left to do that with, but the property is + worth pinning directly for ``Protocol`` too. + """ + from pcapkit.const.reg.transtype import TransType + from pcapkit.protocols.internet.internet import Internet + + with mock.patch.dict(Internet.__proto__, {}, clear=False): + class Parent(Internet, code=TransType.GRE): + __layer__ = 'Internet' + + self.assertIs(Internet.__proto__[TransType.GRE], Parent) + + class Child(Parent): + __layer__ = 'Internet' + + # ``Child`` passed no ``code=``, so the entry must still be ``Parent``. + self.assertIs(Internet.__proto__[TransType.GRE], Parent) + self.assertNotIn(Child, Internet.__proto__.values()) + self.assertNotIn(TransType.GRE, Internet.__proto__) + + def test_backward_compatible_dispatch_tables_are_unaffected(self) -> None: + """No built-in protocol class passes ``code=``, so nothing shipped moves. + + The built-in dispatch tables (``Link.__proto__``, etc.) are populated + by literal assignment in each layer module; re-importing the package + must not have moved a single entry as a side effect of this hook + existing. + """ + from pcapkit.const.reg.ethertype import EtherType + from pcapkit.const.reg.transtype import TransType + from pcapkit.corekit.module import ModuleDescriptor + from pcapkit.protocols.internet.internet import Internet + from pcapkit.protocols.link.link import Link + + # Built-in entries are ``ModuleDescriptor``s, resolved lazily on first + # dispatch (see ``ProtocolBase._lookup_registry``) -- this asserts the + # table still *names* ``IPv4``, without triggering that resolution. + link_entry = Link.__proto__[EtherType.Internet_Protocol_version_4] + internet_entry = Internet.__proto__[TransType.IPv4] + self.assertIsInstance(link_entry, ModuleDescriptor) + self.assertEqual((link_entry.module, link_entry.name), + ('pcapkit.protocols.internet.ipv4', 'IPv4')) + self.assertIsInstance(internet_entry, ModuleDescriptor) + self.assertEqual((internet_entry.module, internet_entry.name), + ('pcapkit.protocols.internet.ipv4', 'IPv4')) + + +if __name__ == '__main__': + unittest.main()