Parent registries and overrides¶
Registries nest. A child registry answers what it can locally and falls back to its parent for the rest — and local registrations shadow the parent’s. That gives you layered wiring: application services in a long-lived parent, request- or job-specific services in a short-lived child; or production wiring in the parent and test doubles in a child.
from action0.service import Registry, Scope
class Database:
def __init__(self, dsn: str = "sqlite://"):
self.dsn = dsn
class Repository:
def __init__(self, db: Database):
self.db = db
app = Registry()
app.register(Database)
app.register(Repository, scope=Scope.TRANSIENT)
request = Registry(parent=app)
request.register_instance(Database("request://"), replace=True)
request.get(Repository).db.dsn # 'request://' — child wiring wins
app.get(Repository).db.dsn # 'sqlite://' — parent unaffected
Who builds where¶
Two rules make layering predictable:
Scope state lives on the owner. A singleton registered in the parent is one instance, shared by every child; a child cannot accidentally get its “own copy” of a parent singleton.
Cached instances are built in the owner’s context. When a child triggers the first construction of a parent-owned singleton, its dependencies resolve against the parent’s registrations — never against the child’s. A shared instance must not capture wiring from whichever short-lived child happened to request it first.
Non-caching scopes (transient, and custom scopes with
caches = False) are the mirror image: every request builds a fresh,
unshared instance, so dependencies resolve through the requesting
registry and pick up the child’s shadowing — as Repository does in
the example above.
get_all() follows the layering too: parent registrations come first,
then local ones, in registration order.
Overrides, for tests¶
override() temporarily
replaces a service — by type or by name — for the duration of a with
block. While active, the override wins every lookup, including
injections into other services being built:
class FakeDatabase(Database):
pass
registry = Registry()
registry.register(Database)
registry.register(Repository, scope=Scope.TRANSIENT)
with registry.override(Database, FakeDatabase()):
repository = registry.get(Repository)
assert isinstance(repository.db, FakeDatabase)
Type overrides deliberately skip any isinstance check — injecting a
Mock is the whole point. Overrides nest; the innermost wins.
Two caveats, both consequences of caching:
Instances cached before the override keep their real dependencies — an already-built singleton is not rewritten.
Conversely, a singleton first built during the override keeps the replacement after the block ends.
In tests, prefer a fresh registry — or a child registry — per test over overriding in a long-lived one. And override on the registry that owns the service: parent-owned cached services build against the parent’s world, so an override declared on a child cannot reach them (shadow with a child registration instead, as in the example at the top).
The process-wide default registry¶
Explicit registries compose better and are easier to test — pass them
around where you can, especially in library code. But an application or
script often has exactly one registry anyway, and for that case
default_registry() holds a
process-wide instance, created lazily on first access:
from action0.service import default_registry
default_registry().register(Database)
default_registry().get(Database).dsn # 'sqlite://'
It is never closed automatically; closing it is the application’s
responsibility. set_default_registry()
installs a replacement and returns the previous instance — pass None
to reset, e.g. after closing, so the next access starts fresh:
default_registry().close()
set_default_registry(None) # next default_registry() creates a new one
Code under test that relies on default_registry() is exercised with
using_default_registry(), which swaps
in a replacement and restores the previous default afterwards — combine
it with a registry context manager so instances are disposed too:
from action0.service import Registry, using_default_registry
with Registry() as registry, using_default_registry(registry):
registry.register_instance(FakeDatabase(), provides=Database)
code_under_test() # sees the fake through default_registry()
Because this is process-global state, such tests cannot run concurrently with other tests touching the default registry.