Sentinel Objects

pcapkit.corekit.sentinels is the single, shared home for every module-level singleton sentinel this package defines for itself – a value whose only job is to be recognised by identity (value is SENTINEL), so that it can never be confused with a value a caller might legitimately pass.

Each of the three below used to live beside the one class that consumed it – NullType in pcapkit.corekit.module, NoValueType in pcapkit.corekit.fields.field and NoDefaultType in pcapkit.corekit.enum – until GitHub issue #911 moved all four definitions here, the private AbsentType/ABSENT included. Each original module keeps a re-export, so every existing from <module> import <name> keeps working unchanged.

class pcapkit.corekit.sentinels.NullType[source]

Bases: object

Type of NULL, the omitted-class_/module sentinel.

A distinct class rather than a plain str – which is what the registry helpers in pcapkit.foundation.registry.protocols and pcapkit.foundation.registry.foundation used to define, independently of each other – so that is comparisons against it mean what they say: no str a caller passes, including one that happens to spell '(null)' itself, can compare equal to this sentinel by identity. See GitHub issue #833.

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, so no caller – direct, or copy/pickle reconstructing an instance behind the scenes – can end up holding a second object that fails an is NULL check downstream. ModuleDescriptor.klass makes exactly that check, and a stricter guard that raises on a second call would be truer to “singleton” in the abstract, but it would also mean the module’s own NULL = NullType() below is the only call that is ever allowed to succeed – fragile for no real benefit, since nothing here needs rejecting a second construction, only preventing it from producing a distinct object.

That still leaves copy.deepcopy(), copy.copy() and pickle unhandled: none of them constructs a new instance by calling NullType() themselves, so the guard above never runs for them. Each is therefore given its own override below, rather than left to fall back to the default behaviour for a plain object:

  • copy.copy() and copy.deepcopy() check for __copy__()/__deepcopy__() before ever falling back to reduction, so __deepcopy__() in particular has to be defined – its absence is the actual defect this class used to have: deepcopying a ModuleDescriptor recursed into this sentinel, reduced it, and rebuilt a second, non-identical NullType that then read as an ordinary attribute name to getattr(), downgrading a clean ProtocolError into a bare TypeError (attribute name must be string, not 'NullType').

  • pickle protocols 2 and up reconstruct through cls.__new__(cls), which the guarded __new__() already keeps to one instance – but protocols 0 and 1 reconstruct through copyreg._reconstructor(), which calls object.__new__() directly, bypassing __new__() entirely. __reduce__() is defined so that every protocol, not only the ones that happen to go through this class’s own __new__(), is routed through the same module-level getter instead of through reconstruction at all.

A caveat rather than a defect: importlib.reload() on this module re-executes NULL = NullType() below, producing a second singleton that the reloaded code compares against correctly but that every module which already imported the pre-reload NULL still holds – so a comparison spanning the reload sees two “singletons” that are not each other. ModuleDescriptor.klass faces exactly this class of problem for the class it resolves, which is why it re-reads sys.modules on every call rather than memoising; nothing equivalent is possible here, because unlike a resolved class there is no live registry this sentinel could be re-read from. The pre-#833 str sentinel had the same fragility for the same reason – it is a property of sharing one module-level binding across a reload, not something this class’s singleton guarantees claim to solve – and nothing in this package reloads pcapkit.corekit.sentinels after import.

A second caveat, specific to this class now living apart from its one caller-visible re-export: reloading pcapkit.corekit.module itself – rather than this module – no longer has any effect on the singleton at all. That module now only re-imports NULL and NullType from here, and re-running an already-satisfied from ... import reads the current binding in sys.modules rather than re-executing anything, so it neither mints a new instance nor loses the old one. The reload hazard this docstring describes moved with the class definition; it did not double.

pcapkit.corekit.sentinels.NULL = <NULL>

Sentinel for an omitted class_ argument to the register_* helpers in pcapkit.foundation.registry.protocols and pcapkit.foundation.registry.foundation. Housed here, alongside the package’s other sentinels, rather than in pcapkit.corekit.module where it used to live – per the owner’s ruling on GitHub issue #911, see the module docstring above. pcapkit.corekit.module keeps a re-export so every existing from pcapkit.corekit.module import NULL keeps working.

Type:

NullType

class pcapkit.corekit.sentinels.NoValueType[source]

Bases: object

Type of NO_VALUE, the default value for pcapkit.corekit.fields.

Housed here per GitHub issue #911 rather than in pcapkit.corekit.fields.field, where it used to be defined and where FieldBase.default still documents it as the field-default sentinel.

pcapkit.corekit.sentinels.NO_VALUE

Default value for FieldBase.default. pcapkit.corekit.fields.field keeps a re-export, since that attribute’s own documentation is a published contract naming this object. Renamed from NoValue to NO_VALUE by GitHub issue #937, which normalised all four sentinel objects to SCREAMING_SNAKE.

Type:

NoValueType

class pcapkit.corekit.sentinels.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.sentinels.NO_DEFAULT = <NO_DEFAULT>

The default argument value that means no default, i.e. let an unresolvable key propagate its lookup error rather than falling back. See NoDefaultType for why this is a dedicated class rather than a bare object, why it is a guarded singleton, and why it defines neither the copy/pickle hooks nor the __bool__ that its pcapkit.corekit siblings do. pcapkit.corekit.enum keeps a re-export, since EnumLookup.get names this object in its signature and docstring.

Type:

NoDefaultType

Note

AbsentType and ABSENT are private – never imported outside pcapkit.protocols.protocol, and named in no module’s __all__. Until GitHub issue #937, the leading underscore they carried (_AbsentType/ _Absent) hid them from Sphinx automatically, the way it hides every other _-prefixed name; dropping the underscore for SCREAMING_SNAKE consistency with the other three sentinels (see Naming a sentinel) means Sphinx would otherwise document them as though they were public. They are documented below instead, explicitly marked private, per the maintainer’s ruling on #937: “we can change _ABSENT to ABSENT just document it as private type/class in the documentation and not for public use is enough.” Neither is for use outside this package.

class pcapkit.corekit.sentinels.AbsentType[source]

Bases: object

Type of ABSENT, the absent-key sentinel.

A distinct class rather than a bare object so that the sentinel has a name of its own in a traceback or a debugger, and so that a type checker has something to name where object() would give it nothing. It follows NoValueType, which does the same job for an unset field default; this is a sibling of it rather than a reuse, since that one is documented as the default value of FieldBase.default and means “no value was given”, not “this key is not here”.

Defined here, alongside the package’s other sentinels, per the owner’s ruling on GitHub issue #911. Originally named _AbsentType/_Absent, with the leading underscore standing in for “private” – GitHub issue #937 normalised every sentinel object to SCREAMING_SNAKE and dropped it, so this pair now reads as CamelCase/SCREAMING_SNAKE like their two siblings and privacy is no longer signalled by the name at all. The owner’s ruling on #937, verbatim: “we can change _ABSENT to ABSENT just document it as private type/class in the documentation and not for public use is enough.” So this class and ABSENT stay exactly as private as they were: nothing outside pcapkit.protocols.protocol reads ABSENT, from here or from there, and neither this module’s nor that module’s __all__ names either one. This docstring, and the “Naming a sentinel” section of docs/source/contributing/conventions/sentinel-convention.rst, are what now records that fact in place of the leading underscore.

pcapkit.corekit.sentinels.ABSENT = <absent>

Absent-versus-None sentinel for pcapkit.protocols.protocol._declared_keywords() to read a class’s own __keywords__ out of its __dict__, where None is itself a meaningful value – the opt-out that says the class cannot enumerate its keywords, c.f. ProtocolBase.__keywords__. Never leaves pcapkit.protocols.protocol, which keeps a private re-export of it for exactly that one read. Private by convention and documentation only, not by a leading underscore – see AbsentType’s own docstring for why, per GitHub issue #937.

Type:

AbsentType