Resolution context¶
The ResolutionContext is the per-resolution scratchpad the
runtime maintains for one in-flight resolve() (or aresolve())
operation. It carries the stack of tokens the resolver is
currently working on - the data the cycle detector inspects to
spot A -> B -> A patterns - and serves as the anchor for scope
state and per-scope teardown registries added in later commits.
from tripack_runtime import (
ResolutionContext,
aresolution_scope,
current_context,
resolution_scope,
)
What it is, and what it is NOT¶
- It IS a small mutable object owned by the resolver for the
duration of a resolve call. External consumers see only the
tuple snapshot via
stackand a membership check viain. - It IS NOT a container of bindings. The
DependencyGraphis. - It IS NOT thread-shared. It lives behind a
ContextVar, so every sync thread and everyasyncio.Taskgets its own copy on entry.
The data model¶
class ResolutionContext:
__slots__ = ("_stack",)
@property
def stack(self) -> tuple[DependencyToken, ...]: ...
def __contains__(self, token: object) -> bool: ...
@contextmanager
def resolving(self, token: DependencyToken) -> Iterator[None]: ...
@asynccontextmanager
async def aresolving(
self, token: DependencyToken
) -> AsyncIterator[None]: ...
The stack property returns a fresh tuple each time it is read,
so a caller that captures it gets a frozen snapshot, never a
live view of the underlying list.
Push/pop with resolving¶
The only correct way to mutate the stack is through the
resolving / aresolving context managers. They push on entry
and pop on exit, including when the body raises, which is
what keeps the stack consistent with the call chain:
ctx = ResolutionContext()
with ctx.resolving(Clock):
assert Clock in ctx
assert ctx.stack == (Clock,)
with ctx.resolving(Cache):
assert ctx.stack == (Clock, Cache)
assert ctx.stack == (Clock,)
assert ctx.stack == ()
The async counterpart has identical semantics and is meant for
aresolve() paths:
The current context¶
Most callers do not construct a ResolutionContext directly.
They open a resolution_scope() (or aresolution_scope()) and
let the runtime do it for them. While the scope is open,
current_context() returns the active context; outside any
scope it returns None.
from tripack_runtime import current_context, resolution_scope
assert current_context() is None
with resolution_scope() as ctx:
assert current_context() is ctx
with ctx.resolving(Clock):
...
assert current_context() is None
The scope managers store the context in a module-level
ContextVar, then reset() the token on exit. That is the only
way the context becomes visible to the rest of the runtime
without being threaded as an explicit parameter through every
internal call.
Why ContextVar?¶
ContextVar is what makes async resolution sane. When you
launch two coroutines under asyncio.gather, each gets its own
copy of the variable. Their pushes do not leak across the
await boundary, even when both coroutines are resolving the
same factory concurrently:
async def worker(label: str) -> tuple[object, ...]:
async with aresolution_scope() as ctx:
async with ctx.aresolving(label):
await asyncio.sleep(0) # yield to the scheduler
return ctx.stack
stacks = await asyncio.gather(worker("A"), worker("B"))
assert stacks == [("A",), ("B",)]
A thread-local would not give us this. A global mutable container would not either - they would both let coroutine B observe coroutine A's stack mid-resolve.
Why no pop() / push() on the surface?¶
Manual push/pop is the easiest way to leave the stack in a
broken state: forget the pop after a raise, double-pop on a
nested unwind, push to a borrowed context from a different
thread. Restricting mutation to a with-style manager means the
unwind path is enforced by Python itself, not by discipline.
Why __slots__?¶
The context carries one attribute and is created once per
resolve. __slots__ = ("_stack",) keeps the per-instance
footprint small and prevents accidental attribute creation - a
typo on a setter, a misnamed assignment in a subclass - that
would silently add state to what is meant to be a small,
opaque, internal object.