feat(protocols): opt-in code= registration with enum-type inference for Protocol/ProtocolBase - #570
Conversation
…or 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.
|
✅ GOOD TO MERGE Cross-model review (Opus 5; the PR was authored on Sonnet), head This is the best-evidenced PR of the batch, and I could not break the design. Everything load-bearing I re-derived independently rather than accepting:
On the ownership question you asked about: it is orthogonal, it does not make the fix harder, and this can merge before the ownership work. But one sentence in the Scope section is wrong and should be corrected before merge, because it is what an owner would rely on:
An explicitly-named destination class is arbitrary — it is whatever the user puts in the dict. Measured: Two minor, non-blocking observations in the detailed comment: an empty Green everywhere I ran it: new tests 19 passed (exit 0), the five cited test files 50 passed / 94 subtests (exit 0, matching your numbers exactly), |
Detailed cross-review — #570 @
|
| key type | landed in | expected | |
|---|---|---|---|
EtherType |
['Link'] |
['Link'] |
OK |
TransType |
['Internet'] |
['Internet'] |
OK |
PayloadProtocolIdentifier |
['SCTP'] |
['SCTP'] |
OK |
LinkType |
['Frame', 'PCAPNG'] |
['Frame', 'PCAPNG'] |
OK |
The LinkType fan-out hits both and only both. Nothing leaks into TCP/UDP.
I agree with the framing that LinkType → two classes is determinism, not ambiguity: register_linktype already fans out to both by hand, so the table encodes existing behaviour rather than inventing a policy.
3. Refusal — the property this design exists for
The failure mode worth preventing is silently picking a registry, because that dispatches to the wrong protocol. So I tried to make it fall through six ways. It refuses every time, and — importantly — mutates nothing on the way out:
code= |
outcome | registries touched |
|---|---|---|
8080 (a bare port) |
RegistryError: raw key 8080 has no destination registry it can infer; pass an explicit destination, e.g. code={TCP: 8080} |
False |
an AppType member |
RegistryError: no destination registry is known for enum type 'AppType' (key …) |
False |
'tcp' |
RegistryError: code must be an enum member, a {destination: key} mapping, or an iterable thereof, not 'tcp' |
False |
b'tcp' |
same | False |
1.5 |
RegistryError: raw key 1.5 … |
False |
[None] |
RegistryError: raw key None … |
False |
Two details I want to credit, because both are easy to get wrong:
- The
str/bytesguard sits before the generalIterablebranch, so a string is rejected rather than silently iterated as a sequence of characters. - The enum branch sits before the iterable branch, so an
IntEnum-style member is treated as an enum rather than as an int. AndAppTypebeing absent from the table is correct rather than an omission: its members span both TCP and UDP, so it genuinely cannot infer.
Q12 also holds: code={Internet: <TransType member>} — an explicit destination for an inferable key — is accepted and lands correctly. And an unrecognised keyword raises UnsupportedCall: Probe: unexpected class keyword(s): nonsense.
4. Backward compatibility — the load-bearing safety claim, re-derived
I did not take this one on trust. I dumped, in a canonical sorted form, the Protocol and ProtocolBase transitive descendant counts plus every entry of all seven __proto__ tables (rendered as KeyType:key -> ClassName, with ModuleDescriptor flattened to module.name), with pcapkit.all imported so every registry is fully populated. Run in separate fresh interpreters on main and on this head:
Protocol direct+transitive descendants: 0
ProtocolBase direct+transitive descendants: 43
… 38 registry entries across 7 tables …
SHA256: 8f4ef56585cf395e8409a70d44a2cf02b2b9f2f2deeb1e79a63a7341403e832f
Identical hash on both trees, and diff of the two dumps is empty. So the 0/43 counts and the byte-identical tables are confirmed independently.
5. The Python 3.10 keyword hazard
type(ProtocolBase) is ProtocolMeta, MRO ['ProtocolMeta', 'ABCMeta', 'type', 'object'] — so ABCMeta.__new__ really is in the path and the part (a) hazard is real here too. Measured through real class statements on both interpreters:
| class keyword | Python 3.10.21 | Python 3.14.7 |
|---|---|---|
code= |
accepted, lands in Internet.__proto__ |
accepted, lands correctly |
name= |
TypeError: ABCMeta.__new__() got multiple values for argument 'name' |
UnsupportedCall: … unexpected class keyword(s): name |
mcls= |
TypeError: … 'mcls' |
UnsupportedCall |
bases= |
TypeError: … 'bases' |
UnsupportedCall |
namespace= |
TypeError: … 'namespace' |
UnsupportedCall |
code is not one of the four colliding names, so it is safe — and the test's
expected = UnsupportedCall if sys.version_info >= (3, 11) else TypeErroris exactly right. I verified both branches of that condition on real interpreters rather than only the one this host defaults to. Worth stating plainly why the split exists: on 3.10 the metaclass raises before __init_subclass__ runs at all, so the PR's UnsupportedCall guard cannot catch those four names there, and the test correctly does not pretend otherwise. (The test covers name= only; mcls/bases/namespace behave identically, which I checked — not worth adding.)
6. Fails-without-the-fix
Reverted all five changed source files to main (pcapkit/protocols/protocol.py, pcapkit/foundation/registry/protocols.py, pcapkit/all.py, pcapkit/foundation/__init__.py, pcapkit/foundation/registry/__init__.py), keeping the new tests, then restored from byte-identical copies and proved git status clean and git diff HEAD empty.
18 failed, 1 passed
exit code (from file): 1
and with the fix, 19 passed, exit 0. The single test that passes both ways is test_backward_compatible_dispatch_tables_are_unaffected, which is exactly what the body says it is. 18/19 confirmed.
7. The ownership interaction — your specific question
Answer: orthogonal; not harder; no hasattr trap; can merge first. But the Scope note overstates the separation.
Why it is not harder: register_protocol_code's entire write path is
for destination, key in _iter_code_targets(code):
destination.register(key, protocol)It performs no registry resolution of its own — no hasattr, no getattr('__proto__'), no walking the MRO. It delegates to the existing register classmethods. So when the ownership guard lands in those (tested with '__proto__' in cls.__dict__, not hasattr, per the thread's trap), this mechanism picks it up for free. The diff introduces hasattr only twice, both in tests asserting __schema__/__data__ exist. Nothing here builds the trap in.
Why the Scope note is nevertheless wrong. It says .register() is called on "the fixed inference-table destinations or an explicitly-named destination class, never through an arbitrary subclass's inherited __proto__". The explicitly-named destination is user input, so it can be any class:
'__proto__' in Internet.__dict__ = True # Internet owns its table
'__proto__' in IPv4.__dict__ = False # IPv4 does not
code={IPv4: TransType.ICMP} -> ACCEPTED
landed in Internet.__proto__? True
visible from IPv6.__proto__? True
So the dict form reaches the ownership defect directly, and from a class keyword rather than from an explicit .register() call. Nothing regresses — IPv4.register(...) already did this — but the surface is wider than claimed, which argues for the ownership fix sooner rather than later. And it gives the owner's open design question a second caller: if writing through an inherited table is made to raise, then code={IPv4: …}, legal today, begins raising too. Please correct that sentence; the conclusion ("doesn't affect this mechanism's correctness") is fine for the inference path, just not for the explicit-mapping path.
I did not treat the missing ownership fix as grounds for NEEDS CHANGES. Deferring it is reasonable and the PR flagged it rather than hiding it.
8. Minor, non-blocking
(a) An empty code= is a silent no-op.
code={} -> accepted, registries changed: False
code=[] -> accepted, registries changed: False
code=() -> accepted, registries changed: False
code=set() -> accepted, registries changed: False
None already means "decline registration", so an empty container is far more likely a mistake — a list built programmatically that came out empty, say. It sits oddly next to a design whose stated principle is that inference refuses rather than quietly doing nothing. Raising, or at least a RegistryWarning, would fit the surrounding philosophy better.
(b) isinstance(code, dict) should probably be collections.abc.Mapping. A Mapping that is not a dict falls through to the iterable branch, which iterates its keys — the destination classes — and produces advice that cannot be followed:
RegistryError: raw key <class '...Internet'> has no destination registry it can infer;
pass an explicit destination, e.g. code={TCP: <class '...Internet'>}
OrderedDict works, being a dict subclass. collections.abc is already imported in the module, so this is a one-word change. Low likelihood, confusing when hit.
9. Other checks
tests/protocols/test_protocol_code_registration_unit.py→ exit 0, 19 passed.test_dispatch_registry_unit.py,test_registry_runtime.py,test_protocol_base_unit.py,schema/test_schema_unit.py,schema/test_enum_schema_registry_unit.py→ exit 0, 50 passed, 94 subtests — matching the body's numbers exactly.tests/project/test_public_api.py→ exit 0, 10 passed, 432 subtests. Relevant becauseregister_protocol_codeis added to three__all__lists and this file holdspcapkit.foundation.registryto the stricter "every public attribute is exported" rule.- Full tier
pytest tests(no coverage, per the host memory constraint —coverage run -m pytest testsreached 36.9 GB RSS from a sibling worktree earlier today and exhausted this machine) → exit 0 from a file,1279 passed, 17 skipped, 2850 subtests passed in 1189.89s. Matches the body exactly, and is self-consistent:mainmeasured at 1260 elsewhere in this session, plus 19 new tests. python util/changelog_md.py --check→ exit 0, in step with1.5.0.rst. The entry's content is accurate against every measurement above, including theLinkTypedouble destination and the raw-intrequirement.Protocol.__init_subclass__forwards assuper().__init_subclass__(schema, data, code, *args, **kwargs), which lines up positionally withProtocolBase.__init_subclass__(cls, /, schema=None, data=None, code=None, *args, **kwargs). Correct, and the unrecognised-keyword guard lives in the base so both paths get it.
Could not verify
- CI. Reached deliberately on local evidence only, per my brief; I make no claim about its tally.
- The
#548claim that the fix becomes a one-declarationcode=[TransType.L2TP, {UDP: 1701}]. The shape is consistent with everything I measured about the mixed-iterable form, but I did not actually declare an L2TP subclass that way and drive a capture through it. - Part (c) (the ~76
*Base as <Public>alias imports) and its claimed independence from this PR — out of scope and untested by me. aenumvsenumcoverage. The guard isisinstance(code, (enum.Enum, aenum.Enum)). Every const enum I exercised was caught, but I did not enumerate every enum class inpcapkit.constto confirm none is neither.- Interpreters other than CPython 3.14.7 and 3.10.21. Those two I did measure, which is what the version-pinned test needed.
Summary
Part (b) of #514: the same opt-in treatment PR #547 gave
Engine,Reassembly,TraceFlowandDumper, extended toProtocol/ProtocolBase's next-layer dispatch registries (Link.__proto__,Internet.__proto__,TCP.__proto__,UDP.__proto__,SCTP.__proto__,Frame.__proto__,PCAPNG.__proto__).Protocolwas held back from #547 because giving it a registration keyword meant designing what that keyword should mean -- the enum-type inference settled across the issue thread (Q3/Q11/Q12).ProtocolBase/Protocol.__init_subclass__gain acode=keyword. Opt-in: omitting it leaves the class unregistered, exactly as today -- no built-in protocol class passescode=, so nothing the library ships moves.codeaccepts a bare enum member, whose type infers the destination:EtherType->Link,TransType->Internet,PayloadProtocolIdentifier->SCTP,LinkType-> bothFrameandPCAPNG(mirroring whatregister_linktypealready does by hand). A rawint(e.g. a TCP/UDP port) has no inferable type and must name its destination explicitly via{destination: key}; either form may appear in an iterable to register one class into several registries from one declaration -- e.g. anL2TPsubclass reachable both by IP protocol number and by a UDP port.RegistryError.UnsupportedCall, matching feat(foundation): make subclass registration opt-in for Engine, Reassembly, TraceFlow and Dumper (#514) #547's guard.pcapkit.foundation.registry.protocols.register_protocol_code, mirrored into the__all__ofpcapkit.foundation.registry,pcapkit.foundationandpcapkit.all(each hand-lists every registry function;pcapkit.foundation.registryis one of the three packagestests/project/test_public_api.pyholds to the stricter "every public attribute is exported" check).Verified against the current tree, not just the issue's measurement: re-ran the key-type enumeration on
8cfd6ab01and it still holds exactly --EtherTypeLinkTransTypeInternetPayloadProtocolIdentifierSCTPLinkTypeFrameandPCAPNGintLink,TCP,UDPBackward compatibility, measured rather than assumed:
Protocol/ProtocolBasedescendant counts are 0/43 both before and after (matches the issue's own measurement), and the byte contents of all seven__proto__tables are identical before and after -- dumped, diffed, confirmed unchanged.Scope
Deliberately not included, per the issue thread's own sequencing:
TransType.L2TPregistered nowhere) is now unblocked -- it splits cleanly along this PR's rule:TransType.L2TPinfers intoInternet, while the UDP-1701 encapsulation needs an explicit destination, so the fix becomes a one-declarationcode=[TransType.L2TP, {UDP: 1701}]. Left for its own issue/PR.*Base as <Public>alias imports, is separate follow-up work and does not depend on this PR landing first in any blocking way -- it depends on part (a) (feat(foundation): make subclass registration opt-in for Engine, Reassembly, TraceFlow and Dumper (#514) #547, already merged), not on this one..register()is called through an inherited table) is not implemented here: this PR'sregister_protocol_codeonly ever calls.register()on the fixed inference-table destinations or an explicitly-named destination class, never through an arbitrary subclass's inherited__proto__, so the ownership gap doesn't affect this mechanism's correctness. Flagging it since the issue thread's later comments group it under "part (b)"; happy to add it if wanted, but the design section handed to me for this PR didn't ask for it.Test plan
tests/protocols/test_protocol_code_registration_unit.py(19 cases): opt-in gating, all four inference destinations, theLinkTypedouble fan-out, the raw-intrefusal, the explicit-dict form (single and dual destination), the mixed-iterable (L2TPv2) shape, Q12's explicit-for-inferable acceptance, an unknown-enum-type refusal, unrecognised-keyword rejection (including thename=/ABCMetaversion-pinned case), and two real end-to-end registrations through aclassstatement (viamock.patch.dictso nothing leaks into the shared__proto__tables).tests/protocols/test_dispatch_registry_unit.py,test_registry_runtime.py,test_protocol_base_unit.py,test_schema_unit.py,test_enum_schema_registry_unit.py: all pass unchanged (50 passed, 94 subtests).tests/project/test_public_api.py,test_documentation_claims.py,test_changelog_md.py: all pass (47 passed, 469 subtests) -- confirms the__all__mirroring is complete andCHANGELOG.mdis in step.pytest tests, samples regenerated first viaexamples/generators/make_samples.py): 1279 passed, 17 skipped, 2850 subtests passed, exit 0.