Component model¶
Everything a developer defines in Interloper is a component: assets, sources, destinations,
connections, configs, jobs, hooks. Components share one base with two layers. Serializable is
anything that is "a class plus its configuration"; Component adds kind, identity and relations
and makes the object a catalog citizen. This page is for people writing new component classes
or new kinds. The reference comes first; Writing a kind at the end walks
through it in the order you would do it.
Serializable¶
A pydantic model with:
key: class-level, snake_cased from the class name unless declared.- Strict construction: unknown keyword arguments raise
TypeError. - Specs:
to_spec(),from_spec(),from_spec_file(),classpath(),resolve_path(). See Specs and serialization. Several roots written or read as one document share aSerializationContext, which holds the one rule for inlining, referencing and dropping a bound target; every public entry point starts its own, so only a multi-root container such as the DAG ever passes one. config_schema(): the JSON Schema of user-facing fields, with framework fields and the class'sinternal_fieldsstripped.build_class(body, **settings): the one construction behind every decorator. A decorator reads its target into aClassBody(a class as written, or a function read by the kind'sbody_from_function) and gets one new class back, with every setting in its namespace: publicClassVars as values, field defaults as fields, relations in therelationsdict. A class that already extends the kind becomes the base of the new one and keeps its key and kind. Decorators build classes; they never modify one.
Runners, normalizers and schemas are Serializable without being components.
Component¶
On top of Serializable:
| Attribute | Level | Meaning |
|---|---|---|
kind |
class | The component category (source, asset, connection, …). Set automatically for direct children of Component; inherited below that. |
id |
instance | A UUID by default; the identity persisted relations point at. |
name, icon |
class | Display metadata; name defaults to a label built from the class name. |
relations |
class | Relation name to Relation, the links this class declares (below). |
parent |
instance | The component that owns this one, None when it stands alone. A source owns its assets. |
owned |
instance | The components this one owns: those held in one of its fields whose parent is this component. They travel inside its spec, under that field, as {key: init}. A source owns its assets. |
sensitive |
class | Whether stored configuration must be encrypted. True for resources. |
state_model |
class | A pydantic model of machine-owned state (job timestamps, renewal times). Its JSON Schema becomes state_schema in the definition. |
internal_fields |
class | Fields hidden from the config schema. |
definition() returns a ComponentDefinition (kind, key, path, name, icon, description, tags,
config_schema, state_schema, relations); it is the one place a class is described, and every
FetchField provider is checked against the relations there. A kind whose definition carries more
builds its own model from the base one, SourceDefinition(**dict(super().definition()), assets=...):
SourceDefinition adds assets, AssetDefinition adds asset_schema and partitioning,
ResourceDefinition adds provider, checkable and renewable. A destination describes nothing
beyond the base, so Destination.definition() returns a plain ComponentDefinition.
Kinds and anchors¶
Each kind has an anchor: the base-most class declaring it (Connection for every
connection). Component.anchor() resolves it, and the KINDS registry maps kind names to
anchors. Anchors are declared through the interloper.kinds entry-point group; the core
declares source, asset, destination, resource, connection, config, job and
hook. A new kind is a new anchor class and one entry-point line:
class Report(il.Component):
"""A rendered document built from assets."""
inputs: list[il.Asset] = il.Relation("asset", many=True, optional=True)
A catalog containing a component of an unregistered kind raises ConfigError.
Relations¶
A relation is one declared link from a component to the components that may fill it. One class,
il.Relation, declares every link on every kind: a connection an asset injects, the
destinations a source writes to, a job's targets, an upstream asset.
| Field | Meaning |
|---|---|
kind |
The component kind, or kinds, the relation may point at. |
key |
The keys it narrows to: an exact key, source.asset, *.asset, a list, or "" for any key of those kinds. |
many |
Whether it binds several components at once. |
optional |
Whether it may stay unbound, or be emptied once bound. Says nothing about data, and nothing about deletion. |
default |
A zero-argument factory producing the value an unbound relation resolves to. |
on_delete |
What deleting a bound target does, and the only field that decides it: block (the default) refuses the deletion while this relation holds the target, detach lets it through and drops the binding. Orchestration pointers (a job's targets, a hook's watches) and inferred optional upstreams declare detach; consumption relations keep block, an optional one included. |
Two attributes are derived, not declared: name is stamped from the attribute the relation is
declared under, and target is the class the il.Relation(cls) shorthand was written with,
which is what a fallback is built from. Neither is a constructor argument.
il.Relation(PostgresConnection) is shorthand for
il.Relation(kind="connection", key="postgres_connection") with the class kept as target.
accepts(kind, identity, owner=...) is the one place a candidate is judged against a
declaration, and ComponentIdentity.satisfies the one place a declared key is compared to a
concrete component. An asset's key is source-local, so a bare key is scoped to the owner's
source; every other kind is keyed globally by its catalog key.
Three declaration forms, in increasing precedence, all merged at class creation:
class Widget(il.Component):
connection: PostgresConnection # an annotation naming a class
config: WidgetConfig = il.Relation(WidgetConfig, optional=True) # a typed Relation attribute
relations = {"cache": il.Relation(Cache)} # what the decorators emit
The map merges over every base's, so a subclass entry replaces the inherited one of the same
name and nothing an ancestor declared is lost. Each entry is copied, stamped with its name and
installed under it: a Relation is its own descriptor, so Widget.connection is the
declaration and widget.connection what is bound to it. An annotated relation is dropped from
the class's annotations before pydantic collects its fields, so a relation is never also a
field. The two collectors read the annotations against different namespaces (the declaring
module here, the full defining scope in pydantic), so an annotation naming a component class
that ends up a plain field, and one that resolves nowhere at all, are both TypeError at class
definition rather than a component silently missing a relation.
Operations, all of them on Component:
| Method | What it does |
|---|---|
bind(name, *targets) |
Writes bindings, together with unbind and attribute assignment (which routes through the same checks, see below). Checks accepts for each target; many accumulates and collapses duplicates, single-valued replaces what it holds and refuses more than one target at a time. |
unbind(name, *targets) |
Detaches. Refused when it would empty a non-optional relation. |
bound(name) |
What the component holds itself: a list for many, the single component or None. |
resolve(name) |
What a reader gets: the own binding, else what the owner holds under the same name and this relation accepts, else the relation's fallback for a single-valued relation and an empty list for a many one. Attribute access reads the same, without the fallback. Fallbacks are never bound. |
validate_relations(nodes=None) |
Unbound non-optional without a fallback, several targets on a single-valued relation, a target the relation does not accept, and (with nodes) a non-optional asset target absent from the run. |
Inherited settings: a field declared with inherit=True on a field factory (or
json_schema_extra={"x-inherit": True} on a plain Field) stores only the component's own value,
None while unset, and reads through its owner on every read. The owner decides what its child
reads in one method, inherited(child, name, own): the base returns own when set, else the
owner's own value under that name. Source overrides it to fill a dataset's {field} template
and to disable its assets while it is disabled. Specs and configs carry stored values only.
Asset kinds vary through three hooks, and override nothing else of the pipeline:
| Hook | Asset does |
Overridden by |
|---|---|---|
_upstream(target, window, metadata) |
reads the upstream's data (Upstream.read), or refers to it when the class sets reads_upstream_data = False |
IbisView, to type the reference as an Ibis table |
_output(result) |
shapes, normalizes and conforms what data() returned (Output.from_result) |
View, to keep the definition as it is; IbisView, to check and wrap it |
_land(output, window, metadata) |
writes to every destination, one observed step each | View, to create the view |
A relation name is also a constructor keyword and an assignable attribute; assignment goes
through Relation.__set__, which shares bind's single write path: the replacement is checked
before the existing binding is touched (so a rejected assignment leaves the previous one exactly
as it was), duplicates collapse, and clearing a non-optional relation raises ConfigError.
definition().relations exports each relation for the catalog and the UI: kind, key,
many, optional, on_delete.
Discriminator¶
One configuration field may carry discriminator=True. discriminator and instance_name()
expose its value, discriminator_field() its name for callers holding a config without an
instance; sources use it for per-instance table names. Two marked fields raise TypeError.
Writing a kind¶
A kind is an anchor class plus one entry-point line. Everything above applies to it; this is the order in which it comes up.
-
Subclass the anchor you extend, or
il.Componentfor a new kind.kindderives from the class name for a direct child ofComponent; a subclass of an existing kind inherits its. Give the class a docstring: it becomes the description users see. -
Declare fields for what the user configures. Ordinary pydantic fields, with
il.InputField,il.SelectField,il.SecretFieldand friends where the UI needs to know how to render them. Fields the framework fills belong ininternal_fields. One field may carrydiscriminator=True; it names instances. -
Declare relations for what fills the component. An annotation naming a component class is the shorthand; an
il.Relationvalue is where anything else is said (a key filter,many,optional,on_delete). The relation name is the constructor keyword, the attribute, and what persistence and the UI call the link. -
Register the anchor. One line in
pyproject.toml; the catalog raisesConfigErrorfor a component whose kind it does not know. -
Check
definition(). It is what the catalog, the API and the app read:config_schemafrom the fields,relationsfrom the declarations,state_schemafromstate_modelif the kind has machine-owned state. If the definition says what you meant, the kind is done.
Behaviour comes last and is the kind's own: a Workload exposes operations(), an Operation
exposes run(). See Operations.
Writing a decorator¶
A kind that wants @il.report(...) sugar makes one Decorator for it, and documents it with an
attribute docstring (the reference renders it):
from interloper.component import Decorator
report = Decorator(Report)
"""Create a Report subclass from a decorated class."""
The decorator works bare (@report) or with settings (@report(name="Weekly"), which returns a
decorator carrying them). What it accepts is what the kind declares: a public ClassVar becomes
a class attribute, a pydantic field a default, relations= declares relations (an
il.Relation, a component class, or a list of classes narrowing a relation the kind declares),
and anything else is a TypeError naming what is accepted. Nothing is hand-listed, so a new
ClassVar on Report is a new decorator option with no other change. A kind that decorates
functions, as Asset and Source do, overrides body_from_function to say what a function
becomes.
Definitions in the catalog¶
Every class reachable through the interloper.components entry point becomes a catalog entry
through its definition(). Nothing is inferred or registered at import time: installation is
registration, and the catalog contains exactly what was declared. See
Catalog and Entry points.