The Container (dependency injection)¶
Built and verified
coniti.di is built and tested. The base Container has four real slots
with package defaults: memory, embedding, structures and strategy.
The example under A runnable example defines its own
interface so it stays independent of the real pieces.
Container is a single small class that decides which implementation every
moving piece uses and hands out the instance. It has no external dependency.
Parts never construct each other: they declare what they need with
Depends(...).
from coniti.di import (
Container, Slot, Depends, inject, use, current,
ConfigError, ResolvedBinding, ENV_VAR, LAYERS,
)
ENV_VAR is "CONITI_CONFIG". LAYERS is the layer names, lowest to highest.
Slots and defaults¶
A container has two class attributes:
slots: config key ->Slot(interface, category, many=False, deps=None). A slot says which base class instances must satisfy, which registry/spec category it uses, whether the piece is a list, and optionally a function computing extra keyword dependencies for the factory.defaults: config key -> the default binding for that key.
The base Container ships these slots and defaults. Subclass it to add slots or
change defaults without editing coniti.
| Config key | Interface | Default kind | Status |
|---|---|---|---|
memory |
MemoryBackend |
in_memory |
Done |
embedding |
Embedder |
local_hash |
Done |
structures (a list) |
Structure |
[flat] |
Done |
strategy |
Strategy |
default |
Done |
from coniti import Container, Embedder, MemoryBackend, Structure
c = Container(search_cwd=False)
c.get(MemoryBackend) # an InMemoryBackend
c.get(Embedder).dim # 256 (local_hash default)
c.get_all(Structure) # [FlatStructure], built with dim 256
Structures follow the embedder’s dimension¶
A structure needs the vector length (dim). The container takes it from the
configured embedder and passes it in when it builds each structure. So changing
the embedding dimension, through config, bind or override, rebuilds the
structures to match, and leaving an override restores the previous
structures (the exact instances, as for every piece).
c = Container({"embedding": {"params": {"dim": 64}}}, search_cwd=False)
c.get(Embedder).dim # 64
c.get_all(Structure)[0].dim # 64
with c.override(Embedder, {"kind": "local_hash", "params": {"dim": 128}}):
c.get_all(Structure)[0].dim # 128: rebuilt to match
c.get_all(Structure)[0].dim # 64: the previous structure is back
Layers¶
Each piece resolves through layers, lowest to highest priority:
default < config < bind < override < explicit argument
- Same implementation (or a binding with only
params): the params are deep-merged over the lower layer. - Different implementation: replaces the lower binding.
- List-valued pieces: replaced as a whole.
Bindings¶
A binding is one of:
- a
Specof the right category; - a mapping
{"kind": ..., "params": {...}}, wherekindis a registered name or a class path"package.module:ClassName"."class"is accepted as an alias for a class-pathkind; giving both is an error; - a ready instance of the interface;
- for a list piece, a list of the above.
A class path is imported, checked to subclass the interface, and its params validated. The class need not be registered.
A runnable example¶
Self-contained: it defines its own interface, two implementations and a
container. (The tests use the same shape in tests/di_toys.py.)
from typing import Any, ClassVar
from coniti import Container, Depends, Params, Spec, inject, use
from coniti.di import Slot
from coniti.registry import default_registry
class Store: # the interface
def __init__(self, params: Params, **deps: Any) -> None:
self.params = params
class StoreParams(Params):
size: int = 10
class StoreSpec(Spec): # the spec for this category
category: ClassVar[str] = "demo_store"
id_prefix: ClassVar[str] = "dst"
default_registry.define_category("demo_store", Store)
@default_registry.register("demo_store", "small", StoreParams)
class SmallStore(Store): ...
@default_registry.register("demo_store", "big", StoreParams)
class BigStore(Store): ...
class MyContainer(Container):
slots: ClassVar[dict[str, Slot]] = {"store": Slot(Store, "demo_store")}
defaults: ClassVar[dict[str, Any]] = {"store": {"kind": "small"}}
c = MyContainer({"store": {"params": {"size": 50}}}, search_cwd=False)
c.get(Store).params.size # 50: params merged over the default kind
c.bind(Store, {"kind": "big"}) # permanent for this container
with c.override(Store, {"kind": "small"}): # temporary; nests
c.get(Store) # a SmallStore
c.get(Store) # the BigStore from before, not rebuilt
class Greeter:
@inject
def __init__(self, store: Store = Depends(Store)) -> None:
self.store = store
Greeter(store=SmallStore(Params())) # explicit argument wins
Getting instances¶
get(interface_or_key)returns a single-valued piece;get_all(...)a list-valued one. Each is built once and cached per container.- Instances are shared across pieces of one container: a plug-in injected with
Depends(X)gets the sameXthe container hands out. Separate containers share nothing. build(binding, interface)makes a one-off instance. It is not cached and not bound; use it for a plug-in that wraps a different instance of the same interface.bind(interface, binding)is permanent for the container.override(interface, binding)is a context manager. It nests, and on exit (even on error) it restores the exact previous instances, not rebuilt ones, so state such as stored memory survives. An invalid override is rejected before the block is entered.- Rebinding a piece drops everything that was built on it (dependencies are tracked), so nothing stale is kept.
resolved()reports, per piece, the layer that decided, kind, normalised params, spec id and implementation name. It builds nothing.
Depends, inject, use, current¶
Depends(Interface, many=False) marks a parameter default. @inject fills any
Depends parameter the caller did not pass from current(): the innermost
use(container) block, else a process-wide default container. An explicit
argument wins; it may be an instance, or a spec or mapping that is built one-off.
Errors¶
Invalid bindings raise ConfigError (a ValueError) with .key (the piece)
and .layer; both appear in the message. Covered: unknown kind, unimportable
class path, a module that raises on import, a class that does not subclass the
interface, both kind and class, missing, empty or non-string kind, bad
params (wrong type or unknown field), params not a mapping, unexpected binding
keys, wrong binding type, a spec of the wrong category, list versus single
mismatch, a dependency cycle (reported, not recursed; the container stays
usable), and a factory that raises (wrapped; the container recovers).
Linting note¶
Depends(...) in a parameter default trips flake8-bugbear rule B008. coniti’s
own ruff config lists coniti.di.Depends under extend-immutable-calls; do the
same in your project:
Config files are trusted input
A class path is imported, and importing runs code. Only load config files you trust.
See configuration for the file format.