Bindings¶
Container.bind registers a factory for a token under a chosen
lifecycle. It is the foundation every other piece of the
container builds on - the builder (4.3) groups bind calls into
a sealed unit, modules (4.4) package them as reusable bundles,
provider helpers (4.5) decorate factories with the right
lifecycle, constructor injection (4.6) inspects the factory
signature, and the config loaders (4.9-4.11) translate
external declarations into bind calls.
from tripack_contracts import Lifecycle
from tripack_container import Container
container = Container()
container.bind(Clock, make_clock)
container.bind(Cache, make_cache, lifecycle=Lifecycle.SINGLETON)
container.bind(Session, make_session, lifecycle=Lifecycle.SCOPED)
API¶
class Container:
@overload
def bind[T](
self,
token: type[T],
factory: Callable[..., T],
*,
lifecycle: Lifecycle = Lifecycle.TRANSIENT,
auto_inject: bool = False,
) -> None: ...
@overload
def bind[T](
self,
token: type[T],
factory: Callable[..., Awaitable[T]],
*,
lifecycle: Lifecycle = Lifecycle.TRANSIENT,
auto_inject: bool = False,
) -> None: ...
Two overloads keep typing precise: a sync factory yields a
container that returns instances directly via resolve; an
async factory yields one that returns instances via
await aresolve.
Sync vs async detection¶
bind auto-detects whether factory is a regular function
or an async def via inspect.iscoroutinefunction and routes
it to the matching Binding slot under the hood. No keyword
toggle is needed:
def make_clock() -> Clock:
return Clock()
async def make_clock_async() -> Clock:
return Clock()
container.bind(Clock, make_clock) # sync slot
container.bind(Clock, make_clock_async) # async slot (different binding)
A sync factory bound under a token is rejected by aresolve?
No - the runtime resolver handles a sync factory through both
paths: resolve calls it directly, aresolve calls it
directly too (no await needed). Conversely an async-only
factory raises ResolutionError when resolve is used, with
a message pointing at aresolve.
Idempotent re-bind¶
Re-binding the same (token, factory, lifecycle, auto_inject)
tuple is a no-op (delegated to the graph's idempotent
register). Any difference on those fields raises
BindingError at bind time, not at resolve time, so a
configuration mistake fails fast:
container.bind(Clock, make_clock) # OK
container.bind(Clock, make_clock) # OK, no-op
container.bind(Clock, other_make_clock) # BindingError: conflicting
container.bind(Clock, make_clock,
lifecycle=Lifecycle.SINGLETON) # BindingError: conflicting
Lifecycles¶
bind accepts any Lifecycle value; the actual caching
semantics live in the runtime (docs/runtime/resolver.md,
docs/runtime/scopes.md):
TRANSIENT(default) - fresh instance per resolve;SINGLETON- cached on the container's resolver, shared across all scopes;SCOPED- cached on the activeScope, distinct across scopes, requires an open scope when resolved.
Typed returns¶
The PEP 695 generic on bind and resolve preserves the
type of the bound token end to end. mypy strict sees:
container.bind(Clock, make_clock)
clock = container.resolve(Clock)
assert_type(clock, Clock) # static check, no runtime cost
A future refactor that loses the generic parameter would fail
mypy on the assert_type line before the consumer notices.
What auto_inject does (and does NOT do yet)¶
The keyword is accepted today and stored on the underlying
binding, but the actual constructor-injection wiring is added
in 4.6. Until then a binding with auto_inject=True behaves
like a normal one - the factory is called with the arguments
the framework would pass (none for now, since the runtime's
resolver invokes factories with no args).