Skip to content

Writing an integration

The integrations depin ships are not special: they are written entirely against the same seam any third-party integration uses — Host, hosted_container, optional_hosted_container, ContractVersion, and CONTRACT_VERSION, all re-exported from depin. depin.ext.asgi and depin.ext.wsgi are the two that drive it per request: each holds a Host, opens a scope around the request, and applies the seed it was constructed with to the frame. depin.ext.fastapi reads the container back with optional_hosted_container(), and Host.activated() on its own is called by no integration depin ships — tests/unit/test_hosting.py covers it. This page states the contract, lists what depin ships on it, then builds one integration — a job runner — against it end to end.

What an integration does

Four operations cover every host depin has been fitted to: a web framework, a CLI, a queue consumer.

Operation Call When
Publish the container Host.activated() Outside any unit of work — an ASGI lifespan, process-wide CLI setup.
Open a scope per unit of work Host.scope() / Host.ascope() Once per request, invocation, or message.
Seed the framework's objects ScopeFrame.provide() on the yielded frame, against a key declared with Container.scope_value() Right after the scope opens, before anything resolves.
Read the container back hosted_container() / optional_hosted_container() Anywhere downstream that carries no reference to the container.

Pair every seeded key with Container.scope_value(). A key declared that way gets a plan node: resolve(key) works, the value is cached in the frame on first use, and an override() on the key is honoured. A bare frame.provide(key, value) for a key with no such declaration wires nothing — resolve(key) still raises MissingProviderError — and only reaches a parameter that carries a default or admits None, as a fallback for the case the plan has no route for at all; a required parameter with no default keeps raising. Relying on that fallback skips the guarantees scope_value() buys, so treat it as what happens when the pairing is missing, not as a second way to seed.

The request and response integrations

Three of the four web framework integrations are one of two middlewares — the ASGI one or the WSGI one — with a single seed applied. depin.ext.fastapi is the exception: its RequestScope is the Starlette one re-exported, and what the module adds of its own is Inject[T]. Either way the module to import is chosen by the framework, and the extra is named after it:

Framework Module Protocol Install
FastAPI depin.ext.fastapi ASGI uv add 'pydepin[fastapi]'
Starlette depin.ext.starlette ASGI uv add 'pydepin[starlette]'
Litestar depin.ext.litestar ASGI uv add 'pydepin[litestar]'
Flask depin.ext.flask WSGI uv add 'pydepin[flask]'
Any other ASGI framework depin.ext.asgi ASGI no extra
Any other WSGI framework depin.ext.wsgi WSGI no extra

depin.ext.asgi and depin.ext.wsgi import no third-party package, so a framework outside that list installs RequestScope with a seed of its own and needs nothing from depin beyond the contract above.

examples/starlette_app/ is the whole shape in one runnable program — registries, an app factory taking the container as an argument, scope_value(Request) for the seeded request, and aclose() on shutdown. Run it with python -m examples.starlette_app.main.

A WSGI scope ends when the application returns

The scope ends when the application returns, not when the response is finished. WSGI hands the server an iterable that the server consumes after the application has returned, and it offers no hook that outlives that return, so a streaming body cannot resolve: by the time the server pulls the first chunk the scope has drained and the container is no longer published. Resolve everything a streaming response needs before returning the iterable, and close over the values. ASGI has no such limit; depin.ext.asgi.RequestScope keeps the scope open for the whole response.

A seeded request is not for reading the body

Every seed is built from the connection alone, and what a body read through it does depends on the framework:

Integration A body read through the seed
depin.ext.starlette Raises. The request has no receive channel and caches its body per instance, so no order of reads makes it succeed.
depin.ext.litestar Raises when nothing has parsed the body yet, and returns the parsed body when the handler declares a data parameter — litestar.Request caches through the connection scope rather than per instance.
depin.ext.flask Consumes environ['wsgi.input'], the same stream Flask's own request reads. Flask's parse then finds it empty and answers 400 before the view runs.

Neither ASGI seed reaches the stream the handler reads, so neither can take the body from it; the WSGI seed can. Treat the body as a route concern, not a provider input.

The command and message hosts

The other two shapes a host takes are a command invocation and a queue message. Neither has a request, and both open one scope per unit of work. depin.ext.cli is the framework-free half of the first, the way depin.ext.asgi and depin.ext.wsgi are of the request/response hosts: a CommandContext is anything with a with_resource method, so a command framework outside the table below drives install with a seed of its own and needs nothing from depin beyond the contract above.

Host Module Seeded Install
Click depin.ext.click click.Context uv add 'pydepin[click]'
Typer depin.ext.typer nothing uv add 'pydepin[typer]'
Taskiq depin.ext.taskiq taskiq.TaskiqMessage uv add 'pydepin[taskiq]'
Any other command framework depin.ext.cli whatever you pass as seed no extra

cli.install returns the scope's frame, which none of the middlewares above does, because none of them has a caller. Here the caller is the user's own command callback, so the frame is where a tenant or a correlation id read off an option goes before anything resolves.

examples/click_app/ is the whole shape in one runnable program — registries, a CLI factory taking the container as an argument, the seeded click.Context and a tenant placed into the returned frame, and close() at exit. Run it with python -m examples.click_app.main. One example carries the shape because the shape is one idea — open a scope for the unit of work and let the host end it — and the other two hosts each depart from it somewhere the example cannot show. Typer keeps the structure and changes the seeding: there is no typer.Context seed to inherit, so the container declares scope_value on a key the caller owns and the callback places the context into the returned frame, which Typer seeds nothing sets out. Taskiq changes the structure too: no install and no returned frame, but a MessageScope registered on a broker, scope_value(TaskiqMessage) for the seed the middleware fills, an async scope in place of a synchronous one, and @broker.task in place of @click.command — see register the Taskiq middleware last.

Click and Typer are synchronous

Neither framework awaits a coroutine callback: an async def command body is called and the coroutine it returns is dropped unawaited. The scope install opens is therefore a synchronous one, and every provider resolved inside it has to be synchronous too. An async one raises AsyncInSyncContextErrorthe general rule met by a host that cannot await:

>>> import asyncio
>>> from collections.abc import AsyncGenerator
>>> from depin import Container, Host, Scope, hosted_container
>>> class Pool:
...     def __init__(self) -> None:
...         self.closed = False
>>> pools: list[Pool] = []
>>> async def open_pool() -> AsyncGenerator[Pool]:
...     pool = Pool()
...     pools.append(pool)
...     yield pool
...     pool.closed = True
>>> command_di = Container().bind(open_pool, scope=Scope.SCOPED, provides=Pool).freeze()
>>> command_host = Host(command_di)
>>> with command_host.scope():
...     command_di.resolve(Pool)
Traceback (most recent call last):
    ...
depin.errors.AsyncInSyncContextError: Pool requires async resolution; call aresolve() instead

Reaching for aresolve() instead is worse, not better, whenever the async provider registers a teardown — an async generator or an async context manager, which is what open_pool above is. It builds the provider, and the teardown it registers is one the synchronous scope cannot run, so closing the scope raises an ExceptionGroup wrapping TeardownError — and the pool it built is left open:

>>> async def build_a_pool() -> Pool:
...     with command_host.scope():
...         return await command_di.aresolve(Pool)
>>> try:
...     asyncio.run(build_a_pool())
... except ExceptionGroup as group:
...     print(type(group.exceptions[0]).__name__)
TeardownError
>>> [pool.closed for pool in pools]
[False]

An async provider that registers no teardown leaves nothing behind for the synchronous scope to fail on, so the same call succeeds and the scope closes clean:

>>> async def make_token() -> str:
...     return 'ready'
>>> plain_di = Container().bind(make_token, scope=Scope.SCOPED, provides=str).freeze()
>>> plain_host = Host(plain_di)
>>> async def build_a_token() -> str:
...     with plain_host.scope():
...         return await plain_di.aresolve(str)
>>> asyncio.run(build_a_token())
'ready'

That is why the guidance is ascope() rather than a diagnostic: the harm shows up only for the providers that own something.

An async CLI therefore drives the loop itself. The command body stays synchronous and does one thing — asyncio.run around a coroutine that opens Host.ascope(), which is the scope an async provider has a place in:

>>> async def run() -> str:
...     async with command_host.ascope():
...         pool = await hosted_container().aresolve(Pool)
...         return f'pool ready (closed={pool.closed})'
>>> asyncio.run(run())
'pool ready (closed=False)'
>>> [pool.closed for pool in pools]
[False, True]

The second pool was torn down when ascope() closed; the first is still the one scope() leaked. In a Click command that is:

@click.command()
def report() -> None:
    asyncio.run(run())

install has no part in it. The scope is opened and closed inside the coroutine, not by the command context, so nothing is bound to a lifetime the event loop outlives.

Typer seeds nothing

The omission is measured rather than an oversight. Typer 0.26.0 dropped its dependency on Click and vendored a private copy of it, so from that release the object a callback annotated typer.Context receives is a typer._click.core.Context and isinstance(value, typer.Context) is False. Seeding under the key typer.Context would bind a value that is not an instance of its own key: depin keys on the annotation as written, so the binding would resolve and hand every provider declaring that parameter something its own annotation denies. Seeding under the vendored class instead would make depin import a third-party private name. The module ships neither.

The context is the caller's to seed, under a key the caller owns:

COMMAND_CONTEXT: Token[typer.Context] = Token[typer.Context]('command-context')

di = Container().scope_value(COMMAND_CONTEXT).bind(Report, scope=Scope.SCOPED).freeze()


@app.callback()
def main(ctx: typer.Context) -> None:
    frame = install(ctx, di)
    frame.provide(COMMAND_CONTEXT, ctx)

Report then declares Annotated[typer.Context, COMMAND_CONTEXT] and reads the context through a name whose meaning the caller, not depin, defines.

Register the Taskiq middleware last

depin.ext.taskiq.MessageScope opens one async scope per message and seeds the taskiq.TaskiqMessage into it. Taskiq is the one host depin ships an integration for whose lifecycle is a pair of hooks rather than a block, so the order the hooks run in is part of the contract: pre_execute runs in registration order and post_execute in reverse. A middleware registered after MessageScope that raises in its own pre_execute skips MessageScope.post_execute entirely — the scope stays open and its teardowns never run. Register it last:

di = Container().scope_value(TaskiqMessage).bind(Report, scope=Scope.SCOPED).freeze()

broker = InMemoryBroker().with_middlewares(SimpleRetryMiddleware(), MessageScope(di))

scope_value(TaskiqMessage) is the declaration the middleware cannot make on your behalf: it supplies the value for every message, never the binding. Without that declaration a provider that declares a TaskiqMessage parameter fails at freeze(), which reports MissingProviderError for the key rather than waiting for the first message to arrive.

A worked integration

The host is a job runner: run() is its unit of work, Job is the object it has of its own to hand to providers.

>>> from collections.abc import Generator
>>> from dataclasses import dataclass
>>> from depin import Container, Host, Scope, hosted_container
>>> @dataclass(frozen=True, slots=True)
... class Job:
...     name: str
>>> class Metrics:
...     def __init__(self) -> None:
...         self.completed = 0
>>> class Workspace:
...     def __init__(self, job: Job) -> None:
...         self.job = job
>>> def open_workspace(job: Job) -> Generator[Workspace]:
...     yield Workspace(job)

scope_value(Job) is what makes Job fillable per scope rather than bound to one factory; Metrics is an ordinary singleton, one per process:

>>> di = Container().scope_value(Job).bind(Metrics).bind(open_workspace, scope=Scope.SCOPED).freeze()
>>> host = Host(di)

A handler carries no reference to di or host — only hosted_container():

>>> def handle() -> str:
...     container = hosted_container()
...     workspace = container.resolve(Workspace)
...     metrics = container.resolve(Metrics)
...     metrics.completed += 1
...     return f'{workspace.job.name} (completed={metrics.completed})'

run() opens the scope, seeds Job, and calls the handler:

>>> with host.scope() as frame:
...     frame.provide(Job, Job('reindex'))
...     handle()
'reindex (completed=1)'

Workspace is scoped and rebuilt for the next job; Metrics is a singleton and keeps counting across both scopes, which is why the count reads 2, not 1:

>>> with host.scope() as frame:
...     frame.provide(Job, Job('vacuum'))
...     handle()
'vacuum (completed=2)'

The full picture — one container, run twice from a JobRunner that owns the Host — is examples/integration/main.py, run with python -m examples.integration.main and pinned by tests/integration/test_examples.py.

Two guarantees scope() makes are worth calling out by name, because both are things an integration author relies on without necessarily testing for them. The container is published before the scope opens and stays published until the scope's own teardowns have finished draining — only then is the publication undone — so a generator provider that reaches hosted_container() from its teardown half still finds the container there. And the publication is scoped to the current contextvars.Context: two concurrent requests, or two concurrent tasks, never see each other's container, and two Hosts active in the same process nest rather than collide — the innermost hosted_container() wins, and exiting its scope restores whichever container the enclosing one had published.

That nesting is a property of the publication alone. Scopes do not nest the same way: the scope frame stack is process-wide and shared by every container, so a second Host's scope opened inside a first one's becomes a child of the first frame, and the frame cache is keyed on the key and its tag alone. A key already resolved in the enclosing scope is therefore what the inner scope resolves, from the enclosing container's cache, and the inner container's own binding never runs. Do not nest two different containers' scopes — open the second one after the first has closed, or host one container per context.

Hosts whose lifecycle is a pair of hooks

Host.scope() returns an ordinary context-manager object; a with statement is only the usual way to drive it. A framework that gives a host a before hook and an after hook instead of a block — Flask's before_request / teardown_request is the canonical shape — stores the context manager between the two and calls __enter__ / __exit__ itself. Hold it per unit of work, keyed by whatever identifier the framework already hands the hooks — a task id, a message id — never on the integration object: one attribute is one slot for the whole process, and a second unit of work arriving before the first finishes overwrites it. A framework whose two hooks run in the same contextvars.Context — Taskiq's do, because pre_execute, the task body and post_execute share one asyncio task — can hold it in a module-level contextvars.ContextVar instead, which is per unit of work for that reason and needs no identifier at all. One slot still holds one context manager, so a unit of work that can start another inside its own context puts a tuple of them in that variable and pops the last entry in the after hook. depin.ext.taskiq.MessageScope is written that way.

>>> from contextlib import AbstractContextManager
>>> from types import TracebackType
>>> from depin import FrozenContainer, ScopeFrame
>>> class JobHost:
...     def __init__(self, container: FrozenContainer) -> None:
...         self._host = Host(container)
...         self._open: dict[str, AbstractContextManager[ScopeFrame]] = {}
...
...     def before(self, job_id: str, name: str) -> None:
...         opened = self._host.scope()
...         frame = opened.__enter__()
...         frame.provide(Job, Job(name))
...         self._open[job_id] = opened
...
...     def after(
...         self,
...         job_id: str,
...         exc_type: type[BaseException] | None = None,
...         exc: BaseException | None = None,
...         tb: TracebackType | None = None,
...     ) -> None:
...         opened = self._open.pop(job_id, None)
...         if opened is not None:
...             opened.__exit__(exc_type, exc, tb)
>>> jh = JobHost(di)
>>> jh.before('j-1', 'reindex')
>>> hosted_container() is di
True
>>> handle()
'reindex (completed=3)'
>>> jh.after('j-1')

Both hooks must run in the same contextvars.Context. The publication is a contextvars.ContextVar, and resetting it from a different context raises a bare ValueErrorToken ... was created in a different Context — which is not a DepinError and escapes __exit__ untranslated. A framework that runs before and after on the same thread or the same task satisfies this; one that copies a fresh context for the after hook does not, and needs the whole unit of work driven from a single callable with a with statement instead.

Host.ascope() is an async context manager, driven the same way through __aenter__ / __aexit__ for a framework whose hooks are themselves coroutines.

Sync or async

scope() suits a WSGI host, a CLI, or anything that never awaits inside the unit of work. ascope() is required the moment any provider in that scope is async — the same rule that already separates resolve() from aresolve():

>>> import asyncio
>>> async def run_job(name: str) -> str:
...     async with host.ascope() as frame:
...         frame.provide(Job, Job(name))
...         return handle()
>>> asyncio.run(run_job('compact'))
'compact (completed=4)'

Mixing them the wrong way fails the way the rest of depin does: resolving an async provider inside a sync scope() raises AsyncInSyncContextError, the same error resolve() raises anywhere else; and a sync scope() that closes over an async provider's teardown raises an ExceptionGroup containing TeardownError, naming ascope() as the fix.

Startup and shutdown

Host.container is the FrozenContainer the host was built around, so startup and shutdown are reached through it rather than through Host itself — a lifespan hook, or its CLI or worker equivalent, is where they belong:

>>> host.container is di
True
>>> [node.key.__qualname__ for node in host.container.warmup().cached]
['Metrics']
>>> host.container.close()

warmup() builds every singleton up front so a broken one fails at startup instead of on the first unit of work; here Metrics is already built from the scopes opened above, so it reports cached rather than constructed. awarmup() and aclose() are the async counterparts, called the same way from an async lifespan:

>>> async def make_pool() -> object:
...     return object()
>>> di2 = Container().bind(make_pool, provides=object).freeze()
>>> host2 = Host(di2)
>>> async def startup_and_shutdown() -> None:
...     report = await host2.container.awarmup()
...     print([node.key.__qualname__ for node in report.constructed])
...     await host2.container.aclose()
>>> asyncio.run(startup_and_shutdown())
['object']

Raising your own error

hosted_container() raises ContainerNotBoundError with a contract-level message. An integration that knows which of its own setup steps a caller skipped gives a more specific error instead, by reading optional_hosted_container() and raising when it comes back None — the way depin.ext.fastapi names the missing RequestScope middleware rather than repeating the contract's generic wording:

>>> from depin import optional_hosted_container
>>> from depin.errors import ContainerNotBoundError
>>> def resolve_job() -> Workspace:
...     container = optional_hosted_container()
...     if container is None:
...         raise ContainerNotBoundError('resolve_job() called outside JobRunner.run(); call it from run().')
...     return container.resolve(Workspace)
>>> resolve_job()
Traceback (most recent call last):
    ...
depin.errors.ContainerNotBoundError: resolve_job() called outside JobRunner.run(); call it from run().

The version

CONTRACT_VERSION names the shape of the contract this release of depin implements:

>>> from depin import CONTRACT_VERSION, ContractVersion
>>> CONTRACT_VERSION >= ContractVersion(1, 0)
True

It covers the four operations above and nothing else: an ordinary FrozenContainer method is versioned by the depin release that introduced it, not by this number.

The minor number rises when an operation is added and every existing one keeps its meaning; the major number rises when an operation changes meaning or is removed. An integration that needs an operation added in 1.2 guards with depin.CONTRACT_VERSION >= ContractVersion(1, 2) rather than pinning a depin release directly.

What not to import

An integration imports from depin only — never from depin._core, which carries no compatibility promise across releases. Every module depin ships under depin/ext/ is held to that rule by tests/unit/test_integration_contract.py, which fails on the literal substring _core appearing anywhere in one of them, prose included. A third-party integration gets the same guarantee depin gives its own: everything a host needs — Host, hosted_container, optional_hosted_container, ContractVersion, CONTRACT_VERSION — is re-exported from depin itself.