Enumeration Base

pcapkit.corekit.enum contains the two bases every enumeration in this library is meant to inherit from: EnumLookup, the bare lookup half shared by open registries and closed sets alike, and EnumRegistry, which adds the mutating half every generated enumeration under pcapkit.const inherits.

class pcapkit.corekit.enum.EnumLookup[source]

Bases: object

Bare lookup protocol, shared by open registries and closed sets alike.

Carries get(), get_all() and the _validate_value() guard – everything an enumeration needs in order to be read by name or by value, and nothing that could grow it. EnumRegistry adds the mutating half on top; a closed enumeration inherits this one directly and so is never handed a register it would have to refuse.

This is a plain mix-in rather than an Enum subclass, because an enumeration that already has members cannot be subclassed. Mixed in before the member type – class Foo(EnumRegistry, IntFlag), or class Bar(EnumLookup, IntEnum) – it contributes methods only, so aenum still resolves the member data type from the enumeration base: int for IntEnum and IntFlag, str for StrEnum. That is what lets one base serve all three, where a generated template fragment would have needed a separate rendering per member type.

Because both tiers are plain classes, inserting this one above EnumRegistry leaves the member data type exactly where it was: class Foo(EnumRegistry, IntFlag) resolves as Foo -> EnumRegistry -> EnumLookup -> IntFlag -> int -> ..., so _member_type_ still comes from the enumeration base and not from anything in this module. Had this tier subclassed Enum in order to “be an enum”, it would have become the member type itself and broken all three shapes at once.

The methods deliberately touch only _member_map_, _member_names_ and _value2member_map_, which both enum and aenum maintain, so nothing on this tier depends on aenum internals at all – the one extend_enum() call in this module belongs to EnumRegistry, which is the tier that mutates.

classmethod _validate_value(value)[source]

Hook: reject value if this enumeration’s contract does not allow it.

The owner’s requirement on GitHub issue #877, verbatim: “there must be some sort of range validation logic for the inherited classes to hook in.” This is that hook, and it is what the bare tier carries instead of register: what values are legal is something every enumeration has an opinion on, whereas who may add one is only an open registry’s concern.

The base implementation accepts everything, because a base cannot know any subclass’s range. Overriding it is how a subclass states one – the shape _missing_ spells by hand across the generated registries today:

@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__}')

An override raises or returns; it must never normalise. The return type is None deliberately rather than the validated value, so that this hook cannot become a converter: a subclass that returned a changed value here would silently alter what a lookup resolves to, which is exactly the case-folding the owner’s ruling on GitHub issue #877 rules out – “enum should honour and keep their original writings as in the registrars.” Case handling belongs in a deliberate get override with an RFC behind it, not in a validation hook.

Raise from pcapkit.utilities.exceptions, per the same issue’s ruling that in-library code raises in-library exceptions – EnumValueError is the fitting one and is already what the closed enumerations in pcapkit.protocols.internet.mh raise. Note what that buys on the get() path: EnumValueError subclasses ValueError, so a rejection here is caught by get()’s own except ValueError and falls back to default just as any other unresolvable value does. An override raising something outside that hierarchy would instead propagate past default, which is a real difference in behaviour rather than a stylistic preference. With no usable default, a rejection from this hook reaches the caller exactly as the override raised it – get() re-raises an in-library ValueError unchanged rather than re-wrapping it, so the override’s own message and the single log record it already emitted are what the caller sees.

Called from exactly two places, and the omissions are deliberate:

  • get(), immediately before cls(key) – the one point at which a lookup can reach a subclass’s _missing_ and mint. The str-key path does not call it, because that path never calls cls(key): it resolves against the already-populated lookup tables only, where every value present is legal by construction, so there is nothing left to validate.

  • EnumRegistry.register(), before minting – the one point at which a caller can introduce a value no member carries yet.

EnumRegistry.register_alias() does not call it, and does not need to: it refuses any value that is not already registered, so the value it aliases has necessarily passed validation already. EnumRegistry._unregistered_member() does not call it either, because its callers are the subclasses’ own _missing_ bodies, which already range-check before delegating here – validating again would double the check without being able to disagree with it.

Deliberately carries no Raises: clause, because this implementation raises nothing at all – an override is what raises, and documenting an exception here that this body cannot produce is exactly the phantom tests.test_docstring_contract.DocstringRaisesTests rejects. An override adds its own clause naming what it rejects.

Parameters:

value (Any) – Candidate value to check.

Returns:

Nothing. A value this enumeration allows is reported by returning normally; a value it does not is reported by raising.

classmethod _validate_value(value)[source]

Hook: reject value if this enumeration’s contract does not allow it.

The owner’s requirement on GitHub issue #877, verbatim: “there must be some sort of range validation logic for the inherited classes to hook in.” This is that hook, and it is what the bare tier carries instead of register: what values are legal is something every enumeration has an opinion on, whereas who may add one is only an open registry’s concern.

The base implementation accepts everything, because a base cannot know any subclass’s range. Overriding it is how a subclass states one – the shape _missing_ spells by hand across the generated registries today:

@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__}')

An override raises or returns; it must never normalise. The return type is None deliberately rather than the validated value, so that this hook cannot become a converter: a subclass that returned a changed value here would silently alter what a lookup resolves to, which is exactly the case-folding the owner’s ruling on GitHub issue #877 rules out – “enum should honour and keep their original writings as in the registrars.” Case handling belongs in a deliberate get override with an RFC behind it, not in a validation hook.

Raise from pcapkit.utilities.exceptions, per the same issue’s ruling that in-library code raises in-library exceptions – EnumValueError is the fitting one and is already what the closed enumerations in pcapkit.protocols.internet.mh raise. Note what that buys on the get() path: EnumValueError subclasses ValueError, so a rejection here is caught by get()’s own except ValueError and falls back to default just as any other unresolvable value does. An override raising something outside that hierarchy would instead propagate past default, which is a real difference in behaviour rather than a stylistic preference. With no usable default, a rejection from this hook reaches the caller exactly as the override raised it – get() re-raises an in-library ValueError unchanged rather than re-wrapping it, so the override’s own message and the single log record it already emitted are what the caller sees.

Called from exactly two places, and the omissions are deliberate:

  • get(), immediately before cls(key) – the one point at which a lookup can reach a subclass’s _missing_ and mint. The str-key path does not call it, because that path never calls cls(key): it resolves against the already-populated lookup tables only, where every value present is legal by construction, so there is nothing left to validate.

  • EnumRegistry.register(), before minting – the one point at which a caller can introduce a value no member carries yet.

EnumRegistry.register_alias() does not call it, and does not need to: it refuses any value that is not already registered, so the value it aliases has necessarily passed validation already. EnumRegistry._unregistered_member() does not call it either, because its callers are the subclasses’ own _missing_ bodies, which already range-check before delegating here – validating again would double the check without being able to disagree with it.

Deliberately carries no Raises: clause, because this implementation raises nothing at all – an override is what raises, and documenting an exception here that this body cannot produce is exactly the phantom tests.test_docstring_contract.DocstringRaisesTests rejects. An override adds its own clause naming what it rejects.

Parameters:

value (Any) – Candidate value to check.

Returns:

Nothing. A value this enumeration allows is reported by returning normally; a value it does not is reported by raising.

classmethod get(key, default=<NO_DEFAULT>)[source]

Resolve key to the canonical member.

A shortcut for the [] operation, per the ruling on #842: given a name it is cls[key], and given a value it is cls(key). Either way the answer is the canonical member – subscripting an alias returns the member the alias points at, not a separate object – so two names for one assignment resolve to one enum.

It never mints while resolving default; key may still mint through a _missing_ that GitHub issue #775’s ruling deliberately kept minting, on one registry (CGAType) – the ruling’s final round converted the other two it originally held out, EtherType and Socket, so they no longer mint on any path either. Registering a member any other way is register()’s job and nobody else’s, which is the ruling #775 exists to carry out: “so that we dont create registered enums out of unrecognised/unregistered values, unless user/caller explicitly created them”. A value inside a registry’s declared-but-unassigned range still resolves, through that registry’s own _missing_ and _unregistered_member(), to a member that is deliberately absent from the lookup tables – true outside the one registry named above, where such a value instead lands in both tables, exactly as register() would leave it – for a non-str key; the str case is qualified below. Both describe key resolution only. default never reaches _missing_ on either branch: a declared-but-unassigned default does not resolve to an unregistered member the way such a key does – it simply does not resolve, and the lookup error key itself would have raised propagates instead.

For a str key, a name match wins over a value match – the two are checked in that order, so a string that happens to be both a member’s name and a different member’s value resolves to the name’s member, matching what already happened for a name that resolves today. The value side of that check is a plain _value2member_map_ lookup, not cls(key): on a registry whose own _missing_ mints for an unrecognised value, routing a failed name lookup through the constructor would let a mere get() call mint a permanent member where it previously just raised. Defensive rather than observed: of the 127 classes that reach this method – 125 until GitHub issue #880’s own PR added pcapkit/const/ngap/procedure_code.py and pcapkit/const/ngap/protocol_ie.py, remeasured while auditing GitHub issue #903 – the str-valued ones (Command, FEATCode, Method, OptionType, AppType and its four transport subclasses TCP, UDP, SCTP and DCCP – completing that set as of GitHub issue #860’s own PR 2 – and, newest of them, TLSKeyLabel, which GitHub issue #877’s own thread reclassified from a hand-written helper to a generated registry once RFC 9850 §4.2 turned its member list into a live IANA registry) no longer mint on any path, so no live witness exists in this tree today. The one registry that still mints directly via extend_enum(), CGAType, is int-valued, so a str name could not reach its mint branch even if this restriction did not exist; it is not an exception to it, just not reachable by it. GitHub issue #775’s final round converted the other two that used to share this footnote, Socket and EtherType, so CGAType is now the only one left. This is about a future str-valued registry (or a present one whose _missing_ someday changes) reaching this base with a minting _missing_ of its own, which the restriction below is written to stay correct for regardless. Restricting the value side of key to an already-registered value keeps that side non-minting on every str-valued registry, not only the ones without a minting _missing_. Since #864, that is no longer merely a claim about the value side alone: default resolves through the same kind of _value2member_map_ lookup rather than cls(default), so for a str key every path through this method – name, value and default alike – is non-minting.

That restriction has a cost the paragraph above glosses over: a declared-but-unassigned value – the case resolved there through _missing_ and _unregistered_member() without either lookup table growing – is for that exact reason invisible to the _value2member_map_ check above. Such a value resolves through cls(value) but not through get(value) when value is a str; the non-str path below has no such gap, since it always calls cls(key) and so always reaches _missing_. Closing that gap here would mean calling cls(key) for a str value too, which reopens the exact minting hazard the paragraph above exists to avoid – so the asymmetry is deliberate, not an oversight.

The non-str path calls _validate_value() immediately before cls(key), which is the only point at which this method can reach a subclass’s _missing_, so a subclass that declares a range gets it checked before the constructor rather than after. The base hook accepts everything, so this changes nothing for a subclass that does not override it. A rejection raised as EnumValueError – or any other ValueError subclass – is caught by the same except that catches an ordinary failed construction, and so falls back to default on the same terms; the str path does not call the hook, for the reason given on _validate_value() itself.

Both failure paths raise from pcapkit.utilities.exceptions rather than a builtin, per the owner’s ruling on GitHub issue #923: “Either ``ValueError`` or ``KeyError``, that’s depending on how stdlib’s ``Enum`` would raise on these circumstances. And we should raise one from ``pcapkit.utilities.exceptions`` rather builtin exceptions.” The shape is unchanged by that ruling and deliberately so – a name miss stays KeyError-derived and a value miss ValueError-derived, matching E['nosuch'] and E(999) on a stdlib Enum, and matching the 119 of this tree’s 127 concrete subclasses that already answered a name miss that way. Only the provenance changed, so every except KeyError and except ValueError around a call to this method keeps catching.

Two details of that conversion are worth stating, since neither is visible from the exception type alone:

  • The name miss is raised quietly – EnumKeyError with quiet=True, so nothing is logged and sys.tracebacklimit is left alone. That is not a cosmetic choice: this method’s name miss is in-library control flow at six call sites, and at get() it is part of a successful call – that override catches it in order to mint. A loud error there would put a logging.CRITICAL record on every such call and set sys.tracebacklimit to 0 process-wide, which is exactly the GitHub issue #362 defect BaseError documents quiet for. The value miss takes no such fallback anywhere in this tree, so it stays loud.

  • An in-library rejection propagates unchanged. A ValueError that is already a BaseError – typically EnumValueError from a subclass’s _validate_value() – is re-raised as it stands rather than wrapped, so the subclass’s own message survives and the error is logged once instead of twice. Only aenum’s and enum’s own “no member carries this value” is converted. This is the same discrimination EnumField.post_process already makes for the same reason.

Parameters:
  • key (Any) – Name or value to look up.

  • default (Any) – An already-registered value to fall back to when key does not resolve. Resolved through a plain _value2member_map_ lookup, never through cls(default), so it cannot mint – see #864. NO_DEFAULT stands for no default; that and a default naming no registered member both fall through to the same lookup error key itself would have raised.

Return type:

Self

Returns:

The canonical member for key, or for default.

Raises:
  • EnumValueError – If a value does not resolve and there is no usable default. Also what a subclass’s _validate_value() rejection reaches the caller as, since that hook is documented to raise this very class and it is passed through rather than re-wrapped. A ValueError, so an except ValueError caller is unaffected.

  • EnumKeyError – If a name does not resolve and there is no usable default. A KeyError, so an except KeyError caller is unaffected.

classmethod get_all(key)[source]

Every member matching key, canonical first.

For a registry that maps one key to one member – which is every registry inheriting this base unmodified – that tuple holds exactly one entry, since an alias registered by register_alias() is a second name for the canonical member rather than a second member. The method still exists here, per the ruling that all four “should always exist on the const enums”, and it is where a registry with genuinely several matches puts them: AppType overrides it to return every service IANA assigns to a port.

Parameters:

key (Any) – Name or value to look up.

Return type:

tuple[Self, ...]

Returns:

The canonical member, followed by any further distinct member carrying the same value.

Raises:
class pcapkit.corekit.enum.EnumRegistry[source]

Bases: EnumLookup

Registry protocol shared by every constant enumeration under pcapkit.const.

EnumLookup above carries the read half – get(), get_all() and _validate_value(), all inherited here unchanged. What this tier adds is the half that makes a registry open: register(), register_alias(), register_aliases(), _extend() and _unregistered_member().

An enumeration inherits from here when it may grow at runtime, and from EnumLookup directly when it may not. The owner’s ruling on GitHub issue #877 is what draws that line, verbatim: “My initial thought was to make them immutable - unless RFC/IANA says otherwise.”

Mixed in ahead of the enum base exactly as before – class Foo(EnumRegistry, IntFlag) – and gaining EnumLookup as a parent does not disturb that: both tiers are plain classes, so _member_type_ still resolves past them to the enumeration base.

classmethod _extend(value, name)[source]

The raw extend_enum() call, shared by register() and register_alias().

Neither public method calls the other: register() now refuses an already-registered value before it would ever reach here, and register_alias() depends on the opposite of that – it verifies value is already registered and then relies on exactly the mint-or-alias behaviour this wraps to add name as a further name for the existing member rather than a new one. Routing both through this shared, ungated call is what keeps that behaviour available to register_alias() while register() still rejects it.

Parameters:
  • value (Any) – Value of the member, new or existing.

  • name (str) – Name to add.

Return type:

Self

Returns:

The member now reachable under name, new or existing.

Raises:

ValueError – If name is already taken. aenum reports that as TypeError; it is translated so that the ways one call can fail are one exception type.

classmethod _unregistered_member(value, name)[source]

Build a member absent from this registry’s own lookup tables.

Used by a registry’s _missing_ for a declared-but-unassigned value it resolves without anyone asking for a name, so that such a lookup no longer grows the registry – contrast register(), the explicit path that still does.

The member is constructed through cls._member_type_, which aenum sets from the enumeration base, so this serves int- and str-valued registries alike without either having to say which it is.

Parameters:
  • value (Any) – The member’s value.

  • name (str) – The member’s name.

Return type:

Self

Returns:

The unregistered member.

classmethod register(value, name)[source]

Mint a new member on this registry at runtime, under name.

The caller-named path, and the only one that grows the registry: “register mints new enum to the class at runtime with specified names - so we don’t have to guess blindly”. Contrast get() and _missing_, which resolve without naming anything.

Refuses a value that already has a member. Without this guard, extend_enum() does not mint anything for an already-taken value – aenum treats that as a request to alias the existing member under the caller’s name instead, silently: the call returns the existing member, name becomes reachable in __members__ pointing at it, and _member_names_ does not grow. That is register_alias()’s own effect, reached through the wrong method and with nothing raised to say so – exactly the “guess blindly” this method exists to rule out. Membership is tested against _value2member_map_ rather than by calling cls(value), for the same reason register_alias() tests it that way: a declared-but-unassigned value resolves through _missing_ to an _unregistered_member() absent from that table, so a successful call proves nothing about whether a member already exists.

Routes through _validate_value() before minting, so a subclass that declares a range gets it enforced on the caller-named path too and not only on the lookup one. The duplicate check runs first: a value that already has a member is legal by construction, so the actionable “use register_alias() instead” message is the better answer for it than a range complaint would be, and validation is left to guard only the genuinely new value that is about to be minted.

Parameters:
  • value (Any) – Value of the new member.

  • name (str) – Name of the new member. Required rather than derived, which is the whole point – a generated name is a guess.

Return type:

Self

Returns:

The newly registered member.

Raises:
  • ValueError – If value already has a member – use register_alias() to add a further name for it instead.

  • EnumValueError – If a subclass’s _validate_value() rejects value. The base implementation of that hook accepts everything, so this cannot arise on a registry that does not override it.

  • ValueError – If name is already taken. aenum reports that as TypeError; it is translated so that the ways one call can fail are one exception type.

classmethod _extend(value, name)[source]

The raw extend_enum() call, shared by register() and register_alias().

Neither public method calls the other: register() now refuses an already-registered value before it would ever reach here, and register_alias() depends on the opposite of that – it verifies value is already registered and then relies on exactly the mint-or-alias behaviour this wraps to add name as a further name for the existing member rather than a new one. Routing both through this shared, ungated call is what keeps that behaviour available to register_alias() while register() still rejects it.

Parameters:
  • value (Any) – Value of the member, new or existing.

  • name (str) – Name to add.

Return type:

Self

Returns:

The member now reachable under name, new or existing.

Raises:

ValueError – If name is already taken. aenum reports that as TypeError; it is translated so that the ways one call can fail are one exception type.

classmethod register_alias(value, name)[source]

Add name as a further name for the member already at value.

Per the ruling, an alias “adds additional alias(es) to a given enum’s mapping” – so it needs an enum to be given, and this refuses a value no member carries rather than falling through to register(). Asked whether that should hold generally, the maintainer’s answer was “actually i think it should always be for an existing member”, and on what an alias means away from AppType: “For non-AppType registries, ‘Alias’ is custom/caller-opt-in names, which are not recorded in IANA registrars”. Minting under the name of an aliasing call would manufacture exactly the unrecorded member #775 removes.

Membership is tested against _value2member_map_ rather than by calling cls(value): a declared-but-unassigned value resolves through _missing_ to an _unregistered_member() that is deliberately absent from that table, so a successful call proves nothing about whether a member exists.

An alias adds a name, not a member: __members__ grows by one while _member_names_, iteration and _value2member_map_ are untouched. Calls _extend() directly rather than register(), which would now refuse this call outright – register() and register_alias() test value’s membership for opposite outcomes, so neither can be the other’s implementation any more.

Parameters:
  • value (Any) – Value of the existing member to alias.

  • name (str) – Alias to add for it.

Return type:

Self

Returns:

The existing member, now reachable under name as well.

Raises:

ValueError – If no member carries value, or if name is already taken.

classmethod register_aliases(value, *names)[source]

Add several aliases for the member at value, left to right.

Parameters:
  • value (Any) – Value of the existing member to alias.

  • *names (str) – Aliases to add for it.

Return type:

tuple[Self, ...]

Returns:

One entry per name in names, each the aliased member.

Raises:

ValueError – As register_alias(). Names before the failing one stay registered – extend_enum() has no transaction to roll back, and undoing it by hand would mean reaching further into enumeration internals than anything else here does.

classmethod _unregistered_member(value, name)[source]

Build a member absent from this registry’s own lookup tables.

Used by a registry’s _missing_ for a declared-but-unassigned value it resolves without anyone asking for a name, so that such a lookup no longer grows the registry – contrast register(), the explicit path that still does.

The member is constructed through cls._member_type_, which aenum sets from the enumeration base, so this serves int- and str-valued registries alike without either having to say which it is.

Parameters:
  • value (Any) – The member’s value.

  • name (str) – The member’s name.

Return type:

Self

Returns:

The unregistered member.

Auxiliaries

class pcapkit.corekit.enum.NoDefaultType[source]

Bases: object

Type of NO_DEFAULT, the omitted-default sentinel for EnumLookup.get.

A dedicated class rather than a bare object, per the owner’s ruling on #859: “use dedicated class rather than bare object. Follow the house convention.” A bare object compares under is exactly as safely as a dedicated class with no __eq__ of its own does – identity comparison was never the problem an earlier revision’s docstring here overstated it to be. What a bare object actually lacks is a readable representation: it prints as <object object at 0x...> in a signature, in help(), and in a traceback, where NoDefaultType() – via __repr__() below – prints as <NO_DEFAULT>.

Named NoDefaultType for the class because that half of the house convention is settled: both NullType and NoValueType use <Name>Type. At the time, the instance’s own name was not similarly settled – the owner’s follow-up on #859 was explicit that NULL (SCREAMING_CASE) and NoValue (CapWords) disagreed, and “mainly depends on how we need it.” The need here was continuity: NO_DEFAULT was already the name on main – referenced in EnumLookup.get’s signature, its docstring, and both comparison sites – and that change was to what the sentinel is, not to what it is called, so it kept that name rather than being renamed to match either precedent’s instance casing for its own sake. NULL’s SCREAMING_CASE was the closer match regardless, since NO_DEFAULT was already spelled that way – and GitHub issue #937 later settled the question this paragraph left open: NoValue became NO_VALUE and _Absent became ABSENT, so every instance name now agrees on SCREAMING_SNAKE.

Genuinely a singleton, not merely a class this module happens to instantiate once: __new__() always hands back the one instance that already exists, rather than building a new one. That guards against a caller writing registry.get(key, default=NoDefaultType()) – perhaps not realising NO_DEFAULT already exists – and getting back a second, non-identical sentinel that silently fails is NO_DEFAULT inside EnumLookup.get, so their call is treated as supplying a real (if useless) default instead of the no default they meant. With the guard, NoDefaultType() always returns the one canonical NO_DEFAULT, so that mistake self-corrects.

Unlike NullType, this does not also define __copy__, __deepcopy__ or __reduce__ – but not because copy.deepcopy() or pickle “bypass” __new__(); they do not, for protocol 2 and above. object.__reduce_ex__() at protocol 2 reduces through copyreg.__newobj__(), which reconstructs by calling cls.__new__(cls) – exactly the guarded path above – so copy.copy, copy.deepcopy and every pickle protocol from 2 on already come back as the one canonical instance with no extra code. Measured:

>>> NO_DEFAULT.__reduce_ex__(2)
(<function __newobj__ at 0x...>, (<class '...NoDefaultType'>,), None, None, None)
>>> copy.deepcopy(NO_DEFAULT) is NO_DEFAULT
True

The one path __new__() cannot see is pickle protocol 0 (and 1), which reduces through copyreg._reconstructor() instead, and that calls object.__new__() directly:

>>> NO_DEFAULT.__reduce_ex__(0)
(<function _reconstructor at 0x...>, (<class '...NoDefaultType'>, <class 'object'>, None))
>>> pickle.loads(pickle.dumps(NO_DEFAULT, protocol=0)) is NO_DEFAULT
False

That gap is exactly what NullType’s own __reduce__() exists to close, because NULL is stored as a ModuleDescriptor field that a caller’s own copy.deepcopy() or pickle call can walk into and reconstruct. NO_DEFAULT is left unhandled here not because the gap cannot occur in principle, but because nothing in this package ever pickles it at protocol 0: it is reachable – from EnumLookup.get’s own bound parameter default (EnumLookup.get.__defaults__[0], or any subclass’s, e.g. Hardware.get.__func__.__defaults__[0]) and from inspect.signature(Hardware.get).parameters['default'].default – but neither is a field any object here gets pickled as, and both still survive copy.deepcopy() with identity intact regardless, because deep-copying either still reduces the sentinel itself through the same, guarded protocol-2 path measured above.

A caveat that turns out to be dormant rather than live, on the current tree – worth stating precisely rather than either repeating the older, inaccurate claim or dropping the topic. importlib.reload() on this module re-executes both class NoDefaultType: and NO_DEFAULT = NoDefaultType() below, producing a fresh, distinct object; any consumer that had already captured the pre-reload one – as a bound parameter default, say – goes on holding the stale one, and a bare held_default is NO_DEFAULT comparison against the post-reload global then reads False where it once read True. EnumLookup.get looked exactly like such a consumer before GitHub issue #864: an unrecognised, non-NO_DEFAULT value used to fall through to cls(default), so a stale sentinel handed to that call could raise a ValueError a caller had no reason to expect from an omitted argument. #864 closed a different hole – default could mint a new member – by replacing that call with a default not in cls._value2member_map_ guard, and the guard happens to close this one too: a NoDefaultType instance, stale or fresh, is never a registered enum value, so the guard’s not in half reads True for it either way and get re-raises the original lookup error correctly regardless of which NO_DEFAULT a caller’s stale default is stale against. Measured on the current tree, guard included:

>>> Hardware.get('Definitely-Not-A-Member')              # before reload
KeyError: 'Definitely-Not-A-Member'
>>> importlib.reload(pcapkit.corekit.sentinels)
>>> Hardware.get('Definitely-Not-A-Member')               # after reload
KeyError: 'Definitely-Not-A-Member'

So the docstring this class carried before GitHub issue #911’s move – which claimed the second call above raises ValueError – was already wrong on main at d31c0aaf6, independently of the move: it described the pre-#864 cls(default) call, and nobody had re-verified it against the guard #864 added afterwards. Fixed here as a drive-by correction, not a consequence of the housing change itself.

None of that makes the underlying hazard theoretical elsewhere in this package: -1 never had this failure mode at all, since -1 == -1 compares by value rather than identity, and reload staleness is a tracked defect class here for other constructs – see pcapkit.protocols.protocol.ProtocolBase._lookup_next_layer()’s own docstring note citing GitHub issues #425, #428 and #560, and tests.protocols.test_dispatch_default_resolution_unit’s own test_no_stale_class_survives_a_module_reload, which reloads a module deliberately to pin the fix for exactly that class of bug elsewhere. A future comparison site written the vulnerable way – a bare is NO_DEFAULT with no independent guard behind it, the way #864’s fix itself was not – would still reproduce it. “Nothing in this package reloads pcapkit.corekit.sentinels after import” remains true today, but it is a caveat to keep honest rather than a guarantee this class enforces.

Note

GitHub issue #911 also changes which reload is the one that matters, independently of the #864 finding above. pcapkit.corekit.enum no longer defines NoDefaultType itself; it only reads NO_DEFAULT off this module once, at its own import time, into its own module global. Reloading pcapkit.corekit.enum alone therefore now just re-runs that read, which – so long as this module has not also been reloaded – fetches back the identical object and changes nothing. Producing a fresh NO_DEFAULT at all now takes reloading this module, where the class statement lives; reloading only pcapkit.corekit.enum is not enough on its own, because from ... import binds a copy rather than a live alias, and that module’s own global stays pointed at whatever it read until something re-runs that import.

Also unlike both NullType and NoValueType, this deliberately does not define __bool__. Both of those model an absent value, so reading falsy in a boolean context is the point. NO_DEFAULT models something different: a marker meaning no default was supplied, checked exclusively by is at EnumLookup.get’s two comparison sites – nothing here ever evaluates it for truthiness. Giving it __bool__ -> False for symmetry with the other two would invite exactly the conflation this sentinel exists to rule out: code that writes if not default: instead of if default is NO_DEFAULT: would then read NO_DEFAULT the same way it reads a caller’s genuine falsy default – 0, '', None or False – which is the exact collision -1 used to cause under == and the reason #857 exists. Leaving __bool__ undefined makes NoDefaultType() truthy (the default for any object defining neither __bool__ nor __len__), which at least does not look like one of the falsy values it must never be mistaken for.

pcapkit.corekit.enum.NO_DEFAULT = <NO_DEFAULT>

Type of NO_DEFAULT, the omitted-default sentinel for EnumLookup.get.

A dedicated class rather than a bare object, per the owner’s ruling on #859: “use dedicated class rather than bare object. Follow the house convention.” A bare object compares under is exactly as safely as a dedicated class with no __eq__ of its own does – identity comparison was never the problem an earlier revision’s docstring here overstated it to be. What a bare object actually lacks is a readable representation: it prints as <object object at 0x...> in a signature, in help(), and in a traceback, where NoDefaultType() – via __repr__() below – prints as <NO_DEFAULT>.

Named NoDefaultType for the class because that half of the house convention is settled: both NullType and NoValueType use <Name>Type. At the time, the instance’s own name was not similarly settled – the owner’s follow-up on #859 was explicit that NULL (SCREAMING_CASE) and NoValue (CapWords) disagreed, and “mainly depends on how we need it.” The need here was continuity: NO_DEFAULT was already the name on main – referenced in EnumLookup.get’s signature, its docstring, and both comparison sites – and that change was to what the sentinel is, not to what it is called, so it kept that name rather than being renamed to match either precedent’s instance casing for its own sake. NULL’s SCREAMING_CASE was the closer match regardless, since NO_DEFAULT was already spelled that way – and GitHub issue #937 later settled the question this paragraph left open: NoValue became NO_VALUE and _Absent became ABSENT, so every instance name now agrees on SCREAMING_SNAKE.

Genuinely a singleton, not merely a class this module happens to instantiate once: __new__() always hands back the one instance that already exists, rather than building a new one. That guards against a caller writing registry.get(key, default=NoDefaultType()) – perhaps not realising NO_DEFAULT already exists – and getting back a second, non-identical sentinel that silently fails is NO_DEFAULT inside EnumLookup.get, so their call is treated as supplying a real (if useless) default instead of the no default they meant. With the guard, NoDefaultType() always returns the one canonical NO_DEFAULT, so that mistake self-corrects.

Unlike NullType, this does not also define __copy__, __deepcopy__ or __reduce__ – but not because copy.deepcopy() or pickle “bypass” __new__(); they do not, for protocol 2 and above. object.__reduce_ex__() at protocol 2 reduces through copyreg.__newobj__(), which reconstructs by calling cls.__new__(cls) – exactly the guarded path above – so copy.copy, copy.deepcopy and every pickle protocol from 2 on already come back as the one canonical instance with no extra code. Measured:

>>> NO_DEFAULT.__reduce_ex__(2)
(<function __newobj__ at 0x...>, (<class '...NoDefaultType'>,), None, None, None)
>>> copy.deepcopy(NO_DEFAULT) is NO_DEFAULT
True

The one path __new__() cannot see is pickle protocol 0 (and 1), which reduces through copyreg._reconstructor() instead, and that calls object.__new__() directly:

>>> NO_DEFAULT.__reduce_ex__(0)
(<function _reconstructor at 0x...>, (<class '...NoDefaultType'>, <class 'object'>, None))
>>> pickle.loads(pickle.dumps(NO_DEFAULT, protocol=0)) is NO_DEFAULT
False

That gap is exactly what NullType’s own __reduce__() exists to close, because NULL is stored as a ModuleDescriptor field that a caller’s own copy.deepcopy() or pickle call can walk into and reconstruct. NO_DEFAULT is left unhandled here not because the gap cannot occur in principle, but because nothing in this package ever pickles it at protocol 0: it is reachable – from EnumLookup.get’s own bound parameter default (EnumLookup.get.__defaults__[0], or any subclass’s, e.g. Hardware.get.__func__.__defaults__[0]) and from inspect.signature(Hardware.get).parameters['default'].default – but neither is a field any object here gets pickled as, and both still survive copy.deepcopy() with identity intact regardless, because deep-copying either still reduces the sentinel itself through the same, guarded protocol-2 path measured above.

A caveat that turns out to be dormant rather than live, on the current tree – worth stating precisely rather than either repeating the older, inaccurate claim or dropping the topic. importlib.reload() on this module re-executes both class NoDefaultType: and NO_DEFAULT = NoDefaultType() below, producing a fresh, distinct object; any consumer that had already captured the pre-reload one – as a bound parameter default, say – goes on holding the stale one, and a bare held_default is NO_DEFAULT comparison against the post-reload global then reads False where it once read True. EnumLookup.get looked exactly like such a consumer before GitHub issue #864: an unrecognised, non-NO_DEFAULT value used to fall through to cls(default), so a stale sentinel handed to that call could raise a ValueError a caller had no reason to expect from an omitted argument. #864 closed a different hole – default could mint a new member – by replacing that call with a default not in cls._value2member_map_ guard, and the guard happens to close this one too: a NoDefaultType instance, stale or fresh, is never a registered enum value, so the guard’s not in half reads True for it either way and get re-raises the original lookup error correctly regardless of which NO_DEFAULT a caller’s stale default is stale against. Measured on the current tree, guard included:

>>> Hardware.get('Definitely-Not-A-Member')              # before reload
KeyError: 'Definitely-Not-A-Member'
>>> importlib.reload(pcapkit.corekit.sentinels)
>>> Hardware.get('Definitely-Not-A-Member')               # after reload
KeyError: 'Definitely-Not-A-Member'

So the docstring this class carried before GitHub issue #911’s move – which claimed the second call above raises ValueError – was already wrong on main at d31c0aaf6, independently of the move: it described the pre-#864 cls(default) call, and nobody had re-verified it against the guard #864 added afterwards. Fixed here as a drive-by correction, not a consequence of the housing change itself.

None of that makes the underlying hazard theoretical elsewhere in this package: -1 never had this failure mode at all, since -1 == -1 compares by value rather than identity, and reload staleness is a tracked defect class here for other constructs – see pcapkit.protocols.protocol.ProtocolBase._lookup_next_layer()’s own docstring note citing GitHub issues #425, #428 and #560, and tests.protocols.test_dispatch_default_resolution_unit’s own test_no_stale_class_survives_a_module_reload, which reloads a module deliberately to pin the fix for exactly that class of bug elsewhere. A future comparison site written the vulnerable way – a bare is NO_DEFAULT with no independent guard behind it, the way #864’s fix itself was not – would still reproduce it. “Nothing in this package reloads pcapkit.corekit.sentinels after import” remains true today, but it is a caveat to keep honest rather than a guarantee this class enforces.

Note

GitHub issue #911 also changes which reload is the one that matters, independently of the #864 finding above. pcapkit.corekit.enum no longer defines NoDefaultType itself; it only reads NO_DEFAULT off this module once, at its own import time, into its own module global. Reloading pcapkit.corekit.enum alone therefore now just re-runs that read, which – so long as this module has not also been reloaded – fetches back the identical object and changes nothing. Producing a fresh NO_DEFAULT at all now takes reloading this module, where the class statement lives; reloading only pcapkit.corekit.enum is not enough on its own, because from ... import binds a copy rather than a live alias, and that module’s own global stays pointed at whatever it read until something re-runs that import.

Also unlike both NullType and NoValueType, this deliberately does not define __bool__. Both of those model an absent value, so reading falsy in a boolean context is the point. NO_DEFAULT models something different: a marker meaning no default was supplied, checked exclusively by is at EnumLookup.get’s two comparison sites – nothing here ever evaluates it for truthiness. Giving it __bool__ -> False for symmetry with the other two would invite exactly the conflation this sentinel exists to rule out: code that writes if not default: instead of if default is NO_DEFAULT: would then read NO_DEFAULT the same way it reads a caller’s genuine falsy default – 0, '', None or False – which is the exact collision -1 used to cause under == and the reason #857 exists. Leaving __bool__ undefined makes NoDefaultType() truthy (the default for any object defining neither __bool__ nor __len__), which at least does not look like one of the falsy values it must never be mistaken for.