Skip to content

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 Spec of the right category;
  • a mapping {"kind": ..., "params": {...}}, where kind is a registered name or a class path "package.module:ClassName". "class" is accepted as an alias for a class-path kind; 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 same X the 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.

c = MyContainer(search_cwd=False)
with use(c):
    Greeter()                                  # store comes from c

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:

[tool.ruff.lint.flake8-bugbear]
extend-immutable-calls = ["coniti.di.Depends"]

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.