Skip to content

Declarative provider discovery

Declarative discovery starts from objects named explicitly in Python code. A module declares its own providers, closes them into an immutable Catalog, and a composition root imports completed catalogues into a Manifest. depin never walks a package, searches module globals, follows naming conventions, or imports modules on the application's behalf.

Declare providers in their owning module

provider() records one class or factory together with the module that declared it. The resulting Provider is immutable. configure() returns a replacement declaration, leaving the original unchanged, and stores metadata for later processing.

>>> from depin import Catalog, Scope, provider
>>> class Cache: ...
>>> declaration = provider(Cache)
>>> transient = declaration.configure(scope=Scope.TRANSIENT, tag='worker')
>>> providers = Catalog(__name__, transient)
>>> providers.providers == (transient,)
True
>>> declaration is transient
False

Construct Catalog(__name__, ...) in that same module after its declarations. The first argument must identify the calling module, and every member must have been declared there. Export the completed catalogue; do not export individual declarations for another module to assemble. A catalogue contains only local providers and deliberately is not a Bindings source.

Compose explicit imports with a manifest

The import graph is the discovery graph. A provider module exports a completed catalogue, and a composition module imports that object explicitly:

# billing/providers.py
from depin import Catalog, provider


class BillingService: ...


billing = Catalog(__name__, provider(BillingService))
# application.py
from depin import Manifest
from billing.providers import billing


application = Manifest(__name__, billing)

Manifest is owned by the module that constructs it, but its sources are the completed Catalog and Manifest objects explicitly imported there. Nested manifests let a package publish a larger boundary without hiding its imports. Only the manifest satisfies Bindings, so pass it to a container and freeze the ordinary dependency graph:

>>> from depin import Catalog, Container, Manifest, provider
>>> class Settings: ...
>>> class Service:
...     def __init__(self, settings: Settings) -> None:
...         self.settings = settings
>>> local = Catalog(__name__, provider(Settings), provider(Service))
>>> application = Manifest(__name__, local)
>>> runtime = Container(application).freeze()
>>> isinstance(runtime[Service].settings, Settings)
True

There is no separate discovery runtime. Container.include(application) and Container(application) ingest the same records, and freeze() validates them with manually registered bindings as one graph.

Interleave manifests and manual bindings

A manifest is a binding source, not a special container mode. Include it at the exact point where its records belong among calls to bind(), value(), alias(), and the other registration methods. Each source is appended as one contiguous segment.

>>> from depin import Catalog, Container, Manifest, provider
>>> class Before: ...
>>> class Imported: ...
>>> class After: ...
>>> imported = Manifest(__name__, Catalog(__name__, provider(Imported)))
>>> runtime = Container().bind(Before).include(imported).bind(After).freeze()
>>> isinstance(runtime[Before], Before)
True
>>> isinstance(runtime[Imported], Imported)
True
>>> isinstance(runtime[After], After)
True

Registration order is retained for diagnostics and deterministic composition; dependency order is still derived from the validated graph at freeze().

Repetition remains a conflict

Manifest sources flatten from left to right. Nesting preserves that order, and repeating a catalogue or manifest repeats every occurrence. Nothing silently de-duplicates or gives the later declaration precedence, so repeated keys reach the normal duplicate-provider validation.

>>> from depin import Catalog, Container, Manifest, provider
>>> from depin.errors import DuplicateProviderError
>>> class Worker: ...
>>> workers = Catalog(__name__, provider(Worker))
>>> leaf = Manifest(__name__, workers)
>>> repeated = Manifest(__name__, leaf, leaf)
>>> try:
...     Container(repeated).freeze()
... except DuplicateProviderError:
...     print('duplicate rejected')
duplicate rejected

This makes conflicts visible at the composition root instead of allowing import order to choose a winner.

Reloads create new snapshots

Catalogues and manifests retain immutable tuples of the objects supplied at construction. Reloading a provider module creates a new exported catalogue; it does not mutate catalogues already imported or manifests already built. Re-import the completed catalogue and rebuild the manifest when a reload should change the application snapshot.

Rebinding a local name illustrates the same boundary:

>>> from depin import Catalog, Container, Manifest, provider
>>> from depin.errors import MissingProviderError
>>> class First: ...
>>> class Replacement: ...
>>> exported = Catalog(__name__, provider(First))
>>> snapshot = Manifest(__name__, exported)
>>> exported = Catalog(__name__, provider(Replacement))
>>> runtime = Container(snapshot).freeze()
>>> isinstance(runtime[First], First)
True
>>> try:
...     runtime[Replacement]
... except MissingProviderError:
...     print('not in the snapshot')
not in the snapshot

Declaration, freeze, and resolution stay separate

Declaring providers and composing snapshots calls neither conditions nor factories. freeze() evaluates when conditions and validates only the active records. Resolution constructs the selected provider; a health check, when configured, runs only through the frozen container's health operations.

>>> from depin import Catalog, Container, Manifest, provider
>>> events: list[str] = []
>>> class Service: ...
>>> def enabled() -> bool:
...     events.append('condition')
...     return True
>>> def build_service() -> Service:
...     events.append('factory')
...     return Service()
>>> declared = provider(build_service).configure(when=enabled)
>>> application = Manifest(__name__, Catalog(__name__, declared))
>>> events
[]
>>> runtime = Container(application).freeze()
>>> events
['condition']
>>> isinstance(runtime[Service], Service)
True
>>> events
['condition', 'factory']

An unlisted class or factory remains unlisted even when it shares a module, package, annotation, or name with a declaration. The explicit objects in the manifest are the complete discovery boundary.