Naming a sentinel

A sentinel here is a module-level singleton 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. The house rule, from the maintainer, covers the type:

Keep the sentinel object’s type class naming as <SENTINEL>Type.

That is, the class takes the instance’s name in CamelCase with Type appended. It says nothing about the object’s own name, which is what let three casings diverge with no rule naming any of them wrong. GitHub issue #937 closed that gap, verbatim: “take SCREAMING_SNAKE and accept the breaking change (no backport needed).” So the object is named in SCREAMING_SNAKE, and the type-naming rule above derives from it mechanically – title-case each underscore-separated word and append Type, no per-sentinel exception needed. The four in the tree follow it:

Instance

Type

Defined in

NULL

NullType

pcapkit.corekit.sentinels

NO_VALUE

NoValueType

pcapkit.corekit.sentinels

NO_DEFAULT

NoDefaultType

pcapkit.corekit.sentinels

ABSENT

AbsentType

pcapkit.corekit.sentinels

All four used to live beside the one class that used them – pcapkit.corekit.module, pcapkit.corekit.fields.field, pcapkit.corekit.enum and pcapkit.protocols.protocol respectively. GitHub issue #911’s housing ruling, verbatim – “Okay one module for all four it is.” – moved the four definitions into the single shared module the table now names; each original module keeps a re-export so every existing from <module> import <name> keeps working, including the if TYPE_CHECKING:-only imports of the types.

Before GitHub issue #937, the instance name’s casing was deliberately free, which is why NULL and NoValue disagreed and both were called correct – three sentinels had already picked three different casings (NULL SCREAMING_SNAKE, NoValue CamelCase, _Absent CamelCase with a leading underscore) before anyone ruled on it. #937’s ruling closes that: SCREAMING_SNAKE is now the one answer, and the two renames it made – NoValue to NO_VALUE, _Absent to ABSENT – are the breaking change it accepted rather than deprecating. Where a sentinel name already exists and already follows SCREAMING_SNAKE, keep it; renaming a published sentinel again costs every caller for no further gain.

The rename also dropped the leading underscore _Absent/_AbsentType used to carry. ABSENT is private – it is read in _declared_keywords and discarded there, never leaving pcapkit.protocols.protocol – and the underscore used to be the mechanical signal of that. The maintainer’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 privacy is documentation-only from here on, carried by this paragraph and by AbsentType’s own docstring (pcapkit/corekit/sentinels.py, line 439), which still says so:

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 […]

It remains a deliberate fourth rather than an accident: the leading underscore’s absence is also why this table once listed three for as long as it did – a sweep filtered on capitalised names did not see _Absent – and that history does not change now that nothing in the name itself marks it out. When adding a sentinel, add it here whether or not it is public.

What reaches users is the object only. The maintainer’s ruling: “we should ONLY export the objects (like NULL ) to users” – so a public sentinel names its instance in its module’s __all__ and leaves the type out of it (GitHub issue #911). The type stays importable by its dotted path, for an annotation or an is guard; it is import * that no longer offers it. A private sentinel such as ABSENT is in neither, which is what private means here – dropping its leading underscore did not add it to either list, and AbsentType and ABSENT are documented on the sentinels API page as private and not for public use rather than left off it, since the name alone no longer says so.

Note

Of the four, only NullType is a full worked example. NoValueType follows the naming rule but is not a singleton (NoValueType() is NO_VALUE is False) and has no __repr__ of its own, so it demonstrates the name and nothing else; AbsentType has a __repr__ (<absent>) but no singleton guard either. Copy NullType when you need a pattern to follow.

Why a class and not object()

A bare object() is just as safe under is, so safety is not the reason. The reason is legibility: a dedicated class can define __repr__, and that repr is what appears in a signature, in help() output and in a traceback. Compare what inspect.signature() renders for a method whose default is the sentinel:

# bare object(): an address, different every process
default: 'Any' = <object object at 0x7fc393324cf0>

# dedicated type with __repr__
default: 'Any' = <NO_DEFAULT>

Warning

Do not justify a dedicated class by claiming a subclass “could still compare equal via a custom __eq__”. A class that defines only __repr__ inherits identity __eq__ and is exactly as safe as object(). That argument appeared in an early draft of pcapkit.corekit.enum and was wrong.

Ported code is exempt. _NOT_FOUND = object() at pcapkit/utilities/compat.py, line 73, sits inside the cached_property backport taken for interpreters below 3.8, which tracks CPython’s own functools implementation down to that name. It is not to be converted: the value of a vendored backport is that it can still be diffed against upstream, and a house-style rewrite destroys that in exchange for a sentinel nobody outside those forty lines ever sees. The rule above is for sentinels this package writes itself.

What to implement, and what not to

The four sentinels deliberately differ, and the differences are needs, not inconsistencies:

__new__ returning a cached instance

Guards against a caller constructing a second, non-identical sentinel that then fails every is check. Worth having wherever the type is reachable by a caller at all – which, since the type is kept out of __all__, means wherever it is importable by its dotted path rather than wherever it is star-exported. NullType documents the limit honestly: a module reload re-executes the class statement, so the guard does not survive one, and code holding the pre-reload instance will fail is. Since GitHub issue #911, that means reloading pcapkit.corekit.sentinels itself – reloading pcapkit.corekit.module, which now only re-exports the sentinel, no longer has any effect on it.

__bool__ returning False

NULL, NO_VALUE and ABSENT have it, because each stands for an absent value and reads naturally in a boolean test. NO_DEFAULT deliberately does not: it is a marker meaning no default was supplied, it is only ever tested with is, and making it falsy would invite if not default: – which would then treat a caller’s genuine falsy default (0, '', None, False) the same as the sentinel, the very confusion the sentinel exists to prevent.

__copy__ / __deepcopy__ / __reduce__

NullType has them because NULL is stored in a ModuleDescriptor field, so a caller’s copy.deepcopy() or pickle can walk into it and would otherwise reconstruct a second instance. NO_DEFAULT and ABSENT have none, because neither is ever stored in any structure a caller copies – one only ever appears as a default argument, and the other never leaves the module that reads it. Add them when, and only when, the sentinel becomes reachable from something copyable.