Architecture of coola.registry¶
This page describes the design of the
coola.registry package for contributors: the invariants, the contracts between the parts, and the trade-offs of the main design choices. The rules that users rely on are in the user guide; the API is in the reference.
Architecture¶
exceptions.py AlreadyRegisteredError(ValueError)
KeyNotRegisteredError(KeyError) <- TypeNotRegisteredError
registry.py Registry[K, V] storage: one dict, replaced on every write
| register, register_many, unregister, clear, copy
| get, has / in, [], len, iter, keys, values, items, repr
| writer lock, track_fork, pickle
v
type.py TypeRegistry[V](Registry[type, V]) dispatch
| resolve(dtype) cache -> MRO (no object) -> fallbacks -> object
| register_fallback / unregister_fallback / fallbacks()
| register_lazy groups of handlers for optional packages
v
domain HasherRegistry, EqualityTesterRegistry, SummarizerRegistry,
ChildFinderRegistry, TransformerRegistry (subclasses of TypeRegistry)
| has_<x>, find_<x> (one line each), the domain entry point
Two classes carry everything. A domain registry is a TypeRegistry (it inherits, it does not
wrap), so it has no _state wrapper and one display. The dataclass rule is a fallback predicate, not
a marker type.
State: replaced, never mutated¶
class Registry(Generic[K, V]):
def __init__(self, initial_state=None):
self._state: dict[K, V] = dict(initial_state or {}) # never mutated after publication
self._lock = threading.RLock() # writers only
track_fork(self)
def register(self, key, value, *, exist_ok=False):
self._validate_key(key)
with self._lock:
self._load_for(key) # a lazy default is seen by the exist_ok check
if key in self._state and not exist_ok:
raise AlreadyRegisteredError(self._already_registered_msg(key))
self._publish({**self._state, key: value})
def items(self): # a view of a dict that is never mutated is a snapshot
self._load_all()
return self._state.items()
- Readers take no lock: they read
self._stateonce. A mutation builds a new dict (O(n),nis about 50 and a registration is rare) and swaps the reference, which is atomic, so no method needs its own snapshot or invalidation logic. register_manybuilds one dict for the whole mapping and publishes it once: atomic by construction, including for readers._publish(state)assigns_stateand calls_changed(), a hook thatTypeRegistryoverrides to empty the cache. Three hooks exist for the subclass, all no-ops inRegistry:_validate_key,_load_for(key),_load_all().exist_okis keyword-only everywhere (register,register_many,register_fallback).
Fork and pickle¶
_reset_after_fork recreates the (only) lock. __getstate__ returns __dict__ without
_lock and _cache; __setstate__ recreates them and calls track_fork. Ten lines in
Registry; TypeRegistry adds nothing because its other attributes are immutable containers.
A registry holding lambdas (a predicate, a lazy factory) is not picklable, like any object holding
a lambda; the default registries hold module-level functions and "module:name" paths.
Dispatch¶
def resolve(self, dtype: type) -> V:
try:
return self._cache[dtype] # lock-free hit
except KeyError:
pass
with self._lock:
self._validate_key(dtype)
self._load_for(dtype)
value = self._lookup(dtype) # raises TypeNotRegisteredError
if len(self._cache) >= _MAX_CACHE_SIZE:
self._cache = {}
self._cache[dtype] = value # a miss is not cached: _lookup raised
return value
def _lookup(self, dtype):
state = self._state
for base in dtype.__mro__[:-1]: # most specific first; object is the last resort
if base in state:
return state[base]
for predicate, handler in self._fallbacks.items(): # registration order
if predicate(dtype):
return handler
if object in state:
return state[object]
raise TypeNotRegisteredError(self._not_registered_msg(dtype))
- The cache is a plain strong
dict, bounded (1,024 classes) and emptied by any change (register,unregister,clear,register_fallback, a lazy load). The miss path is under the lock, as the registration is, so a stale value cannot be written after a registration has emptied the cache. A program that creates classes dynamically keeps at most 1,024 of them alive (a few MB) until the next registration. A weak dictionary would not keep them, but is the more expensive structure; no cache at all is simpler by about 12 lines, but the dataclass fallback predicates would run on every lookup. - The fallbacks sit between the types and
object: a registered type always wins, then the first predicate that accepts the type, thenobject.objectis the last resort in every registry, so a handler registered forobjectnever hides a fallback.resolve(object)checks the fallbacks too (no predicate of the repository acceptsobject). A predicate also solves the virtual-subclass limit of the MRO lookup (a user can registerlambda t: issubclass(t, Sequence)). resolveis the only lookup name. The domain aliaseshas_<x>andfind_<x>are public API of five packages and read well at the call site: they are one-line delegations.
Fallbacks¶
TypeRegistry._fallbacks is a dict[Callable[[type], bool], V], replaced on write like _state.
| Method | Behaviour |
|---|---|
register_fallback(predicate, handler, *, exist_ok=False) |
Appends the pair. A predicate already registered raises AlreadyRegisteredError (exist_ok=True replaces it, and its position is kept). |
unregister_fallback(predicate) -> handler |
Removes the pair and returns the handler; KeyNotRegisteredError (a KeyError) if absent. |
fallbacks() -> ItemsView |
The (predicate, handler) pairs in order, as a snapshot. |
clear() |
Removes the types, the fallbacks and the pending lazy groups. copy() and repr include the fallbacks. |
The dataclass rule of each package is one registration in its get_default_registry:
registry.register_fallback(dataclasses.is_dataclass, DataclassSummarizer())
# equality: the dataclass must use the __eq__ generated by @dataclass
registry.register_fallback(has_generated_dataclass_eq, DataclassEqualityTester())
# hashing
registry.register_fallback(dataclasses.is_dataclass, DataclassHasher())
registry.register_fallback(_has_coola_hash_method, ContentHasher())
The dataclass handler is therefore not a visible "type" entry of items() / len().
Lazy groups (TypeRegistry only)¶
@dataclass(frozen=True)
class _LazyGroup:
packages: frozenset[str]
factory: str | Callable[[], Mapping[type, V]] # "package.module:name" is imported at load
self._lazy: tuple[_LazyGroup, ...] = () # replaced on write
register_lazy(packages, factory)appends a group. Two groups with the same package both stay; there is no alias table, so there is nothing to keep in sync._load_for(dtype): ifself._lazyis empty, return (the common case after warm-up). Otherwise compute the top-level modules of the classes ofdtype.__mro__and load the groups whosepackagesintersect them._load_all()loads every group (len, iteration,items,keys,values).- Loading runs under the writer lock: remove the group, call the factory,
setdefaulteach entry after_validate_key, publish. If the factory raises, the group is put back and the error propagates, so the next call fails the same way. - A lazy factory may call
registeron the same registry (the lock is reentrant). reprlists the pending groups by package name and loads nothing;copy()copies the tuple and loads nothing.register(key, ...),unregister(key),has(key),get(key)and[key]call_load_for(key), sohas(np.ndarray)does not depend on whether numpy was already looked up. Registering over a lazy default therefore needsexist_ok=True; this is documented.
Errors¶
class KeyNotRegisteredError(KeyError): # __str__ is not repr()'d
class TypeNotRegisteredError(KeyNotRegisteredError): ...
class AlreadyRegisteredError(ValueError): ... # the value is not acceptable in the current state
The message builders (Type '<class ...>' is not registered (MRO: ...). Registered types: ...) are
module-level functions of type.py.
What a domain registry looks like¶
class HasherRegistry(TypeRegistry[Hasher[Any]]):
def has_hasher(self, data_type: type) -> bool:
return self.has(data_type)
def find_hasher(self, data_type: type) -> Hasher[Any]:
return self.resolve(data_type)
def hash(self, data, length=64, ignore_unhashable=False): ...
The domain registry defines only the aliases and its entry point: __init__, copy, the fork reset,
the display, the fallbacks, clear and equal are inherited from the base classes.
equal¶
equal(other) compares two registries without any feature package:
def equal(self, other: object) -> bool:
if type(other) is not type(self):
return False
self._load_all()
other._load_all()
return self._state == other._state and self._fallbacks == other._fallbacks
- The states are replaced, never mutated, so the comparison needs no lock and no import of
coola.equality(soregistrystays a core package with no exception in the import-linter contract). - Handlers are compared with
==: a handler without__eq__is equal to itself only. Acopy()is therefore equal to its source (the handlers are shared). Two registries built separately with handlers of the same class are equal only if the handlers define__eq__. - There is no
equal_nanargument (it has no meaning for==).
Public API¶
from coola.registry import (
Registry, # Registry[K, V]
TypeRegistry, # TypeRegistry[V]
AlreadyRegisteredError,
KeyNotRegisteredError,
TypeNotRegisteredError,
)
| Class | Methods |
|---|---|
Registry |
register(key, value, *, exist_ok=False), register_many(mapping, *, exist_ok=False), unregister(key) -> value, clear(), copy(), get(key, default=None), has(key), [], []=, del, in, len, iter, keys(), values(), items(), equal(other) |
TypeRegistry |
all of Registry, plus resolve(dtype), register_fallback(predicate, handler, *, exist_ok=False), unregister_fallback(predicate), fallbacks(), register_lazy(packages, factory) |
Registering twice raises AlreadyRegisteredError (a ValueError) unless exist_ok=True, which is
keyword-only everywhere.
Dependents¶
| Package / file | Use |
|---|---|
equality/tester/registry.py, interface.py |
EqualityTesterRegistry(TypeRegistry[...]); the default registry registers the dataclass tester as a fallback on has_generated_dataclass_eq. find_equality_tester is resolve. |
hashing/registry.py, interface.py |
HasherRegistry(TypeRegistry[...]); the enum rule is _find_enum_hasher; hash calls the engine. The fallbacks are dataclasses.is_dataclass and the __coola_hash__ predicate. |
summary, iterator, recursive |
The same shape, with dataclasses.is_dataclass as the predicate of the dataclass fallback. |
jsonable |
TransformerRegistry of recursive; a dataclass is entered through the fallback. |
random/registry.py |
RandomManagerRegistry(Registry[str, BaseRandomManager], BaseRandomManager): a registry keyed by name, not by type. |
utils/singleton.py, testing/registries.py |
temporary_default_registries uses copy(); a copy contains the fallbacks. |
pyproject.toml (import-linter) |
registry is a core package: it imports no feature package. |
Design choices and trade-offs¶
One fallback mechanism instead of a marker type. Before the fallbacks, the dataclass rule needed a
marker type in the registry and an identity test. A predicate is one idea implemented once, and it also
solves the virtual-subclass limit of the MRO lookup. The consequence is that the dataclass handler is
not a visible entry of items(). Keeping a marker type would leave each registry with a second
mechanism.
A generic Registry is kept. RandomManagerRegistry uses it and TypeRegistry extends it, so it
costs nothing: it is the storage class. Making it private would only change the guide (a "plugin
system" or "factory" example is not what coola is for).
equal() is kept. It is the documented way to compare two registries, it needs no feature
package, and the alternative (dict(a.items()) == dict(b.items())) forgets the fallbacks.
AlreadyRegisteredError is a ValueError. The value is not acceptable in the current state; it is
not a RuntimeError.
Registries are picklable. Ten lines remove a failure that only shows with spawn. The alternative
is to document that a registry is not picklable and to pass the arguments that build it.
A duplicate fallback predicate is an error. A silent duplicate would be appended and never reached.
Not covered¶
issubclasslookup. Not added toresolve: it would sendstrto the handler ofSequence(the reason in the current docstring). The fallbacks are the opt-in way.- Entry-point plugins: how plugins are listed or disabled is a question of the packages that load them, not of the registry.
- Scoped overrides (
temporary_default_registries): incoola.utils.singleton. - Handler protocol check at registration. The registry stays generic; the domain registries could validate their handlers, which is a separate item.
- A registry shared between processes. Out of scope; a copy per process is the model.
- The per-call caches of the engines (
recursive,iterator,equality,hashing): they belong to the engines; the registry cache makes the lookup they call cheap.