get(...) or default bugs, and absence checks that quietly eat legal values:
timeout = config.get("timeout") or 30 # a configured 0 becomes 30
if cached is None: # a cached None recomputes forever
cached = expensive()- Find the collision. Which legal value is doubling as "absent"?
(
None,0,"",-1are the usual suspects.) - Mint one named sentinel per meaning —
MISSING = Sentinel("MISSING")from this unit'spattern/, or a bare_MISSING = object()when a repr does not matter. One marker per meaning, not per call site. - Thread it through the boundary:
layer.get(key, MISSING)inside, a cleanValuetype outside. The sentinel should not leak into public return types — resolve it (raise, or apply the caller's default) before returning. - Check by identity —
value is MISSING, the rule the pattern lives by: equality is overloadable, and a type check (isinstance(value, Sentinel)) would swallow any other sentinel stored as a legitimate value. Undermypy --strictacastat the return keeps the public type clean; identity remains the semantic guard. - Upgrade chronic branching to a Null Object. If many callers test the
sentinel just to skip work, return a do-nothing implementation of the real
interface instead (
NullNotifierin the worked example).
from patterns.python.sentinel_object import MISSING, Sentinel
def get(self, key: str, default: Value | Sentinel = MISSING) -> Value:
for layer in self._layers:
value = layer.get(key, MISSING)
if value is not MISSING:
return cast("Value", value) # a stored None wins here
if default is MISSING:
raise KeyError(key)
return cast("Value", default)dict.get(key, MISSING)turns "key present?" plus "value None?" into one identity check.- A keyword default of
MISSINGdistinguishes "not passed" from "passed None" without**kwargsgames. __slots__and a__repr__on a tinySentinelclass cost three lines and make debugger output say<MISSING>instead of<object object at …>.
==instead ofis. Equality is overloadable; identity is the contract.value == MISSINGinvites a__eq__to lie.- Sentinels escaping the API. A public function returning
MISSINGforces every caller to import your marker — resolve absence at the boundary. - Pickle/copy round-trips. A copied sentinel is a different object; identity checks fail across process boundaries. Keep sentinels inside one process's logic (PEP 661 discusses the fix).
- In-band "improvements". Replacing the sentinel with
-1/""to avoid the import reintroduces the original bug one type away.
examples/layered_config/ resolves settings
CLI ← file ← defaults where a stored None means "explicitly disabled", and
hands back a NullNotifier so callers never branch. Run it:
uv run python -m patterns.python.sentinel_object.examples.layered_config.main