Component¶
A Component is a Python class registered in the Component namespace via the
@Component decorator — the foundational building block of the framework. Decorating
a class with @Component is what makes it participate in dependency injection; the
Context reads this namespace at startup to build the dependency graph and create
instances.
Component carries no execution contract. It does not require specific methods to be
implemented. Its role is to be wired into the system, receive its dependencies through
the constructor, and provide whatever capability it encapsulates.
When to use Component¶
Use @Component when a class provides a reusable capability that:
- does not need to run indefinitely (that is a Service)
- does not execute a single bounded unit of work (that is a Task)
- does not coordinate other tasks (that is a Pipeline)
- does not work towards a goal handed over at call time (that is an Agent)
- does not manage the entities of one data source (that is a Repository)
If none of the above apply, it is a Component.
Typical examples:
- a model wrapper (fit, predict)
- a data validator
- a feature transformer
- a notification sender
- a configuration reader
- a client wrapper around an external API
Decorator¶
The @Component decorator registers the class into the Component namespace at
definition time and marks it for dependency injection and lifecycle participation.
The class itself is returned unchanged — no base class is added, no methods are injected.
Without arguments¶
With a name¶
An optional name can be provided to register the component under something shorter
than the kebab-case form of its class name — useful when the class name is long and
you'd rather look it up by a short alias via Component.get(...).
@Component(name="churn-risk")
class CustomerChurnRiskClassifier:
def fit(self, data): ...
def predict(self, data): ...
Combined with a Layer¶
@Component is often stacked with a layer decorator to indicate which subsystem the
component belongs to. Layer comes first (outermost), component type second — a class
takes one execution type, so @Component is not combined with @Service, @Task and
the like (see Namespace):
Dependency Injection¶
Dependencies are declared through the constructor. The Context resolves and injects
them automatically when the component is created.
@Analytics
@Component
class Report:
def __init__(
self,
classifier: Classifier,
metrics: Metrics,
):
self.classifier = classifier
self.metrics = metrics
def build(self, data):
prediction = self.classifier.predict(data)
self.metrics.save(prediction)
...
No service locators. No imports of global registries. The constructor signature is the complete and readable description of what this component needs.
Full Example¶
A realistic component in a data analytics project:
@Analytics
@Component
class Classifier:
def __init__(
self,
config: Config,
metrics: Metrics,
):
self.config = config.component.classifier
self.metrics = metrics
self.model = self._load_model(self.config.model_path)
def fit(self, data):
self.model.fit(data)
self.metrics.save_training_run(data)
def predict(self, data):
return self.model.predict(data)
def _load_model(self, path): ...
Namespace¶
Component is a namespace — a named scope that maps registration names to classes.
Two class methods are available for inspection:
Component.get("Classifier") # → Classifier class, or None if not registered
Component.get("metrics-classifier") # → Classifier class registered under a custom name
dict(Component.items()) # → {"classifier": Classifier, ...}
get() accepts any name form
.get() normalizes its argument before looking it up, so PascalCase
("Classifier"), snake_case ("metrics_fetcher"), and kebab-case
("metrics-fetcher") all resolve to the same registration — that's why
Component.get("Classifier") finds a class stored under the kebab-case key
"classifier".
The Context uses these to discover registered components at startup. Application
code does not call them directly.
Details¶
A Component may optionally implement details() to surface key/value
information about itself — a running service's URL, a port it bound, a
dashboard link. It is independent of on_start()/on_stop() and is never
called by the Context; it's detected structurally and called on demand by
whatever's inspecting the component, so values that only exist after
startup are reflected rather than frozen at resolve time.
@Api
@Service
class Dashboard:
def __init__(self, config: Config):
self.port = config.service.dashboard.port
def details(self) -> dict[str, str]:
return {"UI": f"http://localhost:{self.port}"}
details() is detected structurally via DetailsProtocol
(opendataframework.component.DetailsProtocol, @runtime_checkable) — no base class or
decorator required:
A component with no details() method simply has nothing to surface here —
this is entirely optional.
Seeing it in practice
The sibling odf package's
UI has a Details button in its object inspector; clicking it calls
details() on demand, via GET /api/components/{id}/details, and shows
the result. That's just today's one consumer, though — nothing here
depends on it.
Chart¶
A Component may optionally implement chart() to render a custom
visualization, backed by whatever Python charting library it likes
(matplotlib, plotly, ...) rather than requiring a JS charting library on
the consumer's side. Like details(), it is independent of
on_start()/on_stop() and is never called by the Context — it's
detected structurally and called on demand by whatever's inspecting the
component, so the chart reflects live repository state on every call
rather than being frozen at resolve time.
chart() must return a self-contained HTML document — inline image/JS, no
external file references — so a caller can embed it safely:
@Analytics
@Component
class UsersByStore:
def __init__(self, users: Users, stores: Stores):
self.users = users
self.stores = stores
def chart(self) -> str:
fig = ... # build a matplotlib/plotly/... figure from self.users, self.stores
return f"<html><body><img src='data:image/png;base64,{encoded}'></body></html>"
Because the chart lives on a normal DI-managed Component, it gets
constructor-injected access to repositories and other components just like
any other component — no separate data-fetching layer is needed.
chart() is detected structurally via ChartProtocol
(opendataframework.component.ChartProtocol, @runtime_checkable) — no base class or
decorator required:
A component with no chart() method simply has no chart to show — this is
entirely optional.
Seeing it in practice
The sibling odf package's
UI calls chart() on demand, per request to
GET /api/components/{id}/chart, serves the returned string as
text/html, and renders it inside a same-origin <iframe> — which is
why the HTML must be self-contained. That's just today's one consumer,
though — nothing here depends on it.
MCP Tools¶
A Component may optionally implement mcp_tools() to expose its own
custom MCP tools, in addition to the fixed lifecycle/task/pipeline
surface. Like details()/chart(), it is independent of
on_start()/on_stop() and is never called by the Context — it's
detected structurally and consumed on demand by whatever builds an MCP
tool surface.
@Component
class Weather:
def __init__(self, config: Config):
self.api_key = config.component.weather.api_key
def forecast(self, city: str) -> dict: ...
def mcp_tools(self) -> list[McpTool]:
return [
McpTool(
name="forecast",
description="Get the weather forecast for a city.",
handler=self.forecast,
)
]
mcp_tools() is detected structurally via McpToolsProtocol
(opendataframework.component.McpToolsProtocol, @runtime_checkable) — no
base class or decorator required:
Each returned McpTool carries a name, description, handler (a
callable — usually a bound method), and structured_output flag. A
component with no mcp_tools() method simply exposes no custom tools —
this is entirely optional.
Whoever calls a tool — typically a model — sees only its description and
the handler's signature, so an McpTool checks both when it is created:
- the
descriptionis not empty —ValueErrorotherwise; - every parameter of the handler is annotated;
- the handler takes no
*argsor**kwargs; - the handler has a return annotation (
-> Nonecounts); - no annotation is a string — as every one is under
from __future__ import annotations.
A handler that falls short raises TypeError, naming the tool and
everything that is missing. A lambda has no annotations, so it can't be a
handler — use a method or a function.
Because detection is structural, any class the Context creates can
provide tools this way — a Repository, a
Service, a Task, an Agent — not
only a @Component.
A function as a tool¶
A handler needn't be a method. A registered Function the class takes through its constructor arrives with its own dependencies filled in and a signature that leaves them out, so it can be returned as a handler directly:
@Function
def find_titles(catalogue: Catalogue, author: str) -> list[str]:
"""Find the titles the catalogue holds by an author."""
return catalogue.titles_by(author)
@Component
class Library:
def __init__(self, find: Annotated[Callable[..., list[str]], find_titles]):
self.find = find
def mcp_tools(self) -> list[McpTool]:
return [
McpTool(
name="find-titles",
description="Find the titles the catalogue holds by an author.",
handler=self.find,
)
]
The tool asks for author only. Returning it from a class lets the tool
take its own name and description, and sit beside the class's other tools.
See Injecting a Function.
Seeing it in practice
This package only defines the McpTool/McpToolsProtocol shape —
it has no MCP server of its own. The sibling
odf package's
McpServer is the consumer: it registers each returned McpTool
under <key>.<tool.name> (component.weather.forecast), next to its
six fixed built-in tools. It also offers every resolved
Agent as <key>.invoke and every registered
Function as <key>.call without any mcp_tools(), so
a tool returned here must not reuse one of those names.
What Component is Not¶
-
Not a base class.
Classifierdoes not inherit fromComponent. The decorator registers it; inheritance is not involved. -
Not an execution unit. The framework does not call any method on a
Componentautomatically — it is instantiated and wired, nothing more. If you need the framework to callrun()orexecute(), use Service or Task instead. -
Not created more than once. The
Contextcreates exactly one instance per registered class and returns that same instance from everycontext.get(cls)call — see Context.
Decision Guide¶
| I need to... | Use |
|---|---|
| Provide a reusable capability | Component |
| Run a process indefinitely | Service |
| Execute a bounded unit of work once, no arguments | Task |
| Call a unit of work with arguments | Function |
| Coordinate multiple tasks into a workflow | Pipeline |
| Work towards a goal handed over at call time | Agent |
| Manage persistent data | Repository |