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:
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:
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:
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:
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:
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:
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:
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
Contextat 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
Contextresolves and drives lifecycle as an internal engine.project.context.get(cls)is the entry point end users reach for only afterproject.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 theContextdirectly. -
Not async. The
Contextcalls and never awaits. Anasync defin anything it would call — a Function,execute(),invoke(),setup()/run()/stop(),on_start()/on_stop(), a repository's stream methods — makesproject.start()raiseTypeErrornaming it, before anything is created. Otherasync defmethods 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 aTypeErrorthat says so; remove the import, or the quotes around the annotation. -
Not responsible for threading. The framework handles
Service.run(), and a streamableRepository'sstream(), in background threads automatically. Neither the service/repository author nor the context caller needs to manage this directly.