Special dependency types
Clean IoC 2 keeps the runtime special surface small: Provider, AsyncProvider, ManagedProvider, AsyncManagedProvider, ResolutionContext, Scope, and
Container.
ParameterContext is related but is not a runtime dependency. Clean IoC passes it only to an explicit derive(...)
policy during build(). See argument policies.
Typed providers
Use Provider[T] when a known dependency should be created later rather than while its consumer is constructed:
from clean_ioc import Provider
class BatchRunner:
def __init__(self, units: Provider[UnitOfWork]):
self.units = units
def run(self, items):
for item in items:
self.units().process(item)
The compiler unwraps T, applies any select(...) policy once, validates the complete target graph, and stores a direct
reference to its frozen activation step. A provider call starts a fresh top-level resolution in the scope where the
handle was obtained. Transients are therefore new per call, per_resolution values are shared only inside one call,
and scoped and singleton targets retain their normal caches.
Use AsyncProvider[T] for a target that requires async resolution:
from clean_ioc import AsyncProvider
class Worker:
def __init__(self, repositories: AsyncProvider[Repository]):
self.repositories = repositories
async def run(self):
repository = await self.repositories()
Providers take no arguments. Their targets may be a closed service type or list[T], tuple[T, ...], or set[T].
They can also be resolved as roots, such as scope.resolve(Provider[Report]). A handle never performs registration or
candidate discovery, and calling it after its bound scope closes raises ProviderScopeClosedError.
For a named target, apply select(...) to the provider argument; the filter sees the target component rather than the
synthetic handle:
import clean_ioc.component_filters as cf
from clean_ioc import select
builder.register(
ClientSelector,
arguments={"client": select(cf.with_name("primary"))},
)
A singleton may retain a provider only when its deferred target contains no scoped component, scope slot, Scope, or
ResolutionContext edge. This rule keeps a provider from disguising captured request state.
Lazy provider maps
Declare a read-only Mapping[K, Provider[T]] when consumers select one named implementation on demand:
from collections.abc import Mapping
from clean_ioc import ContainerBuilder, Provider
class PaymentGateway:
pass
class StripeGateway(PaymentGateway):
pass
class PayPalGateway(PaymentGateway):
pass
class Checkout:
def __init__(self, gateways: Mapping[str, Provider[PaymentGateway]]):
self.gateways = gateways
builder = ContainerBuilder()
builder.register(PaymentGateway, StripeGateway, name="stripe", lifespan="scoped")
builder.register(PaymentGateway, PayPalGateway, name="paypal", lifespan="scoped")
builder.register_provider_map(PaymentGateway, key=lambda component: component.name)
builder.register(Checkout)
with builder.build() as container:
with container.new_scope() as scope:
checkout = scope.resolve(Checkout) # No gateway is activated.
gateway = checkout.gateways["stripe"]() # Only StripeGateway is activated.
The operation is available on ContainerBuilder, ScopeBuilder, and the ComponentBuilder bundle protocol:
register_provider_map(
service_type,
*,
key, # Callable[[Component], Hashable], required
key_type=str, # Explicit type used as K in the injection annotation
asynchronous=False, # True declares Mapping[K, AsyncProvider[T]]
component_filter=all_components,
name=None, # Name of this map registration
) -> str # Registration ID
Explicit provider-map groups
ProviderMapGroup creates an injectable provider-map contract with keyed contributes= entries. It is separate from ServiceGroup, which only selects registered decorator targets and has no map keys.
When a map must include only opt-in registrations, declare a ProviderMapGroup and add a contribution when
registering each target. This selection happens before target graphs are compiled, which is useful when another
implementation of the same service consumes the map itself:
from clean_ioc import ProviderMapGroup
delegated_gateways = ProviderMapGroup("delegated-gateways", str, PaymentGateway)
builder.register(PaymentGateway, StripeGateway, name="stripe", contributes={delegated_gateways: "stripe"})
builder.register(PaymentGateway, PayPalGateway, name="paypal", contributes={delegated_gateways: "paypal"})
builder.register_provider_map(delegated_gateways)
The group declares Mapping[str, Provider[PaymentGateway]]; contribution values become the map keys. A
contribution is additive metadata only: it does not affect ordinary resolution, names, collection membership, or
visibility. Thus a named contributor remains selectable with cf.with_name("stripe"), and an unnamed contributor
remains eligible for ordinary unnamed resolution. Duplicate contributed keys fail during build just like duplicate
callback keys. component_filter remains available and runs after the explicitly selected target graphs compile.
Supply key_type for non-string keys, including empty maps. The callback's return annotation is never inferred.
For example, key=lambda component: component.implementation_type, key_type=type declares
Mapping[type, Provider[PaymentGateway]]. Keys must conform to the declared key type and retain stable equality and
hash behavior. As with registered service values, runtime type checking is not added. Both collections.abc.Mapping
and typing.Mapping annotations work, including aliases nested around the map, key, provider, or target. NewType
targets and keys retain their nominal type identity.
For async targets, declare asynchronous=True, inject Mapping[str, AsyncProvider[PaymentGateway]], and call
await gateways["stripe"](). Acquiring either kind of map is synchronous; resolve_async() is also supported.
Every selected target graph is validated during build, so a sync map containing an async target fails immediately.
The target filter includes all visible eligible registrations by default, including named registrations. Use
component_filter=cf.with_name("stripe") to narrow entries. Entry iteration follows the existing candidate order:
newest registrations first within a layer, with overlay candidates before inherited ones. Keys are never sorted.
An empty target set yields an empty map; an absent runtime key raises normal KeyError.
Each declaration is an ordinary selectable transient map registration. Multiple declarations do not merge; name them
and use normal root filters or arguments={"gateways": select(cf.with_name("international"))} to select a map.
Those filters select map definitions, while component_filter selects their target entries. Ordinary dict injection
and eager mapping factories keep their existing behavior.
Map declarations require closed key and target types. They cannot be patched to a cached lifespan or given argument
overrides; invalid declarations report provider-map-invalid-declaration. Cache the consumer when appropriate: its
map handles still bind according to the consumer's validated ownership.
The required key callable receives compiled target Component metadata and runs synchronously during compilation,
once per selected occurrence within that map compilation. It must be pure: previews, diagnostic recompilation after
failure, retries, and fresh builds may evaluate it again. It never receives an activated service. Runtime acquisition,
iteration, and provider invocation do not rerun the callback or component filters. Acquisition shares the frozen
key-to-entry index and creates scope-bound provider handles without activating targets or rehashing keys.
Duplicate keys use Python dictionary equality semantics. Invalid or async callbacks/results, unhashable keys, and
callback/hash/equality failures produce structured build errors with provider-map-invalid-key,
provider-map-unhashable-key, provider-map-duplicate-key, or provider-map-key-evaluation. Errors omit computed
keys and callback exception text. Failed builds remain repairable.
Each provider call starts a fresh resolution and preserves the target's normal lifespan, decorators, and cleanup owner.
Singleton capture has the same restrictions and owner binding as an individual provider. Escaped handles fail with
ProviderScopeClosedError after their bound owner closes. Ordinary child scopes reuse the frozen topology; overlays
compile their visible targets while inherited singleton consumers retain their original maps. Boundary-local maps
select only targets visible within their declaring boundary, even when the map itself is exposed.
Graph tooling represents a provider_map node with one provider child per entry and each child's direct target.
Manifests include this node kind plus key_type and provider_mode metadata. The format is unversioned in this release candidate.
Computed keys and hashes are not added to manifests, explanations, or fingerprints. Ordinary component names and tags
retain their existing graph representation, including when a callback also uses them as keys. Changing only redacted
computed key values cannot be detected by the default fingerprint. Programs without maps keep their existing graph output.
ResolutionContext
ResolutionContext resolves an already-compiled root inside the active top-level resolve. It preserves per_resolution identity.
Prefer constructor injection. When the target type is known, prefer a typed provider. Use ResolutionContext or helpers
such as use_component(...) only when the dependency type itself must be selected dynamically.
from clean_ioc import ResolutionContext
class SenderSelector:
def __init__(self, context: ResolutionContext):
self.context = context
def select(self, premium: bool) -> Sender:
name = "premium" if premium else "standard"
return self.context.resolve(Sender, filter=cf.with_name(name))
ResolutionContext can only select frozen root plans. It cannot register, patch, decorate, provide slots, or compile.
Scope
Injecting Scope returns the current runtime scope. This is useful at framework boundaries that must create a nested cache boundary. Application services should normally depend on their actual collaborators.
Container
Injecting Container returns the immutable root container, even while resolving inside a child scope. It has resolution and scope-creation APIs but no composition APIs.
Parent precedence for deferred targets
An injected Provider[T] or AsyncProvider[T] first retains the maximum parent_precedence among eligible targets,
then applies the consumer's select(..., prefer=chain) and registration preference chains to remaining ties.
Two or more final survivors produce provider-ambiguous-component, even when lower-valued candidates also exist.
The selected target is frozen at build time. Provider acquisition and invocation do not compare values or replay
filters or preferences.
The provider is the target's immediate parent: a condition on its consumer must navigate through that node explicitly.
Provider[list[T]] and provider maps retain every eligible member in their existing order, and duplicate map keys still
fail. Marked provider root entrypoints and parentless requests through ResolutionContext, use_component, or its async
helper keep their existing selection rules. See parent precedence.
Managed resource providers
ManagedProvider[T] acquires a precompiled target in a fresh isolated scope when
its context manager is entered. Each call creates a single-use manager; build,
handle resolution and manager creation activate no target. The reusable handle
supports concurrent or nested acquisitions with independent scoped caches and
reverse-order cleanup.
from collections.abc import Iterator
from contextlib import contextmanager
from typing import assert_type
from clean_ioc import ContainerBuilder, ManagedProvider
class Session:
closed = False
def load(self) -> str:
assert not self.closed
return "materialized report"
@contextmanager
def session_factory() -> Iterator[Session]:
session = Session()
try:
yield session
finally:
session.closed = True
class ReportRunner:
def __init__(self, sessions: ManagedProvider[Session]):
self.sessions = sessions
def run(self) -> str:
with self.sessions() as session:
assert_type(session, Session)
return session.load()
builder = ContainerBuilder()
builder.register(Session, factory=session_factory, lifespan="scoped")
builder.register(ReportRunner, lifespan="singleton")
with builder.build() as container:
runner = container.resolve(ReportRunner)
assert runner.run() == "materialized report"
with container.resolve(ManagedProvider[Session])() as session:
assert not session.closed
assert session.closed
AsyncManagedProvider[T] has a synchronous __call__: use async with handle().
It supports both synchronous and asynchronous activation and cleanup plans.
ManagedProvider[T] requires synchronous initial activation and synchronous known
acquisition-owned cleanup; an incompatible
injected target or marked entry point fails build with
managed-provider-requires-async. This includes acquisition-owned async cleanup
reachable through an ordinary deferred provider. Independent managed/per-call
boundaries and parent singleton cleanup keep their own owners; resource-free
deferred async work remains permitted. An unmarked sync root rejects an
incompatible frozen plan before target activation at entry. Later operations
performed through an injected scope or unrestricted resolution-context lookup
are caller controlled; the captured graph cannot prove every future operation.
Use AsyncManagedProvider when those operations can acquire async resources.
import asyncio
from collections.abc import AsyncIterator
from typing import assert_type
from clean_ioc import AsyncManagedProvider, ContainerBuilder
class AsyncSession:
closed = False
async def session_factory() -> AsyncIterator[AsyncSession]:
session = AsyncSession()
try:
yield session
finally:
await asyncio.sleep(0)
session.closed = True
async def example() -> None:
builder = ContainerBuilder()
builder.register(AsyncSession, factory=session_factory, lifespan="scoped")
async with builder.build() as container:
sessions = container.resolve(AsyncManagedProvider[AsyncSession])
async with sessions() as session:
assert_type(session, AsyncSession)
assert not session.closed
assert session.closed
asyncio.run(example())
Targets support closed service types and list[T], tuple[T, ...] and set[T].
select(...), names, tags, aliases, generics, patterns, decorators, boundaries and
pre-configurations follow ordinary provider selection. Targets need not own a
resource. Entry executes the frozen plan without compilation or filter replay.
Collection members activate sequentially in their frozen registration order; separate acquisitions may run concurrently. Scoped dependencies share inside an acquisition and differ between acquisitions, even if the enclosing scope has already resolved them. Per-resolution values share inside the acquisition's initial resolve. Singleton targets and dependencies retain their declaring owner and are not finalized by acquisition exit. Supplied slot values inherit from the handle's bound scope; they remain application owned.
A singleton consumer can retain a managed handle for scoped resources. Its handle binds to the singleton owner and that owner's composition and provisions, even when the singleton is first resolved from a child or overlay. Ordinary providers retain their existing restrictions. Captive dependencies, runtime context checks and cycles inside a managed target are still build errors.
Keep the bound owner and any shared singleton owners open throughout the block.
An owner closed before entry causes ProviderScopeClosedError; an acquisition
does not extend its lifetime. If the parent closes during the block, exit still
cleans the acquisition's own resources. Body errors, failed entry and cancellation
run cleanup. A cleanup failure propagates with the original body or activation
error in __context__; multiple cleanup failures form the existing exception group.
Use the target only during the block. Return materialized application data: objects, closures and lazy results backed by a scoped resource may be invalid after exit. There is no escape detector or method proxy. A singleton target retains its ordinary longer lifetime.
Container-level marked managed entry points are supported. Boundary-local marked provider entry points retain the existing ordinary-provider restriction; use an exposed boundary target or a consumer entry point.
Graphs identify managed_provider as a deferred boundary, with provider mode,
target and scope_policy: per_call (a fresh scope per context entry). Ownership and
sharing reports distinguish acquisition scopes from shared singleton owners.
Direct dependency policies traverse the boundary; semantic diffs classify lifetime
boundary changes. Existing graph manifests remain unchanged; managed graphs use
the existing unversioned prerelease format.