Resolver¶
The Resolver is the smallest unit that answers the question
"what instance does this token map to?". It composes the three
pieces introduced earlier: the DependencyGraph registry, the
ResolutionContext stack, and the cycle detector.
API¶
class Resolver:
def __init__(self, graph: DependencyGraph) -> None: ...
def resolve[T](self, token: type[T]) -> T: ...
async def aresolve[T](self, token: type[T]) -> T: ...
Lifecycles¶
The resolver dispatches on binding.lifecycle for every call.
Two lifecycles are supported today; SCOPED raises
NotImplementedError and lands in 3.7.
Transient¶
Every call to resolve (or aresolve) invokes the factory and
returns a fresh result. There is no cache and no teardown
registration.
graph.register(Binding(token=Clock, factory=Clock))
a = resolver.resolve(Clock)
b = resolver.resolve(Clock)
assert a is not b
Singleton¶
The first call builds the instance and caches it; subsequent calls return the cached one. The cache check runs before the cycle-detection push, so a hit costs one dict lookup and does not touch the resolution stack.
graph.register(
Binding(token=Clock, factory=Clock, lifecycle=Lifecycle.SINGLETON)
)
a = resolver.resolve(Clock)
b = resolver.resolve(Clock)
assert a is b
A factory that raises does NOT poison the cache: the next call retries. A SINGLETON whose factory recursively resolves itself trips the cycle detector and leaves the cache empty.
Async-only SINGLETON, sync read¶
A SINGLETON registered with async_factory (no sync factory)
is normally rejected by sync resolve. Once aresolve has
constructed it, the cache hit on subsequent resolve calls
bypasses construction entirely and returns the cached instance.
This is a feature: async-built singletons are still readable
from sync code paths.
async def make_clock() -> Clock:
return Clock()
graph.register(
Binding(
token=Clock,
async_factory=make_clock,
lifecycle=Lifecycle.SINGLETON,
)
)
await resolver.aresolve(Clock) # constructs and caches
resolver.resolve(Clock) # cache hit, returns the cached one
Teardown propagation¶
Once you are done with the resolver (typically at container
shutdown), call close() or aclose():
resolver = Resolver(graph)
# ... resolve a few SINGLETONs ...
resolver.close() # sync teardown for every registered SINGLETON
# or
await resolver.aclose() # async teardown (awaits aclose, falls
# back to sync close for sync-only targets)
Both methods iterate the SINGLETON teardown registry in LIFO
order so dependents close before what they depend on, collect
exceptions into a single ExceptionGroup, and are idempotent
(a second call is a no-op via an internal _closed flag).
Resolver.close() skips async-only targets silently - reach
them via aclose() instead. SCOPED teardowns are owned by
their Scope and run on scope exit, not on resolver.close().
Teardown registration¶
When a SINGLETON is built and its instance exposes a close
method (sync) or aclose method (async), the resolver appends
it to an internal teardown list:
graph.register(
Binding(
token=ConnectionPool,
factory=ConnectionPool,
lifecycle=Lifecycle.SINGLETON,
)
)
pool = resolver.resolve(ConnectionPool)
assert resolver.teardowns() == (pool,)
teardowns() returns a tuple snapshot in registration
order, which is also construction order. The eventual
teardown propagation (3.9) will iterate this list in reverse so
dependents are closed before the services they depend on.
The classifier is structural (duck-typed): any instance with a
callable close or aclose attribute qualifies. TRANSIENT
instances are NEVER registered, even when they expose
close - their lifetime is the caller's responsibility, and
the runtime has no way to know when they go out of use.
This commit only collects the targets. The actual close /
aclose invocation, the LIFO ordering, and the cross-scope
propagation arrive in 3.9.
Sync vs async paths¶
# sync factory, sync resolve
graph.register(Binding(token=Clock, factory=Clock))
resolver.resolve(Clock) # returns a Clock
# sync factory, async resolve
await resolver.aresolve(Clock) # also returns a Clock
# async factory, async resolve
async def make_clock() -> Clock:
return Clock()
graph.register(Binding(token=Clock, async_factory=make_clock))
await resolver.aresolve(Clock) # awaited transparently
# async factory, sync resolve -> ResolutionError
resolver.resolve(Clock)
# -> ResolutionError: Binding for token <class 'Clock'> is async-only;
# use aresolve() to drive it.
aresolve accepts both factory shapes; resolve accepts only
the sync one. This asymmetry is intentional: a sync caller
cannot meaningfully await, so silently calling
asyncio.run inside would be the wrong default. The user has
to opt into async by reaching for aresolve.
Cycle detection across factory recursion¶
A factory that calls back into the resolver participates in the same cycle-detection stack as its caller. The check fires before the cycle ever recurses:
def make_clock():
return resolver.resolve(Cache)
def make_cache():
return resolver.resolve(Clock)
graph.register(Binding(token=Clock, factory=make_clock))
graph.register(Binding(token=Cache, factory=make_cache))
resolver.resolve(Clock)
# -> CircularDependencyError: Circular dependency detected: Clock -> Cache -> Clock
The same guarantee holds on the async path through aresolve
and async_factory. Each asyncio.Task started inside the
scope inherits its own copy of the ResolutionContext via the
backing ContextVar, so concurrent
asyncio.gather(resolver.aresolve(...), resolver.aresolve(...))
calls have independent stacks and do not see each other's
in-flight tokens.
Scope inheritance¶
resolve and aresolve look at current_context() first:
- if a scope is already open (because the caller wrapped a
block in
resolution_scope()or because a factory is recursing into the resolver), the existing context is reused; - if no scope is open, the resolver opens one for the duration of the call and tears it down on exit.
This is how a factory's recursive resolve(...) call shares
the in-flight stack with its parent call without the caller
needing to thread the context anywhere.
with resolution_scope() as ctx:
resolver.resolve(Clock)
# The factory ran inside `ctx`; cycle detection saw the parent's stack.
Lookup-then-guard ordering¶
resolve looks up the binding before opening the cycle-
detection frame. A missing token therefore raises
ResolutionError without ever pushing the token onto the
stack, keeping the context invariant intact ("every token on
the stack corresponds to a live, non-failing resolution
frame").
What this commit deliberately does NOT do¶
- No constructor injection. Factories are called with no arguments. The container layer in Phase 3 inspects factory signatures and resolves each parameter; the runtime stays agnostic.
- No caching. Only the transient lifecycle is wired up here. Singleton/scoped land in 3.6 and 3.7.
- No teardown. Even when a factory produces a
Closeable, this commit does not register it for cleanup. Teardown propagation lands in 3.9. - No thread-safety guarantee. A single
Resolverinstance is safe to call from a single thread or fromasyncio.Tasksiblings (thanks toContextVar), but two OS threads sharing a resolver and mutating its graph race.