Skip to content

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

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

    def predict(self, data): ...

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):

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

    def predict(self, data): ...

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:

class DetailsProtocol(Protocol):
    def details(self) -> dict[str, str]: ...

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:

class ChartProtocol(Protocol):
    def chart(self) -> str: ...

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:

class McpToolsProtocol(Protocol):
    def mcp_tools(self) -> list[McpTool]: ...

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 description is not empty — ValueError otherwise;
  • every parameter of the handler is annotated;
  • the handler takes no *args or **kwargs;
  • the handler has a return annotation (-> None counts);
  • 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. Classifier does not inherit from Component. The decorator registers it; inheritance is not involved.

  • Not an execution unit. The framework does not call any method on a Component automatically — it is instantiated and wired, nothing more. If you need the framework to call run() or execute(), use Service or Task instead.

  • Not created more than once. The Context creates exactly one instance per registered class and returns that same instance from every context.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