Skip to content

Context

The Context is the framework's dependency-injection and lifecycle engine. It manages the full lifecycle of application components — registering them, resolving their dependencies, creating instances in the correct order, and coordinating startup and shutdown.

Everything the framework does flows through the Context. Project owns it and exposes project.context.get(cls) to fetch any resolved instance by its class.

Minimal, self-contained example

Any registered class works — here's a plain Component, defined and fetched:

@Component
class Classifier:
    def predict(self, data): ...


project = Project.from_config("config.toml")
project.start()

classifier = project.context.get(Classifier)
classifier.predict(data)

Responsibilities

Responsibility Description
Registration Tracks all classes and functions declared with framework decorators
Dependency resolution Builds the dependency graph from constructor signatures
Instance creation Creates instances in dependency order
Lifecycle management Drives the lifecycle of every component in the correct order

Registration is global; instances are per-Context

Every class registered with a framework decorator becomes part of the shared namespace the moment its module is imported — every Project's Context instantiates it, regardless of that project's config. A [section] in config.toml only supplies values a constructor reads via config: Config; it doesn't determine which classes get built. What isn't shared is state: each Project owns its own Context, so its instances stay separate from any other Project in the same process.

[project] app controls import timing, not scope

[project] app = "app" in config.toml just runs importlib.import_module("app") before the Context resolves — equivalent to writing import app by hand before Project.from_config()/Project.from_dict() is called. It doesn't create a new per-Project component-isolation mechanism: the global-registration behavior described above still fully applies — once a module is imported (by this key or by any other means), every Project's Context in the process can see the classes it registers. See Project.


How it Works

The Context operates as an internal engine — components are registered via decorators at class definition time, and the Context does the rest when the Project starts. Most end users never interact with it directly.

Decorators register components at definition time
    → @Component, @Service, @Task, @Pipeline, @Agent, @Repository, @Function

project.start() triggers context initialisation
    → Context builds the dependency graph from constructor signatures
    → For each component in dependency order:
        → if Service:                     setup() → run() (backgrounded by framework)
        → if Component/Repository defines on_start(): called directly, blocking
        → otherwise:                      instantiated only
    → project.start() returns — everything is ready

project.stop() triggers context teardown
    → For each component in reverse dependency order:
        → if Service:                     stop() (signals background thread to exit)
        → if Component/Repository defines on_stop(): called directly, blocking
        → otherwise:                      no teardown

Dependency Graph

The Context derives the dependency graph automatically from constructor signatures. No explicit dependency declarations are needed.

@Api
@Service
class UsersApi:
    def __init__(self, users: Users):
        self.users = users


@Storage
@Repository(User)
class Users:
    def __init__(self, postgres: Postgres):
        self.postgres = postgres


@Storage
@Service
class Postgres:
    def __init__(self, config: Config):
        self.config = config

The Context resolves the full graph:

Config      ← no dependencies, initialised first
    ↓
Postgres    ← depends on Config
    ↓
Users       ← depends on Postgres
    ↓
UsersApi    ← depends on Users

A registered Function is part of the same graph. It depends on the managed types in its signature, and a class depends on it by naming it in Annotated[..., func] — see Injecting a Function. It is bound in dependency order alongside the classes, but has no lifecycle and is not in context.instances.

One of each

The Context holds a single instance of every class, so asking for the same one twice — def __init__(self, source: Db, target: Db) — would hand over the same object under both names. That raises ValueError at project.start(), naming the class, the dependency and the parameters. If two are needed, register two classes.


Startup Order

Startup runs in dependency order — dependencies first, dependents after.

For each component, the framework applies the appropriate lifecycle:

Postgres
    → setup()             ← Service lifecycle, blocking
    → run()               ← backgrounded by framework, continues to next

Users
    → instantiated        ← Repository (no on_start() defined here)

UsersApi
    → setup()             ← Service lifecycle, blocking
    → run()               ← backgrounded by framework

project.start() blocks until all components have completed their initialisation stage. After it returns, everything is ready — no hidden latency, no partial state.

Config isn't part of this walk

Config isn't decorated with @Component and isn't registered in any namespace, so it's never collected into the dependency-ordered list above. Project/Context construct it upfront and seed it directly into the resolved instances, before dependency resolution even starts — it's simply already there for anything that declares config: Config to receive, with no lifecycle of its own.

Readiness — do setup work inside setup()

Services should complete all setup work inside setup(). The framework waits for setup() to return before proceeding to the next component, so any service that opens connections or runs migrations in setup() is guaranteed to be ready before dependents start.

Component/Repository startup hook

If Users (or any Component/Repository) defines on_start(), it's called at this same point in dependency order, blocking, right where "instantiated" appears above. It's optional and independent of on_stop() — see Repository for the full pattern (connections, file handles, and similar lightweight setup that should happen once, at Project start).


Shutdown Order

Shutdown runs in reverse dependency order — dependents are stopped before their dependencies.

UsersApi    → stop()      ← Service stopped first
Users       → (none)      ← Repository (no on_stop() defined here)
Postgres    → stop()      ← Service stopped last

This ensures no component is stopped while something that depends on it is still running.

Config isn't part of this walk either

Just as in Startup Order, Config was never collected into the dependency-ordered list, so it doesn't appear above at all — unlike Users, which is listed with (none) because it is part of the walk but simply has no on_stop() to call.

Component/Repository shutdown hook

If Users (or any Component/Repository) defines on_stop(), it's called at this same point in reverse dependency order, blocking.


Circular Dependencies

Circular dependencies indicate a design problem and are not supported. If A depends on B and B depends on A, the Context cannot determine a valid initialisation order.

Raises ValueError

Detecting a cycle raises ValueError, naming the classes — and any functions — involved: Circular dependency detected among: A, B. This surfaces at project.start(), not at class-definition time — decorators only register classes, they don't check the graph.

The fix is to introduce a third collaborator that both can depend on, or replace one of the dependencies with an event or callback:

# problem
class A:
    def __init__(self, b: B): ...


class B:
    def __init__(self, a: A): ...  # circular


# solution — extract shared concern into C
class A:
    def __init__(self, c: C): ...


class B:
    def __init__(self, c: C): ...

Direct Access

project.context.get(cls) fetches any resolved instance by its class — the intended entry point for top-level application code, scripts, and notebooks:

users = project.context.get(Users)
users.all()

project.context.instances is the lower-level, untyped dict backing get() — primarily useful for framework extension authors:

# use the context directly — useful in extensions and tooling
with project.context as ctx:
    component = ctx.instances[UsersApi]

get(cls) relies on every registered class being a per-Context singleton — exactly one instance per class, stored in instances. Logger is the one constructor dependency that breaks this: it isn't registered in any namespace and has no slot in instances at all, so context.get(Logger) doesn't work — see Logger.

get() also accepts a registered Function, returning it with its managed dependencies already filled in:

load = project.context.get(load_data)
load("data.csv")  # db injected

Functions are kept out of instances — that mapping holds classes only. project.context.functions is the equivalent for functions (function → bound callable), and project.context.parameters(func) returns the parameters a caller still has to supply. See Function.


By Key — Single-Component Control

Beyond get(cls), Context exposes a control surface addressed by key — every method below takes <namespace>.<name>, where <name> is the name the object is registered under (its kebab-case class or function name, or the explicit name=):

Registered as Key
@Service class Postgres "service.postgres"
@Repository(Frame) class Webcam "repository.webcam"
@Pipeline class DailyAnalytics "pipeline.daily-analytics"
@Task("export") class ExportUsers "task.export"
@Function def load_data "function.load-data"
@Agent class Librarian "agent.librarian"

A name is unique only within its namespace, so the namespace is always part of the key: a @Task class Loader and an @Agent class Loader can live in the same project as "task.loader" and "agent.loader". Keys are accepted in any casing ("Task.LoadJob" is "task.load-job"). A string without a namespace raises ValueError, and a key that matches no resolved object raises KeyError. project.context.key(obj) returns the key of a class or function you already hold:

project.context.key(DailyAnalytics)  # → "pipeline.daily-analytics"

This is the mechanism a UI, CLI, or MCP server built on top of the framework uses to control one component at a time without importing its class:

Services — start, stop, or check a single Service independent of the normal dependency-order startup/shutdown driven by project.start()/ project.stop():

project.context.start("service.postgres")  # no-op if already running
project.context.stop("service.postgres")  # no-op if not running
project.context.is_running("service.postgres")  # → bool

Each raises TypeError if the matched instance isn't a Service.

Streams — start, stop, check, or attach to a StreamableProtocol repository's background stream (see Repository):

project.context.start_stream("repository.webcam")  # spawns the background thread
project.context.stop_stream("repository.webcam")  # no-op if not streaming
project.context.is_streaming("repository.webcam")  # → bool

for frame in project.context.iter_stream("repository.webcam"):
    ...  # one subscriber; detaches when the stream stops or iteration ends

start_stream()/stop_stream() mirror start()/stop() for services and raise TypeError if the matched instance isn't StreamableProtocol. iter_stream() only attaches to an already-running stream — it doesn't start one itself, and raises KeyError if the stream isn't currently running.

Tasks and Pipelines — execute a single resolved Task or Pipeline:

project.context.execute("pipeline.daily-analytics")  # → whatever execute() returns

Raises TypeError if the matched instance is neither.

Functions — call a single resolved Function. Unlike execute(), call() forwards arguments: the Context fills in the parameters whose type it manages, and the keywords supply the rest:

project.context.call("function.load-data", path="data.csv")  # db injected

Raises TypeError if a required argument is neither managed nor supplied.

Agents — call() also invokes a single resolved Agent, passing the keywords on to its invoke(). An agent's dependencies were already injected through its constructor, so every keyword here is one of invoke()'s own arguments:

project.context.call("agent.librarian", question="Anything by Herbert?")

A function and an agent may share a name — "function.librarian" and "agent.librarian" are different keys.

project.context.parameters(target) returns what a caller must supply to call() — a function's parameters minus the injected ones, or the parameters of an agent's invoke().

Logs — read back a component's recent log lines (see Logger); each key has its own <namespace>.<name>.log file:

project.context.tail_logs("service.postgres", lines=300)
# → [{"ts": ..., "level": ..., "message": ...}, ...], oldest first

What Context is Not

  • Not a service locator for internal wiring. Components should declare dependencies through their constructor, not look them up from the Context at runtime — project.context.get(cls) is for top-level and cross-cutting access, not a replacement for dependency injection between components.

  • Not directly called by end users during startup. Registration and wiring happen through decorators and constructor signatures; the Context resolves and drives lifecycle as an internal engine. project.context.get(cls) is the entry point end users reach for only after project.start() returns — and even then, it's not something that should be used directly in most cases: most dependency logic belongs inside a class that receives it via constructor injection, not in code that pulls it from the Context directly.

  • Not async. The Context calls and never awaits. An async def in anything it would call — a Function, execute(), invoke(), setup()/run()/ stop(), on_start()/on_stop(), a repository's stream methods — makes project.start() raise TypeError naming it, before anything is created. Other async def methods on a component are yours to call and are left alone.

  • Not a reader of string annotations. Dependencies are injected by type, and an annotation that is a string is not one — every annotation is, under from __future__ import annotations. A required constructor parameter left unfilled for that reason raises a TypeError that says so; remove the import, or the quotes around the annotation.

  • Not responsible for threading. The framework handles Service.run(), and a streamable Repository's stream(), in background threads automatically. Neither the service/repository author nor the context caller needs to manage this directly.