The Registry Protocol¶
get(),
get_all(),
register() and
register_alias() are expected to exist on
every registry, per the ruling on
#842. They come from
EnumRegistry, mixed in ahead of the enum base so that
_member_type_ still resolves to int or str:
class LinkType(EnumRegistry, IntEnum):
...
These registries define their own __new__ to carry extra attributes and so do not
share the generated template: Command,
ReturnCode,
Method,
StatusCode,
OptionType and
AppType. They are on the base regardless –
#860 finished that – so a
bespoke __new__ exempts a registry from the template, not from the protocol.
The Two-Tier Hierarchy¶
EnumRegistry is not the only base: per phase 1 of
#877 it has a parent, and the line between them is whether the enumeration
may grow:
|
|
|
The owner ruled on #877 that a registry may subclass a bare base enum out of
pcapkit.corekit.enum, with EnumRegistry
subclassing that base in turn for use by the mutable ones. So a closed set
inherits EnumLookup
directly and is never handed a register it would have to refuse; an open
registry inherits EnumRegistry exactly as before.
What settled the split is the owner’s own second thought about carrying register
on the base: if the base carries register, there is no principled reason for it
not to carry register_alias as well, and the ruling would be the worse for it.
Following that through leaves EnumRegistry holding only
a few methods, too thin to justify a second class – so the two tiers collapse into one,
which is the opposite of what was ruled.
Both tiers are plain classes, and that is load-bearing. Inserting a parent above
EnumRegistry leaves the member data type exactly where
it was – LinkType -> EnumRegistry -> EnumLookup -> IntEnum -> int – so
_member_type_ still comes from the enum base. Had either tier subclassed
aenum.Enum in order to “be an enum”, it would have become the member type
itself and broken int, str and flag registries at once.
_validate_value() is what the base carries
instead of register, and it answers the owner’s other requirement: the base has
to offer range-validation logic for its inheriting classes to hook into. The
base implementation accepts everything; an override states a range, in the shape the
generated registries spell by hand in _missing_:
@classmethod
def _validate_value(cls, value: 'Any') -> 'None':
if not (isinstance(value, int) and 0 <= value <= 0xFF):
raise EnumValueError(f'{value!r} is not a valid {cls.__name__}')
Every registry overrides it, so that register refuses what get refuses
(#1816). Each vendor generator emits the override from the check it already
writes at the top of _missing_, with the same message. That is the FLAG of most
crawlers, but a bespoke template’s own range where its _missing_ checks something
else: 0-999 for StatusCode and
ReturnCode, and str for the
string-valued registries.
TLSKeyLabel and
ExtensionHeader have no _missing_ to
copy from, because their constructor refuses every value no member carries, so the
first checks its member type and the second the 8-bit Next Header field the type is
sent in. The AppType transports inherit
AppType’s own.
The check is the range, not everything _missing_ decides. A value inside the range
that matches neither a member nor an unassigned range – CauseCode(0), say – still
fails to resolve through get, and register may mint it.
What is easy to get wrong about it:
It guards, it does not convert. The return type is
Nonedeliberately, so that an override cannot normalise a value on its way through and silently change what a lookup resolves to.Raise from
pcapkit.utilities.exceptions.EnumValueErroris the fitting one, and because it subclassesValueErrora rejection is caught byget’s ownexceptand falls back todefaultlike any other unresolvable value. An override raising outside that hierarchy propagates pastdefaultinstead.With no usable
default, the rejection reaches the caller unwrapped.getre-raises aValueErrorthat is already aBaseErrorexactly as the override raised it, and converts onlyaenum’s andenum’s own “no member carries this value”. The consequence is the point of the discrimination rather than a side effect: the override’s own message survives to the caller instead of being replaced by the base’s. So an override should say in its message what it rejected and why; that message is what the caller sees.A
strkey never reaches it. That path never callscls(key), so it resolves only against already-populated lookup tables, where every value present is legal by construction.registerdoes call it, after its duplicate check.
Note
Re-parenting every non-registry enumeration onto
EnumLookup was phase 2 of
#877, and it is complete: the phase landed for every non-registry
enumeration. No enumeration remains outside the hierarchy, measured by a
runtime walk over both the enum and aenum flavours.
Failed-Lookup Exceptions¶
Two rules govern which exception a failed lookup raises, and they pull in opposite
directions on purpose. The owner ruled on
#923 that the choice between
ValueError and KeyError follows whichever stdlib’s Enum
would raise in the same circumstance, and that whichever it is comes from
pcapkit.utilities.exceptions rather than from builtins.
So the provenance is in-library and the shape is stdlib’s:
get()raisesEnumKeyErrorfor a name miss andEnumValueErrorfor a value miss, both frompcapkit.utilities.exceptionsrather than from builtins.The split between the two is not taste.
E['nosuch']raisesKeyErrorandE(999)raisesValueErroron a stdlibEnum, so a miss by name isKeyError-derived here and a miss by value isValueError-derived, matching it.That is what keeps the ruling cheap to carry out:
EnumKeyErrorderivesKeyErrorandEnumValueErrorderivesValueError, so only the provenance changed – everyexcept KeyErrorandexcept ValueErroraround a lookup keeps catching, in this tree and in a caller’s.
Which branch a key takes, and what each miss ends in:
flowchart TD
GET["get(key, default)"] -->|"isinstance(key, str)"| NAME["\_member\_map\_ lookup"]
GET -->|"otherwise"| VALUE["_validate_value(key)<br/>then cls(key)"]
NAME -->|"miss, no usable default"| KEYERR["EnumKeyError<br/>quiet, derives KeyError"]
NAME -->|"hit,<br/>or miss, key is a registered value,<br/>or miss, default is a registered value"| OK["canonical member"]
VALUE -->|"resolves, or \_missing\_ answers,<br/>or ValueError, default is a registered value"| OK
VALUE -->|"ValueError, no usable default"| VALERR["EnumValueError<br/>quiet, derives ValueError"]
Do not “improve” on the shape by making both misses report identically. Converting
one into the other is exactly what #923 retired, and it was retired in several
places at once:
TransportProtocol.get
and Criticality.get had each turned the base’s KeyError into a
ValueError, and FastBindingAcknowledgmentStatus.get raised
EnumValueError for a name miss, so that the two ways
of getting it wrong reported identically.
One more thing is not visible from the exception class, and it is deliberate:
whether a miss is reported. Every miss get reports is quiet – by name
or by value, whether default answers it or it is raised – while register
refusing the same value is loud. Quiet means what
BaseError’s quiet=True means: nothing is
logged and no exception hook is installed. A raised miss is still an ordinary
exception that reaches the caller; left uncaught, it is printed by whichever hook is
installed, the interpreter’s own unless an earlier loud error installed
pcapkit’s.
The name miss is raised with
quiet=True. It is in-library control flow at several call sites, and atMethod.getit is part of a successful call – that override catches it in order to mint.The value miss is quiet with or without a usable
default. The owner ruled for that on #1845, on the ground thatgetis mostly called from protocol parsing, where a value that does not resolve is input for the caller to handle rather than a fault to report.getruns_validate_valueandcls(key)in a block declaring that it catchesValueErroritself, so the loudEnumValueErrora generated override raises is constructed quietly without the override passingquiet=True, and the errorgetconverts fromaenum’s own is raised quiet as well. An error of any other class is not a lookup miss, and stays loud.register()stays loud, even when reached from inside such a block: refusing a mint the caller explicitly asked for is a fault to report, not a lookup that missed.
A loud miss would put a logging.CRITICAL record on every lookup that misses
– the #362 defect quiet exists for – and, outside development mode,
install pcapkit’s exception hooks. So catching either miss as control flow, in
a get override or anywhere else, follows the convention and silences nothing.
get Override Obligations¶
Rulings settled what an override owes
get(), and the last of them deleted
overrides outright. They are collected here because each was reached by the same
argument: the base’s contract is house-wide, so an override that diverges from it is
a defect rather than a local judgement about its own callers. The first item below is
not a ruling but a language constraint, recorded with them because the rulings
all presuppose it.
An override that delegates is a @classmethod, and this one is forced by
Python rather than decided. Zero-argument super binds the enclosing
function’s first positional parameter as its instance, whatever that parameter
is named – so inside @staticmethod def get(key, default=NO_DEFAULT) it binds
key, the lookup key, and return super().get(key) fails on the key rather
than reaching the base. On Python 3.13 and newer:
TypeError: super(type, obj): obj (instance of str) is not an instance or
subtype of type (FastBindingAcknowledgmentStatus).
and on 3.10 through 3.12, where CPython words it differently and names neither the instance nor the type:
TypeError: super(type, obj): obj must be an instance or subtype of type
Both forms share instance or subtype of type, which is the only part of the
message anything here relies on – pinning either full sentence would fail on some
of the supported versions.
Corollaries worth stating, because the obvious summary of this is wrong in both
directions. RuntimeError: super(): no arguments is a different failure, raised
only when the enclosing function takes no parameters at all – no real get
override qualifies, since every one takes key. And the TypeError is not
guaranteed either: pass a first argument that is an instance of the class and the
delegation silently succeeds, so a @staticmethod override cannot even be
relied on to fail loudly. Measured, every case, rather than reasoned about.
get() is itself a @classmethod. The
precedent is #903, which made
FEATCode.get a @classmethod def
get(cls, key, default=NO_DEFAULT) ending in return super().get(key, default);
#908 followed it, which is what turned
Method.get into a classmethod.
Callers cannot see the switch – Method.get('X') binds identically either way –
so there is no compatibility argument for keeping the @staticmethod.
Command’s override made the same switch on
#1824, once it delegated a non-str key to the base instead of calling
key.upper() on it.
OptionType’s @staticmethod override
does survive and is still correct: it never calls super() at all, so it does not
meet the condition.
Raise the way the base raises, which means quiet=True.
#933 asked whether two
overrides raising EnumKeyError without
quiet=True should adopt the base’s. The owner first declined, then reversed
course: they should follow the house convention and not be loud.
Both answers are on the issue deliberately, and the reversal is the ruling. What it
settles is not the one keyword – it is the tie-breaker. A loud
BaseError logs a logging.CRITICAL
record, the #362 hazard, and outside development mode installs
pcapkit’s exception hooks process-wide, so loudness is
paid for by the whole library rather than by the override’s own callers. The argument
against changing them was that quiet=True exists on the base for a name miss
inside a successful call at Method.get
and these two had no such caller; uniformity beat it, because a per-class judgement about
present callers cannot price a process-wide effect.
A signature the base advertises has to be honoured, not suppressed. Re-parenting
onto EnumLookup gave those same two classes an
inherited two-argument get(key, default) that their one-argument overrides then
refused:
FastBindingAcknowledgmentStatus.get('bogus', 'Handover_Accepted')
TypeError: get() takes 1 positional argument but 2 were given
A # type: ignore[override] # pylint: disable=arguments-differ pair hid the
mismatch from mypy and pylint, and both docstrings disclosed it in prose
instead. Put to the owner on
#935 as one of three options
– widen and delegate, refuse default explicitly with an in-library error, or
leave the disclosure as the settled answer – the first was the ruling. So
a suppression plus a docstring is not an answer to a contract the class advertises
and breaks. The check that the suppression was load-bearing is the one to repeat
before believing any such pair: stripping these two yielded
Signature of "get" incompatible with supertype "EnumLookup" [override] at both
lines, with --warn-unused-ignores reporting neither as unused.
And an override that only reimplements the base is deleted, not repaired. Widening those two signatures made them faithful copies of the base. Rather than merge them, the owner asked, in review of that same widening, why the two overrides needed to exist at all, if they could simply fall back to the base’s.
They could. Nine cases per class – name hit, name miss, value hit, value miss and
every default combination – differed from EnumLookup.get.__func__(cls, ...)
in zero of them, and neither class carried an alias (__members__ 6 and 4,
list(cls) 6 and 4) for the “Backport support for original codes” in their
docstrings to refer to. Offered the choice between merging the widened copies and
deleting them in a follow-up, the owner ruled for deleting them outright. Both
get methods and both suppressions went with it, and
pcapkit.protocols.internet.mh defines no get at all.
The one input where the copies did differ is why this matters beyond line count,
and it is the trap for whoever writes the next override: the base branches on
isinstance(key, str) and treats everything else as a value, while those two
branched on isinstance(key, int) and fell through to the name path for anything
else. get(None) therefore raised a quiet
EnumKeyError on those two and a loud
EnumValueError on the other
locally-defined helpers in those two modules, and no prose
anywhere said so. Re-implementing the dispatch is how an override acquires a
divergence nobody wrote down; delegating to it is how it does not.
Taken with the Criticality.get deletion below – an override emptied by #923
rather than by redundancy – the rule generalises: an override justifies itself by
what it adds to the base, and goes when the answer is nothing.
RFC-Directed Case Sensitivity¶
The rule, as the owner ruled it on #877: where the RFC states the values are case-insensitive, the enumeration treats them that way too; otherwise it treats them as case-sensitive.
And the reason a registry’s spelling is never quietly normalised, from the same thread:
an enumeration honours and keeps the original writing the registrar used, and
case-insensitivity applies only to the selected registries where it makes logical sense
– TransportProtocol being one – or where the
RFC documentation itself recognises the values as case-insensitive, FTP and HTTP commands
being the candidates the owner named. Only FTP survived the audit below:
RFC 9110 Section 9.1 makes the HTTP method token case-sensitive outright.
So get() is case-sensitive, and that is the
default every enumeration gets. Case-insensitivity is a per-class get override that
has to cite the RFC or IANA registry making the values case-insensitive; without one it
is a defect rather than a convenience. That also means no public member is ever renamed
to make a lookup work – which is what keeps
Parameter’s R1_Counter = 128 and
R1_COUNTER = 129, two IANA-registered HIP parameters differing only in case, both
resolvable.
And a folding override carries only the fold.
TransportProtocol.get is
the worked example: since #923 it lowers key, forwards default verbatim
and delegates to super().get(), and that is all it does. It used to convert the base’s
name-miss KeyError into a ValueError as well, and #923’s ruling
retired that; the #808 refusal to extend the class at all is untouched by the
retirement, since only the exception class moved. Criticality.get went further and was
deleted: conversion was the only thing it added over the base, so once that went there
was nothing left for an override to hold, and the class inherits
get() unchanged. An override that would now be
empty is deleted, not kept as a pass-through – a get that only calls
super().get() reads as though it were doing something, and the next reader has to diff
it against the base to find out that it is not.
The Lenient Criterion, in Two Limbs¶
The ruling above leaves one question open, and
#903 settled it: does a
specification have to state a comparison rule for a registry to be treated
case-insensitively, or does it also count when the authorities merely disagree
about spelling? The owner ruled for the lenient reading, with
TransportProtocol as the example given: upper
and lower casings are used throughout the RFCs and IANA’s own data, and that mixed usage
is itself an indication of case-insensitivity.
So the test a new registry has to pass has two limbs, and satisfying either one justifies case-insensitivity:
A comparison rule in the governing document. RFC 959 Section 5 for FTP command codes, RFC 5797 Section 2 for FTP FEAT codes, RFC 6335 Section 5.1 for IANA service names.
A documented spelling disagreement between the specification and the registry. If the RFC writes a field one way throughout and the live IANA data writes it another, then neither authority is treating case as significant, and a lookup that does would reject a caller holding the spec’s own spelling.
Limb 2 has to be measured, not assumed – count the casings in the registry the crawler actually reads, and say how many rows carried each. A guess about which way IANA spells a column is not evidence.
Where neither limb holds, the lookup is case-sensitive and inherits
get() unchanged.
The Audit, per Class¶
#903’s sweep, so that a registry added later has something to check itself against. The owner set its scope: audit every registry first, and decide case-sensitivity per registry from that.
The population it covers is every EnumRegistry
subclass, every one of them under pcapkit.const and each either
int-valued (the flag registries among them) or aenum.StrEnum-valued,
plus every non-registry enumeration a runtime walk over both the enum and
aenum flavours finds, nested classes included – such as
FrameType.Flags in
pcapkit.protocols.schema.application.httpv2 and its concrete per-frame subclasses.
The int-valued tier is case-sensitive throughout, and the criterion
is vacuous on it rather than merely unmet. A registry whose values are numbers has
nothing for case to apply to; the only way a string reaches
get() on one is as a member name, and a name
is the Python identifier pcapkit.vendor.default.Vendor.safe_name() derives from
the registry’s own name column – it preserves the registrar’s casing exactly, but it
is not itself a value any specification states a comparison rule for. Measured across
every enumeration: only Parameter would
collide if names were folded, on R1_Counter against R1_COUNTER – and
no enumeration anywhere has two str values that
collide when folded. So folding names is not merely unjustified, it is unsafe in a
measured case; folding values is safe but unjustified except where the table below
says otherwise.
That leaves the classes with something to decide:
Class |
Governing source |
What it says |
Verdict |
|---|---|---|---|
“Upper and lower case alphabetic characters are to be treated identically.” Limb 1. |
case-insensitive – |
||
“IANA maintains uniqueness of feature names (FEAT codes) based on
case-insensitive comparison.” Limb 1. Limb 2 holds too: RFC 2389 recommends
upper case on the wire while the registry spells 5 of its 15 codes lower case
(measured: of 64 rows, 11 upper-case / 10 distinct, 52 lower-case / 5
distinct, 1 blank, 0 mixed). Read §3.2 to the end before concluding it
disagrees: it opens by calling the feature-label “nominally case
sensitive”, then defers to “the definitions of specific labels”, which
RFC 5797 §2 above is. Note also that the §2 sentence is wrapped across a
line break in the RFC’s text file, at |
case-insensitive – was a defect; |
||
“The method token is case-sensitive.” Explicitly the opposite of limb 1. |
case-sensitive – was a defect, fixed by #896 |
||
|
Nothing states a rule; the draft never discusses option-name case. |
case-sensitive – already exact-matches, conforms |
|
Nothing states a rule. Limb 2 fails on measurement: all 10 rows of the RFC’s table and all 10 of the live IANA CSV are upper case, so the authorities agree. (RFC 9850 notes the labels “correspond to lowercase labels in the TLS key schedule”, but those are a different document’s secret names, not a second spelling of the log label.) |
case-sensitive – no override, conforms |
||
Nothing states a rule for the |
case-insensitive – |
||
– |
Moot: its |
n/a – int-keyed |
|
“case is ignored for comparison purposes, so both “http” and “HTTP” denote
the same service.” Limb 1, emphatically – and these registries derive every
member name from the registry’s service-name column, in the registrar’s own
spelling ( |
unimplemented – no service-name lookup exists to fold; see below |
||
Limb 2 holds on measurement: the RFC and registry pages present the kind and
conformance letters upper case ( |
open – see below |
||
IANA Mobility Header registries, 3GPP TS 38.413 |
|
case-sensitive – conforms |
|
|
The draft names the labels outright (“The key type is one of
LOCAL_STATIC_PRIVATE_KEY, …”) and, as for
|
case-sensitive – no override, conforms |
|
Every other non-registry enumeration |
– |
Each is one of pcapkit’s own discriminators and bit labels, with no registrar behind
them at all – |
case-sensitive – nothing to cite, nothing to change |
Rows the audit deliberately left open rather than acting on, because each is wider than a case fix:
A service-name lookup on the
AppTypetransport registries. This is the inverse of every other row: RFC 6335 Section 5.1 does make service names case-insensitive, andTCP,UDP,SCTPandDCCPtake their member names from that column – butAppType.getrefuses a non-intkey, so no service-name lookup exists for the rule to apply to. Implementing one is new public API on a registry of thousands of members where one name maps to many ports, which is aget_alldesign question rather than a case fold.CommandTypeandConformanceRequirement. By parity withTransportProtocol– anint-valued enumeration whose names are the specification’s own tokens – the measured spelling disagreement above would make these two case-insensitive. Nothing looks them up by string, though: the crawler translates the CSV’s lower-case letters to the upper-case member names at generation time. Both classes inheritEnumLookup, since #930 completed phase 2’s re-parenting, so agetexists on each, case-sensitive like the base’s own. Whether to fold case to matchTransportProtocol’s own override is a design question for whoever writes the first string-keyed caller, not one this audit settles.
One case fold also lives outside any get, and so escapes this convention
entirely: _resolve in pcapkit.protocols.internet.esp upper-cases its
value before matching it against Cipher and
Integrity member names. RFC 7296 states no
comparison rule for IKEv2 transform names – checked, it does not discuss case at all
– so that fold is a convenience with no citation behind it. It is a protocol-level
resolver rather than a registry override, which is why the audit records it here
rather than changing it.
Note
get() checks _value2member_map_ when
the name lookup misses, so an aenum.StrEnum registry still resolves a valid
value that is not also a name:
>>> FEATCode['base'].value
'<base>'
>>> '<base>' in FEATCode._member_map_
False
>>> FEATCode.get('<base>')
<FEATCode [base]>
The example above is FEATCode’s shape, and it
still resolves exactly as shown – but since
#903 that class overrides
get too, so the output is only the base’s because its override delegates an
exact name-or-value hit straight through. Measure the base on a registry that does
not override get at all. These do –
Command,
FEATCode,
Method,
OptionType and
AppType – and probing one of those
measures the override rather than the base. The unconditionally clean witness is
tests/corekit/test_enum_lookup_base_unit.py’s own _Str, a purpose-built
closed set carrying angled = '<angled>' precisely so that the value
fall-through can be measured on a class that defines no get.
Command.get upper-cases its key
before matching, which makes it look as though the base were case-insensitive –
deliberately, since RFC 959 Section 5 treats FTP command codes identically
regardless of case. Method.get does not
fold case, as ruled on #896: RFC 9110 Section 9.1 says the HTTP method token
is case-sensitive, so Method.get('get') does not resolve to
Method.GET – it builds its own
unregistered member, preserving the caller’s exact casing, the same way an unrecognised
value always does.
What does survive is narrower and deliberate: a declared-but-unassigned str
value resolves through cls(value) but not through get(value), because
_unregistered_member() returns it without
growing either lookup table. FEATCode.get('ZZ-NOT-REAL') raises
EnumKeyError – which is a KeyError, so
an except KeyError around it is unaffected – while
FEATCode('ZZ-NOT-REAL') yields an unregistered member. Closing that gap would
mean calling cls(key) for a str value too, which reopens the minting hazard
above – so the asymmetry is intended, and get’s own docstring carries the full
reasoning.
See also
pcapkit.vendor generates these modules. A change to the shape of a
generated registry belongs in the crawler or in
pcapkit.vendor.default’s template, never in the generated file alone –
the next regeneration would discard it.