NULL is a plain string, and the guards that test it use identity, so an equal-but-distinct '(null)' takes a different branch from the sentinel itself. Measured on #815's head ee51366a6:
NULL is: '(null)' (type str)
NULL == '(null)' -> True NULL is '(null)' -> False
register_apptype(port, Unit, class_=NULL) -> RegistryError: no transport protocol given … (absent)
register_apptype(port, Unit, class_='(null)') -> RegistryError: unknown transport protocol: '(null)' (present)
Both raise, so nothing is broken today — but which branch runs depends on string interning, not on the caller's intent, and that is the kind of thing that turns into a real defect the moment a branch stops raising. pcapkit/foundation/registry/protocols.py:143 is NULL = '(null)' with 13 uses, and pcapkit/foundation/registry/foundation.py:48 defines the same sentinel independently with 7 more — two definitions of one concept, so a change to either silently diverges from the other.
Two further consequences visible today:
The fix is a real sentinel — a module-level singleton whose type is not str, e.g. an enum member or a bare object() subclass with a __repr__ of <NULL> — defined once and imported by both registry modules, with the is comparisons then meaning what they say. Typing becomes class_: 'str | NullType' = NULL, and the omitted case can be distinguished from any string a caller passes.
Raised at the maintainer's request, in his words: "For the NULL value in the foundation registry, can we actually use a sentinel rather than a pure string? This is not in scope of this PR tho. File an issue and we can talk further."
Blocked on #815 merging, for ordering rather than scope: #815 rewrites register_apptype's signature and its class_ is not NULL guard is one of the sites this would change, so doing it first would conflict directly. It also wants deciding alongside #832, which fixes the other end of the same leak.
NULLis a plain string, and the guards that test it use identity, so an equal-but-distinct'(null)'takes a different branch from the sentinel itself. Measured on #815's headee51366a6:Both raise, so nothing is broken today — but which branch runs depends on string interning, not on the caller's intent, and that is the kind of thing that turns into a real defect the moment a branch stops raising.
pcapkit/foundation/registry/protocols.py:143isNULL = '(null)'with 13 uses, andpcapkit/foundation/registry/foundation.py:48defines the same sentinel independently with 7 more — two definitions of one concept, so a change to either silently diverges from the other.Two further consequences visible today:
strmodule andclass_omitted,'(null)'reachesgetattrand surfaces asAttributeError: module '…' has no attribute '(null)'— reported as though the caller had asked for a class of that name. That is the same symptom fix(registry): ModuleDescriptor.klass leaks a bare AttributeError, so all nine register_* call sites fail a bad class name with a stdlib error #832 covers from the other end.class_is typedstr, so'(null)'is a perfectly well-formed class name a caller could pass, and there is no way for the implementation to tell "omitted" from "asked for a class called(null)".The fix is a real sentinel — a module-level singleton whose type is not
str, e.g. anenummember or a bareobject()subclass with a__repr__of<NULL>— defined once and imported by both registry modules, with theiscomparisons then meaning what they say. Typing becomesclass_: 'str | NullType' = NULL, and the omitted case can be distinguished from any string a caller passes.Raised at the maintainer's request, in his words: "For the NULL value in the foundation registry, can we actually use a sentinel rather than a pure string? This is not in scope of this PR tho. File an issue and we can talk further."
Blocked on #815 merging, for ordering rather than scope: #815 rewrites
register_apptype's signature and itsclass_ is not NULLguard is one of the sites this would change, so doing it first would conflict directly. It also wants deciding alongside #832, which fixes the other end of the same leak.