Skip to content

Scopes

Container.scope() and Container.ascope() open a sync and an async lifetime Scope. Inside a scope, SCOPED bindings cache their instance for the duration of the block; on exit, every cached closeable instance is torn down in LIFO order.

The two methods are thin convenience wrappers around the runtime's lifetime_scope / alifetime_scope from docs/runtime/scopes.md. The container method exists so users who only import tripack_container have everything they need to drive a SCOPED workflow without reaching into the runtime module.

from tripack_contracts import Lifecycle
from tripack_container import Container

container = Container()
container.bind(Request, make_request, lifecycle=Lifecycle.SCOPED)
container.bind(Logger, make_logger, lifecycle=Lifecycle.SCOPED)

Sync scope

with container.scope() as scope:
    handler = container.resolve(RequestHandler)
    request = container.resolve(Request)
    # `handler` and `request` share the same scope's cache.
    ...

Inside the with, current_scope() returns the same Scope the block yields. SCOPED bindings cache there; on exit the sync close of every registered teardown target runs in reverse construction order. Async-only teardown targets are skipped silently; use ascope for them.

Async scope

async with container.ascope() as scope:
    handler = await container.aresolve(RequestHandler)
    request = await container.aresolve(Request)
    ...

Same semantics, with aclose awaited on exit when targets expose it (sync close is used as a fallback for sync-only ones). Each asyncio.Task opened inside the surrounding context inherits its own copy of the underlying ContextVar, so two coroutines launched under asyncio.gather each open and close their own independent scopes.

Teardown on the error path

Both scope and ascope honor the runtime's "teardown runs on exit even when the body raises" guarantee:

try:
    with container.scope():
        container.resolve(Pool)
        raise RuntimeError("body")
except RuntimeError:
    pass
# pool.close() ran during the scope's `__exit__`, before the
# RuntimeError propagated out.

A teardown that itself raises is collected and surfaced as an ExceptionGroup at the end - one failing target does not prevent the others from being closed.

Nesting

Scopes nest cleanly: the inner scope shadows the outer for its block duration, and the outer is restored on exit. Caches are independent:

with container.scope():           # outer scope
    a = container.resolve(Cache)
    with container.scope():       # inner scope - own cache
        b = container.resolve(Cache)
    c = container.resolve(Cache)  # outer scope cache restored
# a is c, but a is not b

Why this lives on the container

The runtime exposes lifetime_scope and alifetime_scope as free functions because the runtime layer itself doesn't own a Container. The container adds these methods as a discovery convenience: a user who has a Container in hand can open a scope without an extra import.