Skip to content

Dependency Injection

ProviderRegistry, Scope, and lifecycle types.

di

Public DI declaration surface.

__all__ module-attribute

__all__ = [
    "LifecycleDetectionWarning",
    "ProviderLifecycle",
    "ProviderRegistry",
    "Scope",
]

ProviderRegistry

ProviderRegistry()

Mutable provider registry with seal guard and edge capture.

Structurally satisfies the taskq._di.types.ProviderRegistry Protocol without explicit inheritance — the Protocol is runtime_checkable for isinstance checks, but the concrete class does not inherit from it to avoid forcing runtime_checkable constraints on the implementation.

Source code in src/taskq/_di/registry.py
def __init__(self) -> None:
    self._providers: dict[type, ProviderEntry[object]] = {}
    self._dep_edges: list[tuple[type, type, Scope | None]] = []
    self._validated: bool = False
    self._sealed: bool = False
    self._plan_cache: dict[tuple[str, Scope], list[type]] = {}
    self._validating: bool = False

providers property

providers: dict[type, ProviderEntry[object]]

Shallow copy preventing external mutation.

Why: the internal map is dict[type, ProviderEntry[object]] per the erasure boundary. Returning a copy prevents callers from mutating registry state.

register_value

register_value(
    type_: type[T], scope: Scope, value: T
) -> None
Source code in src/taskq/_di/registry.py
def register_value[T](self, type_: type[T], scope: Scope, value: T) -> None:
    if self._sealed:
        raise RuntimeError("registry is sealed after validate()")
    if type_ in self._providers:
        raise ValueError(f"{type_!r} is already registered")
    entry = ProviderEntry(
        type_=type_,
        scope=scope,
        kind="value",
        impl=value,
        factory_shape=FactoryShape.VALUE,
    )
    self._providers[type_] = entry
    logger.debug(
        "provider-registered",
        type_name=type_.__qualname__,
        scope=scope.name,
        kind="value",
    )

register_factory

register_factory(
    type_: type[T], scope: Scope, factory: Factory[T]
) -> None
Source code in src/taskq/_di/registry.py
def register_factory[T](self, type_: type[T], scope: Scope, factory: Factory[T]) -> None:
    if self._sealed:
        raise RuntimeError("registry is sealed after validate()")
    if type_ in self._providers:
        raise ValueError(f"{type_!r} is already registered")
    if not callable(factory):
        raise TypeError(f"factory must be callable, got {type(factory).__qualname__}")
    if inspect.isasyncgenfunction(factory):
        shape = FactoryShape.ASYNC_GENERATOR
    elif inspect.isgeneratorfunction(factory):
        shape = FactoryShape.SYNC_GENERATOR
    elif inspect.iscoroutinefunction(factory):
        shape = FactoryShape.ASYNC_CALLABLE
    else:
        shape = FactoryShape.SYNC_CALLABLE

    lifecycle = detect_factory_lifecycle(factory)

    if lifecycle is ProviderLifecycle.SyncGenerator:
        warnings.warn(
            LifecycleDetectionWarning(
                f"{getattr(factory, '__qualname__', repr(factory))} is a sync "
                f"generator factory; its cleanup will run synchronously and "
                f"cannot be awaited by the DI engine."
            ),
            stacklevel=2,
        )
        logger.warning(
            "sync_generator_registered",
            factory_name=getattr(factory, "__qualname__", repr(factory)),
            scope=scope.name,
        )

    edges = _collect_dep_edges(factory, type_)
    self._dep_edges.extend(edges)

    entry = ProviderEntry(
        type_=type_,
        scope=scope,
        kind="factory",
        impl=factory,
        factory_shape=shape,
        lifecycle=lifecycle,
    )
    self._providers[type_] = entry
    logger.debug(
        "provider-registered",
        type_name=type_.__qualname__,
        scope=scope.name,
        kind="factory",
        factory_shape=shape.name,
    )

register_class

register_class(
    type_: type[T],
    scope: Scope,
    *,
    lifecycle: ProviderLifecycle | None = None,
) -> None
Source code in src/taskq/_di/registry.py
def register_class[T](
    self,
    type_: type[T],
    scope: Scope,
    *,
    lifecycle: ProviderLifecycle | None = None,
) -> None:
    if self._sealed:
        raise RuntimeError("registry is sealed after validate()")
    if type_ in self._providers:
        raise ValueError(f"{type_!r} is already registered")

    resolved_lifecycle = lifecycle if lifecycle is not None else detect_lifecycle(type_)

    if lifecycle is None:
        if resolved_lifecycle is ProviderLifecycle.SyncCloseable:
            warnings.warn(
                LifecycleDetectionWarning(
                    f"{type_.__qualname__} has close() but no aclose(); "
                    f"the DI engine will call close() via asyncio.to_thread "
                    f"at teardown. Prefer an async close (aclose) for "
                    f"async-native code."
                ),
                stacklevel=2,
            )
            logger.warning(
                "sync_close_registered",
                class_name=type_.__qualname__,
                scope=scope.name,
            )
        elif (
            resolved_lifecycle is ProviderLifecycle.Plain
            and hasattr(type_, "__enter__")
            and not hasattr(type_, "__aenter__")
        ):
            warnings.warn(
                LifecycleDetectionWarning(
                    f"{type_.__qualname__} has __enter__/__exit__ but no "
                    f"__aenter__/__aexit__; sync context managers are not "
                    f"supported by the async DI engine. The class will be "
                    f"managed as Plain (no teardown)."
                ),
                stacklevel=2,
            )
            logger.warning(
                "sync_context_manager_not_supported",
                class_name=type_.__qualname__,
                scope=scope.name,
            )

    edges = _collect_dep_edges(type_.__init__, type_)
    self._dep_edges.extend(edges)

    entry = ProviderEntry(
        type_=type_,
        scope=scope,
        kind="class",
        impl=type_,
        factory_shape=FactoryShape.CLASS,
        lifecycle=resolved_lifecycle,
    )
    self._providers[type_] = entry
    logger.debug(
        "provider-registered",
        type_name=type_.__qualname__,
        scope=scope.name,
        kind="class",
    )

has_provider

has_provider(type_: type) -> bool
Source code in src/taskq/_di/registry.py
def has_provider(self, type_: type) -> bool:
    return type_ in self._providers

get

get(type_: type[T]) -> ProviderEntry[T]
Source code in src/taskq/_di/registry.py
def get[T](self, type_: type[T]) -> ProviderEntry[T]:
    entry = self._providers.get(type_)
    if entry is None:
        raise MissingProvider(
            type_name=type_.__qualname__,
            required_by="<unknown>",
        )
    return cast(
        ProviderEntry[T], entry
    )  # Why: internal map is dict[type, ProviderEntry[object]] (erasure boundary); get[T] recovers the type parameter

validate

validate(
    actors: list[ActorRef[Any, Any]] | None = None,
    rate_limit_registry: RateLimitRegistry | None = None,
) -> None

Walk all providers and actors; raise on first error; seal on success.

Pure graph-walk over registration-time metadata. Never invokes a factory, calls a resolver, or performs await — the entire algorithm is synchronous introspection on _providers and _dep_edges.

actors: explicit list of ActorRef instances whose DI parameter annotations are walked for MissingProvider checks and plan-cache population. Defaults to None (no actor walk — only provider→provider edges are validated).

rate_limit_registry: when provided, each actor's rate_limits and reservations lists are checked against the registry's dicts. Unknown names raise MissingProvider at startup. When None (default), the name-check phase is skipped entirely.

Why: ActorRef[Any, Any] is the sanctioned erasure
boundary for the heterogeneous actor registry — each ActorRef has
different P and R type parameters, and validate() does not need
per-actor narrowing. The same erasure is used at the existing API
boundary in worker/run.py:283 and cli.py:98.
Source code in src/taskq/_di/registry.py
def validate(
    self,
    actors: list["ActorRef[Any, Any]"] | None = None,
    rate_limit_registry: "RateLimitRegistry | None" = None,
) -> None:
    """Walk all providers and actors; raise on first error; seal on success.

    Pure graph-walk over registration-time metadata. Never invokes a
    factory, calls a resolver, or performs await — the entire algorithm
    is synchronous introspection on ``_providers`` and ``_dep_edges``.

    actors: explicit list of ActorRef instances whose DI parameter
    annotations are walked for MissingProvider checks and plan-cache
    population. Defaults to None (no actor walk — only
    provider→provider edges are validated).

    rate_limit_registry: when provided, each actor's ``rate_limits``
    and ``reservations`` lists are checked against the registry's
    dicts. Unknown names raise ``MissingProvider`` at startup.
    When ``None`` (default), the name-check phase is
    skipped entirely.

    # Why: ActorRef[Any, Any] is the sanctioned erasure
    # boundary for the heterogeneous actor registry — each ActorRef has
    # different P and R type parameters, and validate() does not need
    # per-actor narrowing. The same erasure is used at the existing API
    # boundary in worker/run.py:283 and cli.py:98.
    """
    if self._validated:
        return
    if self._validating:
        raise RuntimeError("validate() called recursively or concurrently")
    self._validating = True
    try:
        self._plan_cache = run_validation(
            self._providers,
            self._dep_edges,
            actors,
            rate_limit_registry,
        )
        if actors is not None:
            _emit_redundant_override_warnings(actors, self._providers)
        self._validated = True
        self._sealed = True
        logger.info(
            "registry-validated",
            provider_count=len(self._providers),
            actor_count=len(actors) if actors else 0,
        )
    finally:
        self._validating = False

LifecycleDetectionWarning

Bases: UserWarning

Emitted for DI usage hazards detected during DI operations.

Specific timing varies per emission site and is documented at each emission. Fires for redundant call-site Scope overrides , sync close detection on registered classes, and other drift-prone API patterns. Subclass of UserWarning so pytest captures it under default filters via pytest.warns(LifecycleDetectionWarning).

Scope

Bases: IntEnum

DI scope lifetime. Higher value = narrower scope.

PROCESS: worker process startup -> exit (singletons). THREAD: worker thread spawn -> thread close (placeholder for multi-thread worker). LOOP: worker loop start -> loop close (pools, long-lived clients). TRANSIENT: fresh per injection point - no cache.

PROCESS class-attribute instance-attribute

PROCESS = 0

THREAD class-attribute instance-attribute

THREAD = 1

LOOP class-attribute instance-attribute

LOOP = 2

TRANSIENT class-attribute instance-attribute

TRANSIENT = 3

ProviderLifecycle

Bases: Enum

Provider lifecycle shapes for resolution-time dispatch.

Plain class-attribute instance-attribute

Plain = 'plain'

AsyncContextManager class-attribute instance-attribute

AsyncContextManager = 'acm'

AsyncCloseable class-attribute instance-attribute

AsyncCloseable = 'acloseable'

SyncCloseable class-attribute instance-attribute

SyncCloseable = 'scloseable'

AsyncGenerator class-attribute instance-attribute

AsyncGenerator = 'asyncgen'

SyncGenerator class-attribute instance-attribute

SyncGenerator = 'syncgen'

PlainFactory class-attribute instance-attribute

PlainFactory = 'plainfactory'

registry

Concrete ProviderRegistry — registration API and seal mechanics.

The public surface (register_value / register_factory / register_class, has_provider, get[T], providers, validate) matches the published block. The validate() method delegates the five-phase startup validation algorithm to _di._validate.run_validation.

logger module-attribute

logger = structlog.get_logger('taskq._di.registry')

ProviderRegistry

ProviderRegistry()

Mutable provider registry with seal guard and edge capture.

Structurally satisfies the taskq._di.types.ProviderRegistry Protocol without explicit inheritance — the Protocol is runtime_checkable for isinstance checks, but the concrete class does not inherit from it to avoid forcing runtime_checkable constraints on the implementation.

Source code in src/taskq/_di/registry.py
def __init__(self) -> None:
    self._providers: dict[type, ProviderEntry[object]] = {}
    self._dep_edges: list[tuple[type, type, Scope | None]] = []
    self._validated: bool = False
    self._sealed: bool = False
    self._plan_cache: dict[tuple[str, Scope], list[type]] = {}
    self._validating: bool = False

providers property

providers: dict[type, ProviderEntry[object]]

Shallow copy preventing external mutation.

Why: the internal map is dict[type, ProviderEntry[object]] per the erasure boundary. Returning a copy prevents callers from mutating registry state.

register_value

register_value(
    type_: type[T], scope: Scope, value: T
) -> None
Source code in src/taskq/_di/registry.py
def register_value[T](self, type_: type[T], scope: Scope, value: T) -> None:
    if self._sealed:
        raise RuntimeError("registry is sealed after validate()")
    if type_ in self._providers:
        raise ValueError(f"{type_!r} is already registered")
    entry = ProviderEntry(
        type_=type_,
        scope=scope,
        kind="value",
        impl=value,
        factory_shape=FactoryShape.VALUE,
    )
    self._providers[type_] = entry
    logger.debug(
        "provider-registered",
        type_name=type_.__qualname__,
        scope=scope.name,
        kind="value",
    )

register_factory

register_factory(
    type_: type[T], scope: Scope, factory: Factory[T]
) -> None
Source code in src/taskq/_di/registry.py
def register_factory[T](self, type_: type[T], scope: Scope, factory: Factory[T]) -> None:
    if self._sealed:
        raise RuntimeError("registry is sealed after validate()")
    if type_ in self._providers:
        raise ValueError(f"{type_!r} is already registered")
    if not callable(factory):
        raise TypeError(f"factory must be callable, got {type(factory).__qualname__}")
    if inspect.isasyncgenfunction(factory):
        shape = FactoryShape.ASYNC_GENERATOR
    elif inspect.isgeneratorfunction(factory):
        shape = FactoryShape.SYNC_GENERATOR
    elif inspect.iscoroutinefunction(factory):
        shape = FactoryShape.ASYNC_CALLABLE
    else:
        shape = FactoryShape.SYNC_CALLABLE

    lifecycle = detect_factory_lifecycle(factory)

    if lifecycle is ProviderLifecycle.SyncGenerator:
        warnings.warn(
            LifecycleDetectionWarning(
                f"{getattr(factory, '__qualname__', repr(factory))} is a sync "
                f"generator factory; its cleanup will run synchronously and "
                f"cannot be awaited by the DI engine."
            ),
            stacklevel=2,
        )
        logger.warning(
            "sync_generator_registered",
            factory_name=getattr(factory, "__qualname__", repr(factory)),
            scope=scope.name,
        )

    edges = _collect_dep_edges(factory, type_)
    self._dep_edges.extend(edges)

    entry = ProviderEntry(
        type_=type_,
        scope=scope,
        kind="factory",
        impl=factory,
        factory_shape=shape,
        lifecycle=lifecycle,
    )
    self._providers[type_] = entry
    logger.debug(
        "provider-registered",
        type_name=type_.__qualname__,
        scope=scope.name,
        kind="factory",
        factory_shape=shape.name,
    )

register_class

register_class(
    type_: type[T],
    scope: Scope,
    *,
    lifecycle: ProviderLifecycle | None = None,
) -> None
Source code in src/taskq/_di/registry.py
def register_class[T](
    self,
    type_: type[T],
    scope: Scope,
    *,
    lifecycle: ProviderLifecycle | None = None,
) -> None:
    if self._sealed:
        raise RuntimeError("registry is sealed after validate()")
    if type_ in self._providers:
        raise ValueError(f"{type_!r} is already registered")

    resolved_lifecycle = lifecycle if lifecycle is not None else detect_lifecycle(type_)

    if lifecycle is None:
        if resolved_lifecycle is ProviderLifecycle.SyncCloseable:
            warnings.warn(
                LifecycleDetectionWarning(
                    f"{type_.__qualname__} has close() but no aclose(); "
                    f"the DI engine will call close() via asyncio.to_thread "
                    f"at teardown. Prefer an async close (aclose) for "
                    f"async-native code."
                ),
                stacklevel=2,
            )
            logger.warning(
                "sync_close_registered",
                class_name=type_.__qualname__,
                scope=scope.name,
            )
        elif (
            resolved_lifecycle is ProviderLifecycle.Plain
            and hasattr(type_, "__enter__")
            and not hasattr(type_, "__aenter__")
        ):
            warnings.warn(
                LifecycleDetectionWarning(
                    f"{type_.__qualname__} has __enter__/__exit__ but no "
                    f"__aenter__/__aexit__; sync context managers are not "
                    f"supported by the async DI engine. The class will be "
                    f"managed as Plain (no teardown)."
                ),
                stacklevel=2,
            )
            logger.warning(
                "sync_context_manager_not_supported",
                class_name=type_.__qualname__,
                scope=scope.name,
            )

    edges = _collect_dep_edges(type_.__init__, type_)
    self._dep_edges.extend(edges)

    entry = ProviderEntry(
        type_=type_,
        scope=scope,
        kind="class",
        impl=type_,
        factory_shape=FactoryShape.CLASS,
        lifecycle=resolved_lifecycle,
    )
    self._providers[type_] = entry
    logger.debug(
        "provider-registered",
        type_name=type_.__qualname__,
        scope=scope.name,
        kind="class",
    )

has_provider

has_provider(type_: type) -> bool
Source code in src/taskq/_di/registry.py
def has_provider(self, type_: type) -> bool:
    return type_ in self._providers

get

get(type_: type[T]) -> ProviderEntry[T]
Source code in src/taskq/_di/registry.py
def get[T](self, type_: type[T]) -> ProviderEntry[T]:
    entry = self._providers.get(type_)
    if entry is None:
        raise MissingProvider(
            type_name=type_.__qualname__,
            required_by="<unknown>",
        )
    return cast(
        ProviderEntry[T], entry
    )  # Why: internal map is dict[type, ProviderEntry[object]] (erasure boundary); get[T] recovers the type parameter

validate

validate(
    actors: list[ActorRef[Any, Any]] | None = None,
    rate_limit_registry: RateLimitRegistry | None = None,
) -> None

Walk all providers and actors; raise on first error; seal on success.

Pure graph-walk over registration-time metadata. Never invokes a factory, calls a resolver, or performs await — the entire algorithm is synchronous introspection on _providers and _dep_edges.

actors: explicit list of ActorRef instances whose DI parameter annotations are walked for MissingProvider checks and plan-cache population. Defaults to None (no actor walk — only provider→provider edges are validated).

rate_limit_registry: when provided, each actor's rate_limits and reservations lists are checked against the registry's dicts. Unknown names raise MissingProvider at startup. When None (default), the name-check phase is skipped entirely.

Why: ActorRef[Any, Any] is the sanctioned erasure
boundary for the heterogeneous actor registry — each ActorRef has
different P and R type parameters, and validate() does not need
per-actor narrowing. The same erasure is used at the existing API
boundary in worker/run.py:283 and cli.py:98.
Source code in src/taskq/_di/registry.py
def validate(
    self,
    actors: list["ActorRef[Any, Any]"] | None = None,
    rate_limit_registry: "RateLimitRegistry | None" = None,
) -> None:
    """Walk all providers and actors; raise on first error; seal on success.

    Pure graph-walk over registration-time metadata. Never invokes a
    factory, calls a resolver, or performs await — the entire algorithm
    is synchronous introspection on ``_providers`` and ``_dep_edges``.

    actors: explicit list of ActorRef instances whose DI parameter
    annotations are walked for MissingProvider checks and plan-cache
    population. Defaults to None (no actor walk — only
    provider→provider edges are validated).

    rate_limit_registry: when provided, each actor's ``rate_limits``
    and ``reservations`` lists are checked against the registry's
    dicts. Unknown names raise ``MissingProvider`` at startup.
    When ``None`` (default), the name-check phase is
    skipped entirely.

    # Why: ActorRef[Any, Any] is the sanctioned erasure
    # boundary for the heterogeneous actor registry — each ActorRef has
    # different P and R type parameters, and validate() does not need
    # per-actor narrowing. The same erasure is used at the existing API
    # boundary in worker/run.py:283 and cli.py:98.
    """
    if self._validated:
        return
    if self._validating:
        raise RuntimeError("validate() called recursively or concurrently")
    self._validating = True
    try:
        self._plan_cache = run_validation(
            self._providers,
            self._dep_edges,
            actors,
            rate_limit_registry,
        )
        if actors is not None:
            _emit_redundant_override_warnings(actors, self._providers)
        self._validated = True
        self._sealed = True
        logger.info(
            "registry-validated",
            provider_count=len(self._providers),
            actor_count=len(actors) if actors else 0,
        )
    finally:
        self._validating = False

scopes

Concrete ScopeContainer with LIFO teardown and factory-shape dispatch.

Implements the log-and-continue teardown policy: each teardown callback runs in its own try/except; failures are logged at ERROR; remaining teardowns always fire. A parallel _teardowns list replaces AsyncExitStack.aclose() which re-raises the first exception and swallows the rest (research line 743-758).

logger module-attribute

logger = structlog.get_logger('taskq._di.scopes')

ScopeContainer

ScopeContainer(*, scope: Scope, resolver: _Resolver)

Concrete scope-lifetime container owning a cache, teardown list, and AsyncExitStack.

The container is responsible for ALL factory invocation, caching, and teardown registration. The solver engine NEVER calls a factory directly and NEVER touches an AsyncExitStack.

Source code in src/taskq/_di/scopes.py
def __init__(
    self,
    *,
    scope: Scope,
    resolver: _Resolver,
) -> None:
    self._scope: Scope = scope
    self._cache: dict[type, object] = {}
    self._stack: AsyncExitStack = AsyncExitStack()
    self._teardowns: list[Callable[[], Any]] = []
    self._resolver: _Resolver = resolver
    self._sync_gen_executor: ThreadPoolExecutor | None = None
    self._last_cache_hit: bool = False

last_cache_hit property

last_cache_hit: bool

Whether the most recent get_or_create returned a cached value.

get_or_create async

get_or_create(type_: type[T], entry: ProviderEntry[T]) -> T

Resolve type_ via entry, creating and caching the instance if needed.

Source code in src/taskq/_di/scopes.py
async def get_or_create[T](self, type_: type[T], entry: ProviderEntry[T]) -> T:
    """Resolve *type_* via *entry*, creating and caching the instance if needed."""
    if self._scope is not Scope.TRANSIENT:
        cached = self._cache.get(type_)
        if cached is not None:
            self._last_cache_hit = True
            return cached  # type: ignore[return-value]  # Why: _cache is dict[type, object]; caller's T is recovered via the ProviderEntry[T] that selected this branch
    self._last_cache_hit = False

    match entry.factory_shape:
        case FactoryShape.VALUE:
            result: object = entry.impl
        case FactoryShape.SYNC_CALLABLE:
            kwargs = await self._resolver(entry.impl)
            result = cast(Callable[..., Any], entry.impl)(**kwargs)
        case FactoryShape.ASYNC_CALLABLE:
            kwargs = await self._resolver(entry.impl)
            result = await cast(Callable[..., Any], entry.impl)(**kwargs)
        case FactoryShape.SYNC_GENERATOR:
            result = await self._resolve_sync_generator(entry)
        case FactoryShape.ASYNC_GENERATOR:
            result = await self._resolve_async_generator(entry)
        case FactoryShape.CLASS:
            kwargs = await self._resolver(cast(type[Any], entry.impl).__init__)
            instance = cast(type[Any], entry.impl)(**kwargs)
            match entry.lifecycle:
                case ProviderLifecycle.AsyncContextManager:
                    value = await instance.__aenter__()

                    async def _acm_teardown() -> None:
                        await instance.__aexit__(None, None, None)

                    self._teardowns.append(_acm_teardown)
                    result = value
                case ProviderLifecycle.AsyncCloseable:
                    self._teardowns.append(instance.aclose)
                    result = instance
                case ProviderLifecycle.SyncCloseable:
                    self._teardowns.append(lambda inst=instance: asyncio.to_thread(inst.close))
                    result = instance
                case ProviderLifecycle.Plain | None:
                    result = instance
                case (
                    ProviderLifecycle.AsyncGenerator
                    | ProviderLifecycle.SyncGenerator
                    | ProviderLifecycle.PlainFactory
                ):
                    msg = f"factory lifecycle {entry.lifecycle!r} reached CLASS arm"
                    raise RuntimeError(msg)
                case _:
                    assert_never(entry.lifecycle)
        case _:
            assert_never(entry.factory_shape)

    if self._scope is not Scope.TRANSIENT:
        self._cache[type_] = result

    return result  # type: ignore[return-value]  # Why: same recovery as cache-hit branch — erasure boundary documented

aclose async

aclose() -> None

Close the container with the log-and-continue teardown policy.

Source code in src/taskq/_di/scopes.py
async def aclose(self) -> None:
    """Close the container with the log-and-continue teardown policy."""
    pending_cancel: BaseException | None = None
    while self._teardowns:
        cb = self._teardowns.pop()
        try:
            await cb()
        except BaseException as exc:
            logger.error(
                "provider-teardown-error",
                scope=self._scope.name,
                exc_info=True,
            )
            # Why: remember a CancelledError so we can re-raise it after
            # all remaining teardowns have run; non-cancel exceptions
            # are logged and dropped  log-and-continue.
            if isinstance(exc, asyncio.CancelledError):
                pending_cancel = exc

    # Why: shut down the pinned SYNC_GENERATOR executor AFTER all
    # per-provider teardown callbacks have run. shutdown(wait=True) is
    # blocking; running it inline would block the loop .
    if self._sync_gen_executor is not None:
        executor = self._sync_gen_executor
        self._sync_gen_executor = None
        try:
            await asyncio.to_thread(executor.shutdown, True)
        except BaseException as exc:
            # Why: parallels the per-callback BaseException pattern above.
            logger.error(
                "sync-generator-executor-shutdown-error",
                scope=self._scope.name,
                exc_info=True,
            )
            if isinstance(exc, asyncio.CancelledError):
                pending_cancel = exc
        else:
            logger.info(
                "sync_generator_executor_shutdown",
                scope=self._scope.name,
            )

    # Why: after every teardown attempted, re-raise any deferred
    # CancelledError so the outer task's cancellation contract is honored.
    if pending_cancel is not None:
        raise pending_cancel

ProcessScope

ProcessScope(*, resolver: _Resolver)

Bases: ScopeContainer

PROCESS-lifetime scope — worker process startup → exit.

Source code in src/taskq/_di/scopes.py
def __init__(self, *, resolver: _Resolver) -> None:
    super().__init__(scope=Scope.PROCESS, resolver=resolver)

bootstrap async

bootstrap(
    registry: ProviderRegistry, settings: WorkerSettings
) -> None

Resolve all PROCESS-scoped providers.

Why: no try/except around get_or_create — earlier providers' teardowns are already registered on self._teardowns; the caller's AsyncExitStack runs aclose() on unwind, which iterates whatever teardowns were registered before the failure. Partial-bootstrap leaks are not possible: every successful get_or_create has its teardown queued before the next get_or_create starts.

Source code in src/taskq/_di/scopes.py
async def bootstrap(
    self,
    registry: ProviderRegistry,
    settings: WorkerSettings,  # Why: accepted for API symmetry with the public surface; bootstrap doesn't use it directly — registration is a separate concern owned by worker bootstrap
) -> None:
    """Resolve all PROCESS-scoped providers.

    Why: no try/except around get_or_create — earlier providers'
    teardowns are already registered on self._teardowns; the
    caller's AsyncExitStack runs aclose() on unwind, which
    iterates whatever teardowns were registered before the
    failure. Partial-bootstrap leaks are not possible: every
    successful get_or_create has its teardown queued before
    the next get_or_create starts.
    """
    process_providers = [t for t, e in registry.providers.items() if e.scope is Scope.PROCESS]
    for t in process_providers:
        await self.get_or_create(t, registry.get(t))  # type: ignore[reportUnknownArgumentType]  # Why: registry.get() returns ProviderEntry[Unknown] when called with plain type; runtime guarantee from providers dict iteration
    logger.info(
        "process-scope-opened",
        provider_count=len(process_providers),
    )

shutdown async

shutdown() -> None
Source code in src/taskq/_di/scopes.py
async def shutdown(self) -> None:
    await self.aclose()
    logger.info("process-scope-closed")

get

get(type_: type) -> object | None
Source code in src/taskq/_di/scopes.py
def get(self, type_: type) -> object | None:
    return self._cache.get(type_)

ThreadScope

ThreadScope(*, resolver: _Resolver)

Bases: ScopeContainer

THREAD-lifetime scope — placeholder for multi-thread workers (trivially empty in M3).

Source code in src/taskq/_di/scopes.py
def __init__(self, *, resolver: _Resolver) -> None:
    super().__init__(scope=Scope.THREAD, resolver=resolver)

bootstrap async

bootstrap(
    registry: ProviderRegistry, process_scope: ProcessScope
) -> None

Resolve all THREAD-scoped providers (empty in M3 single-thread deployment).

Source code in src/taskq/_di/scopes.py
async def bootstrap(
    self,
    registry: ProviderRegistry,
    process_scope: ProcessScope,
) -> None:
    """Resolve all THREAD-scoped providers (empty in M3 single-thread deployment)."""
    thread_providers = [t for t, e in registry.providers.items() if e.scope is Scope.THREAD]
    for t in thread_providers:
        await self.get_or_create(t, registry.get(t))  # type: ignore[reportUnknownArgumentType]  # Why: registry.get() returns ProviderEntry[Unknown] when called with plain type; runtime guarantee from providers dict iteration
    logger.info(
        "thread-scope-opened",
        provider_count=len(thread_providers),
    )

shutdown async

shutdown() -> None
Source code in src/taskq/_di/scopes.py
async def shutdown(self) -> None:
    await self.aclose()
    logger.info("thread-scope-closed")

get

get(type_: type) -> object | None
Source code in src/taskq/_di/scopes.py
def get(self, type_: type) -> object | None:
    return self._cache.get(type_)

LoopScope

LoopScope(*, resolver: _Resolver)

Bases: ScopeContainer

LOOP-lifetime scope — worker loop start → loop close.

Source code in src/taskq/_di/scopes.py
def __init__(self, *, resolver: _Resolver) -> None:
    super().__init__(scope=Scope.LOOP, resolver=resolver)
    self._loop: asyncio.AbstractEventLoop = asyncio.get_running_loop()

bootstrap async

bootstrap(
    registry: ProviderRegistry,
    process_scope: ProcessScope,
    thread_scope: ThreadScope | None = None,
) -> None

Resolve all LOOP-scoped providers.

Why: process_scope / thread_scope parameters are accepted for API completeness even though bootstrap doesn't read them directly — wider scopes' instances are reachable through the _resolver callable (which holds the registry + container map).

Source code in src/taskq/_di/scopes.py
async def bootstrap(
    self,
    registry: ProviderRegistry,
    process_scope: ProcessScope,
    thread_scope: ThreadScope | None = None,
) -> None:
    """Resolve all LOOP-scoped providers.

    Why: process_scope / thread_scope parameters are accepted for
    API completeness even though bootstrap doesn't read them
    directly — wider scopes' instances are reachable through the
    _resolver callable (which holds the registry + container map).
    """
    loop_providers = [t for t, e in registry.providers.items() if e.scope is Scope.LOOP]
    for t in loop_providers:
        await self.get_or_create(t, registry.get(t))  # type: ignore[reportUnknownArgumentType]  # Why: registry.get() returns ProviderEntry[Unknown] when called with plain type; runtime guarantee from providers dict iteration
    logger.info(
        "loop-scope-opened",
        provider_count=len(loop_providers),
    )

shutdown async

shutdown() -> None
Source code in src/taskq/_di/scopes.py
async def shutdown(self) -> None:
    await self.aclose()
    logger.info("loop-scope-closed")

resolved_cache

resolved_cache() -> Mapping[type, object]

Return a read-only view of the LOOP-scope resolved values.

The returned mapping is a live view onto the underlying cache — providers resolved after this method is called (e.g., lazy providers) become visible without re-fetching. Callers MUST NOT attempt to mutate the mapping; doing so raises TypeError.

Stability invariant: once bootstrap returns, the registered set of LOOP-scope provider types is FROZEN for the lifetime of the loop. The values stored under each type key (e.g. an asyncpg.Connection) are expected to be stable references — callers that read the mapping repeatedly (such as SubJobEnqueuer.enqueue) assume the connection identity does not change between dispatches. If a future feature needs to swap a LOOP-scope value mid-loop (e.g., reconnect after a pool reset), it must coordinate with consumers of this mapping because the consumer's per-dispatch transaction lifecycle requires the same connection across both transaction-open and transaction-close.

Source code in src/taskq/_di/scopes.py
def resolved_cache(self) -> Mapping[type, object]:
    """Return a read-only view of the LOOP-scope resolved values.

    The returned mapping is a live view onto the underlying cache —
    providers resolved after this method is called (e.g., lazy
    providers) become visible without re-fetching. Callers MUST NOT
    attempt to mutate the mapping; doing so raises ``TypeError``.

    Stability invariant: once ``bootstrap`` returns, the registered
    set of LOOP-scope provider types is FROZEN for the lifetime of
    the loop. The values stored under each type key (e.g. an
    ``asyncpg.Connection``) are expected to be stable references —
    callers that read the mapping repeatedly (such as
    ``SubJobEnqueuer.enqueue``) assume the connection identity does
    not change between dispatches. If a future feature needs to
    swap a LOOP-scope value mid-loop (e.g., reconnect after a pool
    reset), it must coordinate with consumers of this mapping
    because the consumer's per-dispatch transaction lifecycle
    requires the same connection across both transaction-open and
    transaction-close.
    """
    return MappingProxyType(self._cache)

get_or_create async

get_or_create(type_: type[T], entry: ProviderEntry[T]) -> T
Source code in src/taskq/_di/scopes.py
async def get_or_create[T](self, type_: type[T], entry: ProviderEntry[T]) -> T:
    running = asyncio.get_running_loop()
    if running is not self._loop:
        raise RuntimeError(f"LoopScope created on {self._loop!r} but accessed from {running!r}")
    return await super().get_or_create(type_, entry)

get

get(type_: type) -> object | None
Source code in src/taskq/_di/scopes.py
def get(self, type_: type) -> object | None:
    return self._cache.get(type_)

ResolvedActorScope dataclass

ResolvedActorScope(
    ctx: JobContext[BaseModel], di_kwargs: dict[str, object]
)

The yielded value of build_actor_scope.

ctx — the JobContext supplied by the consumer (passthrough). di_kwargs — the DI-resolved kwargs dict for the actor function.

Usage::

async with build_actor_scope(...) as resolved:
    await run_actor(job, resolved.ctx, **resolved.di_kwargs)

ctx instance-attribute

ctx: JobContext[BaseModel]

di_kwargs instance-attribute

di_kwargs: dict[str, object]

make_resolver

make_resolver(
    registry: ProviderRegistry,
    scope_containers: dict[Scope, ScopeContainer],
) -> _Resolver

Construct the resolver callable expected by ScopeContainer.init.

The resolver invokes solve_dependencies with the registry and scope containers. The closure captures scope_containers by reference so the resolver sees containers added after its construction (the dict is populated incrementally as each scope container is bootstrapped).

Source code in src/taskq/_di/scopes.py
def make_resolver(
    registry: ProviderRegistry,
    scope_containers: dict[Scope, ScopeContainerProtocol],
) -> _Resolver:
    """Construct the resolver callable expected by ScopeContainer.__init__.

    The resolver invokes solve_dependencies with the registry and scope
    containers. The closure captures scope_containers by reference so the
    resolver sees containers added after its construction (the dict is
    populated incrementally as each scope container is bootstrapped).
    """

    def _resolver(func: object) -> Any:
        async def _resolve() -> dict[str, object]:
            return await solve_dependencies(
                func=func,
                registry=registry,
                scope_containers=scope_containers,
            )

        return _resolve()

    return _resolver

build_actor_scope async

build_actor_scope(
    *,
    registry: ProviderRegistry,
    process_scope: ProcessScope,
    thread_scope: ThreadScope,
    loop_scope: LoopScope,
    actor_func: Callable[..., Awaitable[object]],
    actor_name: str,
    passthrough_kwargs: dict[str, object],
) -> AsyncGenerator[ResolvedActorScope, None]

Per-invocation actor scope: opens TRANSIENT stack, resolves DI kwargs.

Yields ResolvedActorScope(ctx, di_kwargs). On exit (regardless of outcome), closes the TRANSIENT stack in LIFO order via the log-and-continue teardown policy.

passthrough_kwargs MUST contain the JobContext (key "ctx") and the validated payload (key "payload"). The consumer constructs both per-job and supplies them as passthrough; the registry's graph walk is configured to skip these parameter names, so they are never resolved from providers.

Source code in src/taskq/_di/scopes.py
@asynccontextmanager
async def build_actor_scope(
    *,
    registry: ProviderRegistry,
    process_scope: ProcessScope,
    thread_scope: ThreadScope,
    loop_scope: LoopScope,
    actor_func: Callable[..., Awaitable[object]],
    actor_name: str,
    passthrough_kwargs: dict[str, object],
) -> AsyncGenerator[ResolvedActorScope, None]:
    """Per-invocation actor scope: opens TRANSIENT stack, resolves DI kwargs.

    Yields ResolvedActorScope(ctx, di_kwargs). On exit (regardless of
    outcome), closes the TRANSIENT stack in LIFO order via the
    log-and-continue teardown policy.

    passthrough_kwargs MUST contain the JobContext (key "ctx") and the
    validated payload (key "payload"). The consumer constructs both
    per-job and supplies them as passthrough; the registry's graph walk
    is configured to skip these parameter names, so they are never
    resolved from providers.
    """
    scope_containers: dict[Scope, ScopeContainerProtocol] = {}

    def _resolver(func: object) -> Any:
        async def _resolve() -> dict[str, object]:
            return await solve_dependencies(
                func=func,
                registry=registry,
                scope_containers=scope_containers,
            )

        return _resolve()

    transient_scope = ScopeContainer(scope=Scope.TRANSIENT, resolver=_resolver)

    scope_containers = {
        Scope.PROCESS: process_scope,
        Scope.THREAD: thread_scope,
        Scope.LOOP: loop_scope,
        Scope.TRANSIENT: transient_scope,
    }

    # Why: the resolver closure captured scope_containers before TRANSIENT
    # was added; re-bind so the resolver sees all four containers.
    def _resolver_with_all(func: object) -> Any:
        async def _resolve() -> dict[str, object]:
            return await solve_dependencies(
                func=func,
                registry=registry,
                scope_containers=scope_containers,
            )

        return _resolve()

    transient_scope._resolver = _resolver_with_all  # pyright: ignore[reportPrivateUsage]  # Why: build_actor_scope constructs the TRANSIENT container and must wire its resolver to see all four scope containers; the resolver is a closure detail owned by this call site

    logger.info("transient-scope-opened", actor_name=actor_name)
    try:
        di_kwargs = await solve_dependencies(
            func=actor_func,
            registry=registry,
            scope_containers=scope_containers,
            passthrough_kwargs=passthrough_kwargs,
        )
        # Why: solve_dependencies includes passthrough keys in its result
        # dict; build_actor_scope yields ctx separately and the consumer
        # spreads di_kwargs — so passthrough keys must not appear in
        # di_kwargs to avoid duplicate keyword arguments at the call site.
        for _key in passthrough_kwargs:
            di_kwargs.pop(_key, None)
        ctx = passthrough_kwargs["ctx"]
        if not isinstance(ctx, JobContext):
            raise TypeError(
                f"passthrough_kwargs['ctx'] must be a JobContext, got {type(ctx).__name__}"
            )
        yield ResolvedActorScope(ctx=ctx, di_kwargs=di_kwargs)  # type: ignore[reportUnknownArgumentType]  # Why: ctx is narrowed to JobContext[Any] by isinstance but pyright cannot recover the BaseModel bound from the passthrough_kwargs dict[str, object] — the consumer that built passthrough_kwargs guarantees the correct P at the call site
    finally:
        # Why: shield the TRANSIENT teardown so cancellation /
        # asyncio.wait_for timeouts in the with-body do not
        # short-circuit the teardown mid-way.
        # ("wrap terminal writes in asyncio.shield") applies to
        # scope teardown too — losing teardown of an opened
        # resource leaks it. CancelledError after the shielded
        # aclose finishes is re-raised to honor the outer cancel.
        try:
            await asyncio.shield(transient_scope.aclose())
        except asyncio.CancelledError:
            raise
        finally:
            logger.info("transient-scope-closed", actor_name=actor_name)