Skip to content

feat(reg)!: retype AppType.proto to its own transport, and give register_apptype varargs (#806) - #815

Merged
JarryShaw merged 1 commit into
mainfrom
fix/806-apptype-single-transport-proto
Sep 26, 2026
Merged

JarryShaw merged 1 commit into
mainfrom
fix/806-apptype-single-transport-proto

Conversation

@JarryShaw

@JarryShaw JarryShaw commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

What is the purpose of your pull request?

  • fix — corrects a defect
  • feat — adds a feature — breaking, feat(reg)!:
  • perf — changes performance, not behaviour
  • refactor — changes neither behaviour nor performance
  • test — tests only
  • docs — documentation only
  • ci — workflows or build tooling
  • chore — anything else

Description of your pull request and other information

Closes #806. Closes #809. Step 1 of #801's three, per the ruling recorded there.

AppType.proto held the whole transport protocol set IANA assigned the service, so TCP['http'].proto named UDP and SCTP as well. That was a second copy of what the four per-transport registries already encode — and the copy that could drift. It is now the single transport of the registry the member lives in. Done in the generated source rather than at runtime: proto.name is folded into the member's underlying str value by __new__, and that value is the live key in _value2member_map_, so 10,625 of 12,391 members change .value, repr() and str().

The two halves are one change because register_apptype relied on the multi-bit value to fan out: it read proto = code.proto and tested each bit with in, so register_apptype(TCP.http, Dummy) displaced the UDP handler for port 80 too. It now takes *transport varargs, named one at a time, with class_ keyword-only — a positional-with-default ahead of the varargs binds the first transport to class_ and silently eats it, which tests/const/test_const_apptype_split_unit.py now pins with an inspect.signature().bind() check. A member with no explicit transport uses its own (now single-bit) proto; a bare int with none is a RegistryError, as is a hand-built composite.

Not regenerated — AppType crawls IANA's live CSV, so a regeneration is not reproducible. The generator and its output were hand-edited, then BASE(...)/TRANSPORT(...) re-rendered from arguments extracted out of the committed const files, reproducing all five byte-identically (and origin/main's five from its own templates, as a control). Also drops the stale consider-using-f-string disable the TRANSPORT template emitted into the four registries, which is #809.

@JarryShaw JarryShaw added enhancement Issues requesting a new capability (set by the feature request template) breaking Breaks public-facing behaviour or API (apply alongside the type label) const Regenerated IANA or vendor constant tables; members keep their numeric values test Pull requests that add or correct tests (test: subject prefix) review: pending No verdict for the current head - never reviewed, or the head moved since the last one labels Sep 25, 2026
@JarryShaw

Copy link
Copy Markdown
Owner Author

Cross-review verdict: GOOD TO GO (sonnet; author was opus). No unprompted findings — an unusually clean change. I re-derived the two decisive claims myself, with a self-test so the zeros mean something:

PROVENANCE: /tmp/w815/pcapkit/__init__.py
members=12391  multi-bit=0  empty=0  proto!=own_registry=0  value-formula-mismatch=0
per-registry: TCP 6147  UDP 6143  SCTP 91  DCCP 10
TCP["http"]: <TCP.http: 80 [tcp]>   value = 'http [80 - tcp]'
SELF-TEST (a deliberately wrong formula must mismatch): 91 of 91

The signature binds correctly and class_ cannot be eaten:

code POSITIONAL_OR_KEYWORD | module POSITIONAL_OR_KEYWORD | transport VAR_POSITIONAL | class_ KEYWORD_ONLY
bind(code, module, TP.udp) -> transport=(udp,)   class_ bound? False
old proto= kwarg          -> TypeError: unexpected keyword argument 'proto'     (removed, not silently accepted)
pickle / deepcopy identity -> True / True

Not regenerated — confirmed by a stronger method than I asked for, and the substitution was flagged. Rather than re-invoking BASE, the reviewer classified every +/- line of the four registry diffs against the three expected patterns (two docstring rewords, the disable drop, and single-line TransportProtocol.x | y → TransportProtocol.<own> flips) and found zero unaccounted lines in any file — 100% coverage rather than a sampled render-and-diff. Member counts identical at 12,391 pre and post, and the vendor generator's split predicate if self.TRANSPORT not in record.protos or record.port == '-1': is byte-unchanged. Classifier self-tested against an injected multi-bit line (flagged) and a correct single-bit line (not flagged). It also independently reproduced multibit_main_count == 10625.

The tests were rewritten, not weakened — each one checked: :80's assertEqual(TCP.http.proto, UDP.http.proto) → assertNotEqual plus two assertIs(..., cls.__transport__) pins; test_every_multi_transport_member_refuses_its_own_proto (which asserted swept == 10625) replaced by test_no_member_carries_a_multi_transport_proto asserting the inverse over the full population and re-deriving the same 23 (port, svc) pairs and the same two divergent rows at 888/999 against the new behaviour; and test_no_lookup_call_site_in_the_library_builds_a_composite_proto now assertNotIns the two old literals, assertIns the new varargs signature, and runs a live inspect.signature/.bind() check of the same shape I ran independently. That last one was the deletion risk and it was rewritten. tests/foundation/registry/test_protocols.py 10/10 under plain unittest.

#809 is genuinely folded in, verified per file:

const/reg/apptype/{tcp,udp,sctp,dccp}.py   disable=0  %-fmt=0
vendor/reg/apptype/apptype.py              disable=2  %-fmt=6   <- earned

The two remaining per-line disables in the generator sit on real % sites (' :widths: %s' % … and another %-join), so they are correct and correctly untouched.

The reviewer caught its own probe artefact: its first byte-identity pass used str(proto) instead of proto.name and produced 12,391 false mismatches — caught by re-deriving the formula rather than trusting the first run. Thirteenth artefact this session and the fifth caught by the agent that made it.

UNVERIFIED: the coverage delta, dropped for budget after the exhaustive member-level checks. The rewritten tests read as a net increase in assertions but that was not measured.

One housekeeping note: the reviewer's worktree could not be returned to its original branch, because fix/800-http-version-positive-identification is checked out in #814's worktree. Its tree is clean and it made no edits; I will clear both worktrees once these merge.

Flipping to review: good-to-go.

@JarryShaw JarryShaw added review: good-to-go Cross-review at the current head says ready; CI state is separate and removed review: pending No verdict for the current head - never reviewed, or the head moved since the last one labels Sep 25, 2026
@JarryShaw

Copy link
Copy Markdown
Owner Author

One thing needs your ruling before this merges, and the author flagged it rather than deciding it. Adding needs: decision; the review: good-to-go stands, since the code correctly implements what you ruled.

register_apptype no longer accepts a transport name, and the failure is not clean. The old form took proto: 'TransportProtocol | str' and coerced at protocols.py:839-840 (if isinstance(proto, str): proto = TransportProtocol.get(proto.lower())); the repo's own test used it at test_protocols.py:493 as proto='tcp'. At this head:

register_apptype(65432, Raw, 'tcp')  ->  AttributeError: 'str' object has no attribute 'name'

Two separable questions:

  1. Should names still be accepted? Your ruling gave the annotation as *transport: TransportProtocol, and the author kept it verbatim — correctly, since mypy.ini sets warn_unreachable = True, so an isinstance(t, str) branch under that annotation is a mypy error. Accepting names again means widening to *transport: 'TransportProtocol | str'. That is a change to your own ruling, so it is yours.
  2. Either way, the AttributeError is a defect. A string reaching this function should raise something a caller can act on — a RegistryError naming the argument — not an AttributeError from deep inside. That holds whether you accept names or reject them, so I would fix it regardless.

My recommendation: reject names, but reject them properly. It matches the "singular and explicit" principle you gave, and TransportProtocol.tcp is no harder to type than 'tcp'. But a caller upgrading from the old kwarg deserves a diagnosis rather than an attribute error.

Also flagged by the author, and worth its own issue rather than holding this PR: _missing_ still mints span members carrying TransportProtocol.undefined (const/reg/apptype/apptype.py:2272, :2296, :2427) rather than the registry's own transport. Pre-existing, so "every member names its own registry" is proven for declared members only. Filing separately.

One correction to my own brief: I told the author tests/const/test_const_apptype_split_unit.py was 863 lines / 19 methods. It is 923 lines / 20 methods on 4530424df — #803 grew it after I measured. The author caught this and re-derived everything itself, which is the right response to a stale number in a brief.

And the author's own honesty note is worth keeping: its first post-change coverage run read 63% with protocols.py dropping 89% → 68%, and it did not reproduce — three isolated runs give 89% / 27 missing either side. It treated the first run as a flake only after failing to reproduce it, rather than instead of trying. It also ran ~75 minutes against a ~25-minute budget and said so, with the tree complete rather than half-applied.

@JarryShaw

Copy link
Copy Markdown
Owner Author

I think we can accept the str value and coerce it into TransportProtocol enumeration. This is a light lift.

@JarryShaw JarryShaw added review: needs-changes Cross-review at the current head says changes are required; see the verdict comment and removed review: good-to-go Cross-review at the current head says ready; CI state is separate needs: decision Waiting on the maintainer to decide — not blocked by other work labels Sep 26, 2026
@JarryShaw

Copy link
Copy Markdown
Owner Author

Ruling recorded, in your words:

I think we can accept the str value and coerce it into TransportProtocol enumeration. This is a light lift.

So *transport accepts str as well as a TransportProtocol member and coerces. Concretely, this PR currently narrows the parameter from the old proto: 'TransportProtocol | str' to *transport: 'TransportProtocol' — dropping str entirely — and that narrowing is what your ruling reverses. The four @overload signatures at pcapkit/foundation/registry/protocols.py:786-793 and the runtime signature at :796 all need 'TransportProtocol | str' back, with coercion at the top of the body and the docstring's "rather than as its name" clause corrected.

needs: decision is cleared and this is review: needs-changes until that lands — the previous good-to-go was against the str-rejecting behaviour, so it no longer describes the PR you want. A worker is on it; class_ stays keyword-only, which is unrelated to the ruling and load-bearing (a positional default ahead of *transport swallows the first transport).

One thing the coercion has to decide, and I will make it strict unless you say otherwise: a name that does not match any member, and a name like "tcp|udp", should raise RegistryError rather than silently resolve — same as the member path already does for a composite, since one call registers under one transport.

@JarryShaw
JarryShaw force-pushed the fix/806-apptype-single-transport-proto branch from 4c035d2 to e683be0 Compare September 26, 2026 01:38
@JarryShaw

Copy link
Copy Markdown
Owner Author

Your ruling is implemented at e683be072 — *transport now takes a TransportProtocol member or its name as a str, coerced before any registry work. Overloads 786-793, runtime signature 796-797, docstring 812-834, coercion at 852-869. Two files: protocols.py and tests/foundation/registry/test_protocols.py.

The author found a real trap, and I reproduced it rather than take its word. The obvious coercion, TransportProtocol[name], would have silently defeated the composite refusal — aenum's Flag.__getitem__ parses a '|'-joined name into a composite:

TransportProtocol['tcp|udp']        -> <TransportProtocol.tcp|udp: 3>     # silent
TransportProtocol.__members__['tcp|udp'] -> KeyError                      # what it uses

So it goes through __members__ directly, case-sensitively. An unknown name and a composite both raise RegistryError('unknown transport protocol: …'), matching the wording the member path already uses — a str and its member are indistinguishable to a caller, including in failure.

It also correctly declined to reuse the existing TransportProtocol.get(), and I confirmed why: get() mints an unknown name rather than raising —

TransportProtocol.get('bogus2') -> <TransportProtocol.bogus2: 16>   # members 5 -> 6

which is exactly the behaviour #775 exists to remove, and the opposite of a strict coercion.

Tests: 6 new blocks (single str, several str, str+member mix, unknown name, composite, and a str-vs-member equivalence check on mock.call_args), each shown failing on 4c035d2c4 with AttributeError: 'str' object has no attribute 'name' and passing after. tests/foundation regression: 193 tests, 11 skipped, all passing. Coverage held at 89% with absolute covered lines up (277→286 stmts).

Label is review: pending — the head moved, so the earlier verdict no longer applies. Cross-review dispatched on a different model.

@JarryShaw JarryShaw added review: pending No verdict for the current head - never reviewed, or the head moved since the last one and removed review: needs-changes Cross-review at the current head says changes are required; see the verdict comment labels Sep 26, 2026
@JarryShaw

Copy link
Copy Markdown
Owner Author

Cross-review verdict: NEEDS CHANGES (opus; author was sonnet). I re-ran the load-bearing probes against origin/main and this head side by side, and they change the picture in both directions.

1. This PR removes a defect that ships today. main mints an enum member for every bad name — measured, __members__ 5 → 9:

main:  'tcp ' -> RegistryError, members 5->6      PR: members 5->5
       'bogus'                         6->7                    5->5
       'tcp|udp'                       7->8                    5->5
       ''                              8->9                    5->5
  main final members: [... 'tcp ', 'bogus', 'tcp|udp', '']

That is the #775 class, live on main, and the coercion via __members__ removes it. Worth saying in the PR description.

2. Required: format the rejected name with !r. 'tcp ' currently reports unknown transport protocol: tcp — the trailing space is invisible, so it asserts tcp is unknown when tcp is the one name that is known. {proto!r} at protocols.py:865. Safe: it only ever formats an unresolvable name, which has no member counterpart, so the str/member message equivalence is untouched.

3. Required, and I am correcting the reviewer here: the int/bool half-write is a regression against main, not pre-existing. It called it pre-existing having checked 4c035d2c4 — but that is this PR's own earlier head, which already carried the varargs change. Against main:

main:  register_apptype(p, Unit, proto=1)    -> TypeError, wrote NOTHING
PR:    register_apptype(p, Unit, 1)          -> AttributeError, wrote [9706]  <-- port registered, then crash

Because TransportProtocol is an IntFlag, registries.get(1) finds TCP, TCP.register runs, and the next line's proto.name explodes — leaving the port in TCP.__proto__ with register_protocol never run. isinstance(proto, str) should be an else-branch that raises RegistryError, not a fall-through.

4. One thing needs your ruling — see the separate comment below. The coercion is case-sensitive; main is case-insensitive.

Confirmed and not in dispute: no str/member divergence across 11 adversarial pairs measured on real registry state (including the composite, whose .name really is 'tcp|udp' so both paths give the same message); nothing in the new path reaches get()/_missing_/extend_enum (spies recorded zero calls, self-tested); class_ still keyword-only and the only keyword-only parameter; delta scope exactly two files; coverage 89% → 89% with all 9 new statements and 6 new branches covered.

Two testing caveats worth fixing eventually: the six new blocks are sequential inside one test method, so the method dies at the first and the other five never run — "all six fail without the fix" is true per-block but not provable from one invocation. And the equivalence block patches only TCP.register and compares call_args, so it would pass even if the member branch had also written to the real UDP registry.

@JarryShaw

Copy link
Copy Markdown
Owner Author

Needs your ruling: the coercion is case-sensitive, and main is not. That is a narrowing your ruling did not ask for.

Measured side by side, origin/main (21e9588af) vs this head (e683be072):

main:  register_apptype(9700, Unit, proto='TCP') -> OK, registered under TCP
       register_apptype(9701, Unit, proto='Tcp') -> OK, registered under TCP
PR:    register_apptype(9700, Unit, 'TCP')       -> RegistryError: unknown transport protocol: TCP
       register_apptype(9701, Unit, 'Tcp')       -> RegistryError: unknown transport protocol: Tcp

main does TransportProtocol.get(proto.lower()), so it has accepted any casing. A caller who has been passing 'TCP' gets a hard error after this merges. Your words were "accept the str value and coerce it into TransportProtocol enumeration", which does not settle casing either way.

(a) Keep it case-sensitive — exact member names only, as implemented. Documented in the docstring, and the PR is already feat(reg)!, so it goes in the breaking-change note next to the class_/varargs change.

(b) Restore case-insensitivity — one line, __members__.get(proto) → __members__.get(proto.lower()). I checked this still refuses the composite, since 'tcp|udp'.lower() is not a member name either, so the strictness you get from __members__ is preserved.

I lean (b): it is what callers have today, the ruling was framed as "a light lift" rather than a tightening, and it costs nothing in strictness. But it is your API.

Related, and probably the same decision: AppType._dispatch (pcapkit/const/reg/apptype/apptype.py:2380) still does TransportProtocol.get(proto.lower()) for a str. So after this merges, AppType.get(port, proto='TCP') would work while register_apptype(port, mod, 'TCP') would not — and AppType.get would still mint on a bad name. Two public APIs taking "a member or its name" disagreeing on both casing and minting. Whichever way you rule, it should apply to both.

The two required changes from the cross-review (!r on the rejected name, and rejecting non-str/non-member instead of half-registering) do not depend on this answer, so a worker is doing those now.

@JarryShaw JarryShaw added review: needs-changes Cross-review at the current head says changes are required; see the verdict comment needs: decision Waiting on the maintainer to decide — not blocked by other work and removed review: pending No verdict for the current head - never reviewed, or the head moved since the last one labels Sep 26, 2026
@JarryShaw
JarryShaw force-pushed the fix/806-apptype-single-transport-proto branch from e683be0 to 7b42912 Compare September 26, 2026 01:57
…ter_apptype varargs

- `AppType.proto` was the whole transport protocol set IANA assigned the
  service, so `TCP['http'].proto` named UDP and SCTP too -- a second copy of
  what the four per-transport registries already encode, and the copy that
  could drift. It is now the single transport of the registry the member
  lives in, generated that way rather than narrowed at runtime because
  `proto.name` is folded into the member's underlying `str` value and so into
  the live `_value2member_map_` key. 10,625 of 12,391 members change value.
- `register_apptype` took a `proto` kwarg and fanned out across every bit it
  held, so `register_apptype(TCP.http, Dummy)` displaced the UDP handler for
  port 80 as well. It now takes `*transport` varargs, named one at a time.
  `class_` is positional again, at the sibling position ahead of
  `*transport`, restoring consistency with `register_tcp` and friends; the
  swallow that once made it keyword-only is disambiguated at runtime by
  `type(module)` alone, per further maintainer ruling, never by what the
  value looks like. A `str` `module` means the third argument really is a
  class name, even one spelling a transport (`register_apptype(80, 'a.b',
  'tcp')` registers a class named `'tcp'`); any other `module` means it was
  never a class name, so it is prepended to `*transport` instead -- order
  preserved -- and `class_` resets to unset.
- Per maintainer ruling, `*transport` also accepts each protocol as its
  member's `name`, case-insensitively (`'tcp'`, `'TCP'`, `'Tcp'` alike),
  coerced to the member before any registry work via `__members__` rather
  than `TransportProtocol[name]`, since aenum's `Flag` getitem parses a
  `'|'`-joined name into a composite value on its own and lowercasing does
  not change that. An unresolvable name raises `RegistryError` formatted
  with `!r`, exactly as the equivalent member does, including for a
  composite such as `'tcp|udp'`. Anything that is neither a `str` nor a
  `TransportProtocol` member is rejected the same way before the loop rather
  than falling through to it: `TransportProtocol` is an `IntFlag`, so an
  `int` such as `1` would otherwise resolve to `TCP` and register there
  before erroring on the missing `.name`.
- Drops the `consider-using-f-string` pylint disable the `TRANSPORT` template
  emitted into the four const registries, which have no `%` sites.
- `tests/const/test_const_apptype_split_unit.py`'s signature checks are
  rewritten twice over: the literal-source-text pin on the old annotation is
  dropped for a live `inspect.signature` check (a brittle snippet re-breaks
  on every annotation change), and that check itself now pins `class_` as
  `POSITIONAL_OR_KEYWORD` rather than `KEYWORD_ONLY`, since a bare `.bind()`
  can only show that a third positional lands in `class_` regardless of
  `module` -- the `type(module)` branch is a runtime property of the
  function body and is asserted by actually calling it.

Generator and generated output hand-edited rather than regenerated, since
`AppType` crawls IANA's live CSV; `BASE`/`TRANSPORT` re-rendered from
arguments extracted out of the committed files reproduce all five byte-
identically. `tests/const`, `tests/vendor` and `tests/foundation` pass.
@JarryShaw
JarryShaw force-pushed the fix/806-apptype-single-transport-proto branch from 4c073e0 to ee51366 Compare September 26, 2026 04:16
@JarryShaw

Copy link
Copy Markdown
Owner Author

Your ruling is implemented at ee51366a6, and I verified all five cases plus the residual.

signature: (code, module, class_='(null)', *transport)        <- positional again, sibling convention restored

row1  class module + 1 member            -> tcp=[9401]
row2  class module + 2 members           -> tcp=[9402] udp=[9402]      <- order preserved, prepended
row3  class module + str transport 'udp' -> udp=[9403]
row4  str module + 'Raw' + tcp           -> tcp=[9404]
row5  str module + 'Raw' + udp (4th pos) -> udp=[9405]
str module + 'tcp' as class name         -> AttributeError: module '…misc.raw' has no attribute 'tcp'

That last line is the one that matters: 'tcp' was resolved as a class name, not sniffed as a transport — the residual I flagged as resolved-by-fiat behaves as documented.

The branch is four lines at the top of the body, keyed on type(module) only:

if not isinstance(module, str) and class_ is not NULL:
    transport = (cast('TransportProtocol | str', class_),) + transport
    class_ = NULL

A precision worth recording, because it changes how the test can be written: Python's argument binding is purely syntactic, so a third positional always raw-binds to class_ regardless of module's type. The disambiguation is therefore a body-level effect, not a binding-level one — which means an inspect.signature().bind() assertion cannot express it. The rewritten const test at test_const_apptype_split_unit.py does both halves: bind() showing the third positional lands in class_ unconditionally, and a real call checking UDP.__proto__[65210] to show the body redirects it. Still all on the live signature and function object, never on source text.

It also caught and fixed a test from round 1 that had relied on the now-reversed convention — register_apptype(65000, mod, TransportProtocol.tcp, class_=raw) is a TypeError: got multiple values for argument 'class_' under the new signature. And it swept the whole package for other register_apptype( call sites: none outside protocols.py and the two test files, and the .rst references are autofunction/prose rather than literal signatures.

tests/const/test_const_apptype_split_unit.py 21 OK, tests/foundation/registry 15 OK, 31 under pytest with no subtest miscount. Coverage 89% held, 288→291 statements, same missed set. aliases not added, per your second ruling.

Label is review: pending — the four earlier verdicts were against the keyword-only signature and cannot carry across a deliberate reversal. A delta re-review is dispatched.

@JarryShaw JarryShaw added review: pending No verdict for the current head - never reviewed, or the head moved since the last one and removed review: needs-changes Cross-review at the current head says changes are required; see the verdict comment labels Sep 26, 2026
@JarryShaw

Copy link
Copy Markdown
Owner Author

Delta verdict: GOOD TO GO (opus, same reviewer). Nothing blocking. It also corrected my brief twice, both verified.

My diff instruction was wrong. I told it ee51366a6 and c55a83c3b share parent 4530424df; the real parent is c287152d7 — my own rebase at 02:48Z. So the two-dot diff I specified reports 36 files / 1930 insertions, mostly main-side movement, and lists transport/{tcp,udp}.py, dispatch.py and test_http_unit.py — all off-limits. Those are rebase artefacts only: the PR's own file set against its parent is the same nine files as round 1, and a grep for the off-limits paths in that set returns NONE. Pure delta on the three changed files is 155 insertions / 31 deletions; the regenerated const/reg/apptype/* and vendor/reg/apptype/* tables are unchanged by this round.

Prepend-not-append proven by asymmetry rather than by reading the code, which is the nicer proof: (Unit, TP.tcp, TP.sctp) writes TCP then raises on sctp; (Unit, TP.sctp, TP.tcp) raises with nothing written. Only prepending produces that ordering.

Direct answer to the question I posed: a class module with class_ as a keyword does not silently register under no transport — it raises. But there is one corner worth putting in the PR description rather than only the docstring, and I reproduced it:

register_apptype(port, Unit, class_='x')   -> RegistryError: unknown transport protocol: 'x'   wrote=[]
register_apptype(port, Unit, class_='tcp') -> OK                                               wrote=[9501]

The branch never sniffs the value — it keys on type(module) exactly as you ruled — but the observable outcome of an explicit class_= with a non-str module depends on whether the string happens to name a transport. class_= with a class module is a caller error under any reading, so not a defect.

On the bare AttributeError for the str-module residual: leave it, and the reasoning is stronger than "out of scope". It comes from ModuleDescriptor.klass (pcapkit/corekit/module.py:37), and protocols.py builds ModuleDescriptor(module, class_) in nine places — every register_* sibling fails the same way for a bad class name, demonstrated on this same head by E_TCP.http, '…raw' with class_ omitted giving AttributeError: … has no attribute '(null)'. Fixing it only here would make register_apptype inconsistent with its eight siblings. Filing it as a family-wide follow-up.

What this delta does change is reachability: under the keyword-only shape f(port, 'a.b', 'tcp') bound 'tcp' to *transport and registered under TCP; now it binds to class_ and dies. Same call, new meaning — which is the point of your ruling, but worth stating.

Both rewritten tests have teeth against the pre-reversal source, checked on a scratch tree at c55a83c3b: the registry test fails with RegistryError: unknown transport protocol: 'Raw', and the const test fails on the parameter-kind list (KEYWORD_ONLY last vs POSITIONAL_OR_KEYWORD third). Each fails on exactly what the reversal changed. And the bind() assertion now pins the undesired outcome on purpose — recording that pure syntax does swallow, with the real registry call proving the body undoes it.

Nothing from earlier rounds regressed: .lower() case-insensitivity, the !r, composites, unknown names, and the elif guard that closed the IntFlag half-write — 1, True, b'tcp' all raise with nothing written to either registry, which is the one the reshuffle could plausibly have broken since class_ is now prepended before the coercion loop. __members__ flat at 5 throughout. 36 passed / 38163 subtests under pytest, 31 OK under plain unittest.

@JarryShaw JarryShaw added review: good-to-go Cross-review at the current head says ready; CI state is separate and removed review: pending No verdict for the current head - never reviewed, or the head moved since the last one labels Sep 26, 2026
@JarryShaw

Copy link
Copy Markdown
Owner Author

For the NULL value in the foundation registry, can we actually use a sentinel rather than a pure string? This is not in scope of this PR tho. File an issue and we can talk further.

@JarryShaw
JarryShaw merged commit 3118ed7 into main Sep 26, 2026
31 checks passed
@JarryShaw
JarryShaw deleted the fix/806-apptype-single-transport-proto branch September 26, 2026 04:39
@JarryShaw JarryShaw removed the review: good-to-go Cross-review at the current head says ready; CI state is separate label Sep 26, 2026
JarryShaw added a commit that referenced this pull request Sep 26, 2026
…lError from ModuleDescriptor.klass (#832) (#833)

`NULL` was a plain `str` (`'(null)'`) compared by identity, defined
independently in `protocols.py` (13 uses) and `foundation.py` (7 uses), so an
equal-but-distinct `'(null)'` from a caller took a different branch than the
sentinel itself depending on string interning. That let an omitted `class_`
reach `ModuleDescriptor.klass`'s bare `getattr` and surface as
`AttributeError: module 'X' has no attribute '(null)'`, and the same bare
`AttributeError` leaked for any bad class name across all nine `register_*`
call sites that build a descriptor.

Add `NullType`/`NULL` to `pcapkit.corekit.module`, the module both registries
already import `ModuleDescriptor` from, giving the sentinel a type no
caller-supplied string can collide with, and retype every `class_` parameter
accordingly. `ModuleDescriptor.klass` now raises `ProtocolError` for a class
name that resolves to nothing, and, ahead of `getattr` entirely, for a `name`
still `NULL` -- an omitted argument rather than a request for a class
literally named `'(null)'`.

`NullType` is now a genuine singleton rather than a class this module merely
instantiated once: `__new__` always hands back the existing instance, and
`__copy__`/`__deepcopy__`/`__reduce__` keep `copy.copy`, `copy.deepcopy` and
every `pickle` protocol (0 through 5) on that same object too. Without this,
deepcopying a `ModuleDescriptor` minted a second, non-identical `NullType`
that reached `getattr` as a non-`str` name and downgraded the clean
`ProtocolError` above into a bare `TypeError`. Also added `NULL`/`NullType`
to `__all__`, fixed an over-indented continuation line, and extended
`test_null_sentinel_is_not_a_string` to assert the singleton claim its
docstring made rather than only describing it.

Updated #815's `AttributeError` assertion in `test_protocols.py` to
`ProtocolError`, keeping its `assertIn("'tcp'", ...)` check that a `str`
third argument is resolved as a class name, never sniffed as a transport.
Added coverage for the omitted-class-name, explicit-`'(null)'`,
sentinel-still-means-absent, and singleton-identity cases; `coverage run`
shows 100% on `module.py` and `foundation.py`, and no drop in `protocols.py`.
JarryShaw added a commit that referenced this pull request Sep 26, 2026
…poses

Closes #808. Blocked on #806 (merged as #815), which retyped every
member's `proto` to a single transport, leaving nothing that builds
or relies on a composite `TransportProtocol` value.

- `TransportProtocol` becomes a plain `aenum.IntEnum`. The four
  transports keep their exact values (tcp=1, udp=2, sctp=4, dccp=8,
  undefined=0) via `cast(...)` rather than `auto()`, since IntEnum's
  `auto()` numbers sequentially and would renumber them.
- Removed `_missing_`'s composing fallback/range guard (a plain
  IntEnum's default `_missing_` already rejects anything undeclared)
  and changed `.get()`'s `extend_enum` from `max_val * 2` to
  `max_val + 1`, since there are no bits left to keep distinct.
- `.get()` now refuses a `|`-containing string outright rather than
  minting it, mirroring `register_apptype`'s existing refusal of a
  composite string.
- `_dispatch` no longer decodes every `proto` through
  `show_flag_values` uniformly: a genuine `TransportProtocol` member
  (real or minted) is looked up directly and never bit-decomposed,
  since composing is impossible for a member now. A review round
  caught that decoding minted members' bits too made every value from
  9 up (`max_val + 1`'s first mint) misread as a composite of real
  transports it never meant, e.g. `get('bogus')` minting 9 = tcp|dccp
  in bits. Only a bare `int` (from `TransportProtocol.a | .b`, which
  now falls through to `int.__or__`) still takes the bit-decomposition
  path, and its error message reconstructs `tcp|udp`-style text from
  the bits rather than relying on Flag repr.
- Widened `proto`'s type annotation to include `int` across
  `_dispatch`/`get`/`get_all`, and cast at the one dict-key site mypy
  cannot infer, to match the type mypy actually needs to stay clean.
- Applied identically to the vendor generator template; verified the
  generated `TransportProtocol` class and `_dispatch`/`get` bodies are
  byte-identical between the two by rendering the template directly.
- `tests/dumpkit/test_nameless_enum_rendering_unit.py`'s flag-registry
  sweep drops from 7 to 6 registries (TransportProtocol no longer
  matches `issubclass(_, aenum.Flag)`) and from 4 to 3 distinct
  `_missing_` field widths; re-measured and re-pinned rather than
  assumed, prose updated to match.
- Updated tests pinning removed Flag mechanics and two enum-sweep
  size pins (Flag count 7->6, IntEnum count 111->112). Added tests
  that fail on stock and on this fix's own first-round head: the
  minted-member misread as composite, a bare int with a stray
  non-real bit, and a composite string refused by `.get()`.
- Corrected an earlier claim: `list(TransportProtocol)` now yields all
  five members (4 on stock) since `Flag` hid the zero-valued
  `undefined` from iteration and plain `IntEnum` does not. Per-member
  repr/str/name/value are still byte-identical; nothing in-tree
  iterates the class bare, only through `__members__` (5 either way).

`register_apptype` and its own tests needed no change: they already
reject anything that is not `isinstance(proto, TransportProtocol)`,
which a bare int (what `|` now produces) satisfies identically.

Built and tested: `tests/const/`, `tests/foundation/registry/`,
`tests/vendor/test_vendor_reg_apptype_generator_unit.py` and
`tests/dumpkit/test_nameless_enum_rendering_unit.py` pass in full
(104 tests, 39354 subtests via pytest; 89 tests via plain unittest).
mypy and isort clean on both touched source files.
JarryShaw added a commit that referenced this pull request Sep 26, 2026
…poses

Closes #808. Blocked on #806 (merged as #815), which retyped every
member's `proto` to a single transport, leaving nothing that builds
or relies on a composite `TransportProtocol` value.

- `TransportProtocol` becomes a plain `aenum.IntEnum`. The four
  transports keep their exact values (tcp=1, udp=2, sctp=4, dccp=8,
  undefined=0) via `cast(...)` rather than `auto()`, since IntEnum's
  `auto()` numbers sequentially and would renumber them.
- Removed `_missing_`'s composing fallback/range guard (a plain
  IntEnum's default `_missing_` already rejects anything undeclared)
  and changed `.get()`'s `extend_enum` from `max_val * 2` to
  `max_val + 1`, since there are no bits left to keep distinct.
- `.get()` now refuses a `|`-containing string outright rather than
  minting it, mirroring `register_apptype`'s existing refusal of a
  composite string.
- `_dispatch` no longer decodes every `proto` through
  `show_flag_values` uniformly: a genuine `TransportProtocol` member
  (real or minted) is looked up directly and never bit-decomposed,
  since composing is impossible for a member now. A review round
  caught that decoding minted members' bits too made every value from
  9 up (`max_val + 1`'s first mint) misread as a composite of real
  transports it never meant, e.g. `get('bogus')` minting 9 = tcp|dccp
  in bits. Only a bare `int` (from `TransportProtocol.a | .b`, which
  now falls through to `int.__or__`) still takes the bit-decomposition
  path, and its error message reconstructs `tcp|udp`-style text from
  the bits rather than relying on Flag repr.
- Widened `proto`'s type annotation to include `int` across
  `_dispatch`/`get`/`get_all`, and cast at the one dict-key site mypy
  cannot infer, to match the type mypy actually needs to stay clean.
- Applied identically to the vendor generator template; verified the
  generated `TransportProtocol` class and `_dispatch`/`get` bodies are
  byte-identical between the two by rendering the template directly.
- `tests/dumpkit/test_nameless_enum_rendering_unit.py`'s flag-registry
  sweep drops from 7 to 6 registries (TransportProtocol no longer
  matches `issubclass(_, aenum.Flag)`) and from 4 to 3 distinct
  `_missing_` field widths; re-measured and re-pinned rather than
  assumed, prose updated to match.
- Updated tests pinning removed Flag mechanics and two enum-sweep
  size pins (Flag count 7->6, IntEnum count 111->112). Added tests
  that fail on stock and on this fix's own first-round head: the
  minted-member misread as composite, a bare int with a stray
  non-real bit, and a composite string refused by `.get()`. That
  minted-member test fails on stock too, but only because stock's
  `max_val * 2` scheme mints a different number (16, not 9) -- stock's
  doubling keeps every minted value a single bit by construction, so
  it never exhibits the misread itself; the misread was introduced and
  caught within this PR's own review.
- Corrected an earlier claim: `list(TransportProtocol)` now yields all
  five members (4 on stock) since `Flag` hid the zero-valued
  `undefined` from iteration and plain `IntEnum` does not. Per-member
  repr/str/name/value are still byte-identical; nothing in-tree
  iterates the class bare, only through `__members__` (5 either way).

`register_apptype` and its own tests needed no change: they already
reject anything that is not `isinstance(proto, TransportProtocol)`,
which a bare int (what `|` now produces) satisfies identically.

Built and tested: `tests/const/`, `tests/foundation/registry/`,
`tests/vendor/test_vendor_reg_apptype_generator_unit.py` and
`tests/dumpkit/test_nameless_enum_rendering_unit.py` pass in full
(104 tests, 39354 subtests via pytest; 104 tests via plain unittest,
77+15+6+6 across the four selections). mypy and isort clean on both
touched source files.
JarryShaw added a commit that referenced this pull request Sep 26, 2026
…lError from ModuleDescriptor.klass (#832) (#833)

`NULL` was a plain `str` (`'(null)'`) compared by identity, defined
independently in `protocols.py` (13 uses) and `foundation.py` (7 uses), so an
equal-but-distinct `'(null)'` from a caller took a different branch than the
sentinel itself depending on string interning. That let an omitted `class_`
reach `ModuleDescriptor.klass`'s bare `getattr` and surface as
`AttributeError: module 'X' has no attribute '(null)'`, and the same bare
`AttributeError` leaked for any bad class name across all nine `register_*`
call sites that build a descriptor.

Add `NullType`/`NULL` to `pcapkit.corekit.module`, the module both registries
already import `ModuleDescriptor` from, giving the sentinel a type no
caller-supplied string can collide with, and retype every `class_` parameter
accordingly. `ModuleDescriptor.klass` now raises `ProtocolError` for a class
name that resolves to nothing, and, ahead of `getattr` entirely, for a `name`
still `NULL` -- an omitted argument rather than a request for a class
literally named `'(null)'`.

`NullType` is now a genuine singleton rather than a class this module merely
instantiated once: `__new__` always hands back the existing instance, and
`__copy__`/`__deepcopy__`/`__reduce__` keep `copy.copy`, `copy.deepcopy` and
every `pickle` protocol (0 through 5) on that same object too. Without this,
deepcopying a `ModuleDescriptor` minted a second, non-identical `NullType`
that reached `getattr` as a non-`str` name and downgraded the clean
`ProtocolError` above into a bare `TypeError`. Also added `NULL`/`NullType`
to `__all__`, fixed an over-indented continuation line, and extended
`test_null_sentinel_is_not_a_string` to assert the singleton claim its
docstring made rather than only describing it.

Updated #815's `AttributeError` assertion in `test_protocols.py` to
`ProtocolError`, keeping its `assertIn("'tcp'", ...)` check that a `str`
third argument is resolved as a class name, never sniffed as a transport.
Added coverage for the omitted-class-name, explicit-`'(null)'`,
sentinel-still-means-absent, and singleton-identity cases; `coverage run`
shows 100% on `module.py` and `foundation.py`, and no drop in `protocols.py`.

Corrected `docs/source/pcapkit/corekit/module.rst`, a hand-written page
`autodoc`/`nitpicky` never regenerates or gates: the `name` property's
`:type:` still said `str`, and the page had no entry for `NullType`/`NULL`
despite eight cross-references into it from `module.py`'s own docstrings.
Added an "Auxiliaries" section documenting both, following the
`NoValueType`/`NoValue` precedent in `fields/field.rst`. Verified by building
the full site locally with `PYTHONPATH` pointed at this tree -- the venv's
editable install otherwise shadows it with the unmodified main checkout --
56 pre-existing warnings, none from this page or naming `NullType`/`NULL`.

Also: the pickle-identity test now asserts outside `subTest` too, since this
repo's `pytest-subtests` reports the parent test as passed when only a
`subTest` failed inside it (reproduced directly to confirm); and `NullType`'s
docstring now notes that `importlib.reload` desyncs the sentinel across
modules that already imported it -- structural to sharing one module-level
binding, true of the old `str` sentinel too, and unreached in-tree.
JarryShaw added a commit that referenced this pull request Sep 26, 2026
…poses

Closes #808. Blocked on #806 (merged as #815), which retyped every
member's `proto` to a single transport, leaving nothing that builds
or relies on a composite `TransportProtocol` value.

- `TransportProtocol` becomes a plain `aenum.IntEnum`. The four
  transports keep their exact values (tcp=1, udp=2, sctp=4, dccp=8,
  undefined=0) via `cast(...)` rather than `auto()`, since IntEnum's
  `auto()` numbers sequentially and would renumber them.
- Removed `_missing_`'s composing fallback/range guard (a plain
  IntEnum's default `_missing_` already rejects anything undeclared).
- `.get()` refuses both a `|`-containing string and any other
  unrecognised name outright, rather than minting either. The second
  refusal is the maintainer's own ruling on this file, at
  `apptype.py:96`: "Do not allow extension of TransportProtocol at
  all." It used to mint at `max_val + 1`; there is nothing left to
  walk now, and both refusals raise the identical `ValueError` shape
  rather than inventing a second style. `.get()` still only
  case-folds, never strips whitespace -- unchanged, and left alone
  deliberately: whether to also strip is a separate, still-open
  question the owner is deciding independently, and is not this
  change's call to make either way.
- `_dispatch` no longer decodes every `proto` through
  `show_flag_values` uniformly: a genuine `TransportProtocol` member
  is looked up directly and never bit-decomposed, since composing is
  impossible for a member and, now that extension is refused, a
  member is always one of the five declared. An intermediate round of
  this PR, after minting had moved to `max_val + 1` but before
  extension was refused outright, decoded every `proto`'s bits
  uniformly and misread a minted `'bogus'` (value 9) as a `tcp|dccp`
  composite -- caught in that round's own review, never in stock.
  Only a bare `int` (from `TransportProtocol.a | .b`, which falls
  through to `int.__or__`) still takes the bit-decomposition path,
  and its error message reconstructs `tcp|udp`-style text from the
  bits rather than relying on Flag repr.
- Widened `proto`'s type annotation to include `int` across
  `_dispatch`/`get`/`get_all`, and cast at the one dict-key site mypy
  cannot infer, to match the type mypy actually needs to stay clean.
- Applied identically to the vendor generator template; verified the
  generated `TransportProtocol` class and `_dispatch`/`get` bodies are
  byte-identical between the two by rendering the template's `BASE`
  lambda directly against text extracted from the committed const
  file, rather than running the network-dependent vendor crawl.
- `tests/dumpkit/test_nameless_enum_rendering_unit.py`'s flag-registry
  sweep drops from 7 to 6 registries (TransportProtocol no longer
  matches `issubclass(_, aenum.Flag)`) and from 4 to 3 distinct
  `_missing_` field widths; re-measured and re-pinned rather than
  assumed, prose updated to match.
- Updated tests pinning removed Flag mechanics and two enum-sweep
  size pins (Flag count 7->6, IntEnum count 111->112). Converted the
  two tests whose premise was the old minting -- the `.get('bogus')
  -> 9` probe and the minted-member-misread-as-composite regression
  now assert refusal instead -- and a third
  (`test_transport_protocol_can_no_longer_be_extended_at_runtime`)
  that used to pin the registration itself now pins its removal. All
  three fail against this PR's own prior head (`3567359e2`) with
  `AssertionError: ValueError not raised`, and pass after this change.
- Corrected an earlier claim: `list(TransportProtocol)` now yields all
  five members (4 on stock) since `Flag` hid the zero-valued
  `undefined` from iteration and plain `IntEnum` does not. Per-member
  repr/str/name/value are still byte-identical; nothing in-tree
  iterates the class bare, only through `__members__` (5 either way).

`register_apptype` and its own tests needed no change: they already
reject anything that is not `isinstance(proto, TransportProtocol)`,
which a bare int (what `|` now produces) satisfies identically, and
its own no-strip case-fold resolution was already the model this
change's refusal-shape follows.

Built and tested against current `main` (`ad4805f5f`): `tests/const/`
(77), `tests/foundation/registry/` (19),
`tests/vendor/test_vendor_reg_apptype_generator_unit.py` (6) and
`tests/dumpkit/test_nameless_enum_rendering_unit.py` (6) all pass via
plain unittest, 108 total. mypy (114 errors/38 files) is byte-for-byte
identical before and after this change -- zero new errors -- and
isort is clean on both touched source files.
JarryShaw added a commit that referenced this pull request Sep 26, 2026
…poses

Closes #808. Blocked on #806 (merged as #815), which retyped every
member's `proto` to a single transport, leaving nothing that builds
or relies on a composite `TransportProtocol` value.

- `TransportProtocol` becomes a plain `aenum.IntEnum`. The four
  transports keep their exact values (tcp=1, udp=2, sctp=4, dccp=8,
  undefined=0) via `cast(...)` rather than `auto()`, since IntEnum's
  `auto()` numbers sequentially and would renumber them.
- Removed `_missing_`'s composing fallback/range guard (a plain
  IntEnum's default `_missing_` already rejects anything undeclared).
- `.get()` refuses an unrecognised name outright rather than minting
  one -- the maintainer's own inline ruling on this method: "Do not
  allow extension of TransportProtocol at all." It used to mint at
  `max_val + 1`; there is nothing left to walk now. A `|`-containing
  string gets its own message distinct from that generic refusal --
  naming it a composite and saying to resolve one transport at a time
  -- rather than sharing the generic text: a caller who typed a
  composite by hand benefits from being told why and how to fix it.
  An earlier round of this same change had the two branches raise the
  byte-for-byte identical message, which made the `if '|' in key`
  branch dead code -- reachable, but incapable of changing the
  outcome either way -- caught and fixed before this went up for
  review; the distinct text is now pinned by
  `test_get_refuses_a_composite_spelled_string`, which also asserts
  it differs from the generic refusal's. `.get()` still only
  case-folds, never strips whitespace -- unchanged, and left alone
  deliberately: whether to also strip is a separate, still-open
  question the owner is deciding independently, and is not this
  change's call to make either way.
- `_dispatch` no longer decodes every `proto` through
  `show_flag_values` uniformly: a genuine `TransportProtocol` member
  is looked up directly and never bit-decomposed, since composing is
  impossible for a member and, now that extension is refused, a
  member is always one of the five declared. An intermediate round of
  this PR, after minting had moved to `max_val + 1` but before
  extension was refused outright, decoded every `proto`'s bits
  uniformly and misread a minted `'bogus'` (value 9) as a `tcp|dccp`
  composite -- caught in that round's own review, never in stock.
  Only a bare `int` (from `TransportProtocol.a | .b`, which falls
  through to `int.__or__`) still takes the bit-decomposition path,
  and its error message reconstructs `tcp|udp`-style text from the
  bits rather than relying on Flag repr.
- Widened `proto`'s type annotation to include `int` across
  `_dispatch`/`get`/`get_all`, and cast at the one dict-key site mypy
  cannot infer, to match the type mypy actually needs to stay clean.
- Applied identically to the vendor generator template; verified the
  generated `TransportProtocol` class and `_dispatch`/`get` bodies are
  byte-identical between the two by rendering the template's `BASE`
  lambda directly against text extracted from the committed const
  file, rather than running the network-dependent vendor crawl.
- `tests/dumpkit/test_nameless_enum_rendering_unit.py`'s flag-registry
  sweep drops from 7 to 6 registries (TransportProtocol no longer
  matches `issubclass(_, aenum.Flag)`) and from 4 to 3 distinct
  `_missing_` field widths; re-measured and re-pinned rather than
  assumed, prose updated to match.
- Updated tests pinning removed Flag mechanics and two enum-sweep
  size pins (Flag count 7->6, IntEnum count 111->112). Converted the
  two tests whose premise was the old minting -- the `.get('bogus')
  -> 9` probe and the minted-member-misread-as-composite regression
  now assert refusal instead -- and a third
  (`test_transport_protocol_can_no_longer_be_extended_at_runtime`)
  that used to pin the registration itself now pins its removal. All
  three fail against this PR's own prior head (`3567359e2`) with
  `AssertionError: ValueError not raised`, and pass after this change.
- Corrected an earlier claim: `list(TransportProtocol)` now yields all
  five members (4 on stock) since `Flag` hid the zero-valued
  `undefined` from iteration and plain `IntEnum` does not. Per-member
  repr/str/name/value are still byte-identical; nothing in-tree
  iterates the class bare, only through `__members__` (5 either way).

`register_apptype` and its own tests needed no change: they already
reject anything that is not `isinstance(proto, TransportProtocol)`,
which a bare int (what `|` now produces) satisfies identically, and
its own no-strip case-fold resolution was already the model this
change's refusal-shape follows.

Built and tested against current `main` (`ad4805f5f`): `tests/const/`
(77), `tests/foundation/registry/` (19),
`tests/vendor/test_vendor_reg_apptype_generator_unit.py` (6) and
`tests/dumpkit/test_nameless_enum_rendering_unit.py` (6) all pass via
plain unittest, 108 total. mypy (114 errors/38 files) is byte-for-byte
identical before and after this change -- zero new errors -- and
isort is clean on both touched source files.
JarryShaw added a commit that referenced this pull request Sep 26, 2026
…poses

Closes #808. Blocked on #806 (merged as #815), which retyped every
member's `proto` to a single transport, leaving nothing that builds
or relies on a composite `TransportProtocol` value.

- `TransportProtocol` becomes a plain `aenum.IntEnum`. The four
  transports keep their exact values (tcp=1, udp=2, sctp=4, dccp=8,
  undefined=0) via `cast(...)` rather than `auto()`, since IntEnum's
  `auto()` numbers sequentially and would renumber them.
- Removed `_missing_`'s composing fallback/range guard (a plain
  IntEnum's default `_missing_` already rejects anything undeclared).
- `.get()` refuses an unrecognised name outright rather than minting
  one -- maintainer ruling: "Do not allow extension of
  TransportProtocol at all." It used to mint at `max_val + 1` (an
  intermediate revision of this PR; stock still doubles, `max_val *
  2`); there is nothing left to walk now. Two earlier rounds of this
  PR gave a `'|'`-containing string, e.g. `'tcp|udp'`, its own
  distinct message on the theory that it names a composite rather
  than merely an unknown name; the owner's final ruling drops that
  distinction outright rather than refining it -- "since it's no
  longer a Flag, `|` joined values are no longer parsed and accepted,
  we will treat it as a whole, instead of splitting" -- so `'|'` gets
  the identical generic refusal any other unrecognised name does.
  `.get()` still only case-folds, never strips whitespace, per the
  same ruling: the owner pointed at engine selection
  (`extraction.py:922`, lower-only, no `.strip()` anywhere in
  `pcapkit/foundation/`) as the convention to match, and normalising
  is the only part of that convention adopted -- engine selection
  warns and falls back to a default on a miss, `.get()` still raises.
- `_dispatch` no longer decodes a bare-int `proto`'s bits at all,
  matching the same ruling: a composite built by hand, e.g.
  `TransportProtocol.tcp | TransportProtocol.udp` (a bare `int` since
  `|` falls through to `int.__or__` now), used to be split via
  `show_flag_values` into a `ProtocolError` naming every transport
  whose bit was set -- the GitHub issue #759 fix, present on stock
  and refined once more in an intermediate round of this PR to tell
  a clean composite (`3`, real bits only) apart from a stray bit
  (`17`, one real bit plus one nothing declares). The owner's ruling
  retires that decoding entirely rather than refining it further: a
  bare-int composite is now refused exactly like any other value
  naming no registry -- one plain `ValueError`, whether the int is
  `3`, `17`, or `TransportProtocol.undefined`. This removes the last
  use of `show_flag_values` and of `ProtocolError` from this module,
  so both imports are dropped along with the docstring `Raises:`
  entries naming `ProtocolError` on `_dispatch`/`get`/`get_all`.
  User-visible consequence: `AppType.get(80, proto=17)` and
  `AppType.get(80, proto=3)` were both `ProtocolError` on stock
  `ad4805f5f` and are both `ValueError` now. Neither is a regression
  on a *supported* input -- a bare `int` was off-contract until this
  PR widened `proto`'s annotation to include it -- but the exception
  type a caller now sees for that input has changed.
- Widened `proto`'s type annotation to include `int` across
  `_dispatch`/`get`/`get_all`, and cast at the one dict-key site mypy
  cannot infer, to match the type mypy actually needs to stay clean.
- Applied identically to the vendor generator template; verified the
  generated `TransportProtocol` class and `_dispatch`/`get` bodies are
  byte-identical between the two by rendering the template's `BASE`
  lambda directly against text extracted from the committed const
  file, rather than running the network-dependent vendor crawl.
- `pcapkit/foundation/registry/protocols.py` (in scope once #835
  merged into `ad4805f5f`): corrected `register_apptype`'s own NOTE,
  which justified resolving a string transport via `__members__`
  rather than `TransportProtocol[name]` with two claims this PR made
  false -- that `Flag.__getitem__` parses `'tcp|udp'` into the value
  `3`, and that `TransportProtocol` is an `IntFlag`. Neither holds
  once `|` is retired: `TransportProtocol['tcp|udp']` now raises a
  bare `KeyError`, same as `TransportProtocol['bogus']`, which is
  the corrected reason `__members__.get(...)` is still used -- this
  function's own contract is `RegistryError` on a miss, not
  `KeyError`. The `registries.get(1)` conclusion right after it is
  unchanged and stays: `hash(TransportProtocol.tcp) == hash(1)`
  regardless of the base, so an `int` key still hits a
  `TransportProtocol`-keyed dict entry. No behaviour changed here,
  only the comment explaining it.
- `tests/dumpkit/test_nameless_enum_rendering_unit.py`'s flag-registry
  sweep drops from 7 to 6 registries (TransportProtocol no longer
  matches `issubclass(_, aenum.Flag)`) and from 4 to 3 distinct
  `_missing_` field widths; re-measured and re-pinned rather than
  assumed, prose updated to match.
- Updated tests pinning removed Flag mechanics and two enum-sweep
  size pins (Flag count 7->6, IntEnum count 111->112). Converted every
  test whose premise the rulings above removed: the `.get('bogus')`
  minting probe and its misread-as-composite regression now assert
  refusal instead; the composite-string test lost its distinct-message
  assertions; the bare-int composite test
  (`test_a_bare_int_composite_is_refused_as_a_whole`, renamed from
  `test_a_proto_naming_two_transports_is_refused_rather_than_resolved`)
  now asserts the identical plain `ValueError` for `3` that a stray
  bit and `undefined` already got, through all three entry points;
  and `test_transport_protocol_can_no_longer_be_extended_at_runtime`
  pins the registration's removal rather than its shape. Corrected two
  rounds of stale narration a cross-review caught along the way: three
  comments/docstrings citing a `TransportProtocol.__getitem__`
  contrast that no longer has anything to contrast (there is no
  composite-specific branch left to justify), and two docstrings
  attributing the `max_val + 1` minting scheme to stock rather than to
  this PR's own now-superseded intermediate revision -- stock mints at
  `max_val * 2` (`16` for `'quic'`/`'bogus'`), measured on `ad4805f5f`.
  Also normalised three `3118ed796` "stock" references to `ad4805f5f`
  for consistency with the rebased base, since the claims hold at
  either commit.
- Corrected an earlier claim: `list(TransportProtocol)` now yields all
  five members (4 on stock) since `Flag` hid the zero-valued
  `undefined` from iteration and plain `IntEnum` does not. Per-member
  repr/str/name/value are still byte-identical; nothing in-tree
  iterates the class bare, only through `__members__` (5 either way).

`register_apptype` and its own tests needed no change beyond the NOTE
above: they already reject anything that is not
`isinstance(proto, TransportProtocol)`, which a bare int (what `|`
now produces) satisfies identically, and its own no-strip case-fold
resolution was already the model `.get()`'s normalisation follows.

Built and tested against current `main` (`ad4805f5f`): `tests/const/`
(77), `tests/foundation/registry/` (19),
`tests/vendor/test_vendor_reg_apptype_generator_unit.py` (6) and
`tests/dumpkit/test_nameless_enum_rendering_unit.py` (6) all pass via
plain unittest, 108 total. mypy (114 errors/38 files) is identical
before and after this change once line-number drift from the new
`protocols.py` comment is accounted for -- zero new errors -- and
isort is clean on all three touched source files.
JarryShaw added a commit that referenced this pull request Oct 3, 2026
…issue (#719)

- Nine citations in tests/ called a pull request "GitHub issue"
  (#921, #764, #906, #815, #936, #721, #501, #983) or lumped PR #428 in
  with issue #425; each now names the right kind.
- test_dispatch_default_resolution_unit: issue #425 reported the registry
  leak and PR #428 fixed it, so the sentence says "reported" and "fixed"
  instead of crediting the issue with the fix.
- Prose only: docstrings and comments, no assertion or logic touched.
@JarryShaw JarryShaw added this to the 1.5 milestone Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Breaks public-facing behaviour or API (apply alongside the type label) const Regenerated IANA or vendor constant tables; members keep their numeric values enhancement Issues requesting a new capability (set by the feature request template) test Pull requests that add or correct tests (test: subject prefix)

Projects

Status: Done

1 participant