Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
56c13de
feat!: rename the asset-to-asset relation to upstream and its wiring …
aaaaahaaaaa Sep 5, 2026
f7733ba
feat(core)!: rename RelationSlot to Dependency with optional and many…
aaaaahaaaaa Sep 5, 2026
63cda39
feat(core): match upstream keys through AssetIdentity.satisfies with …
aaaaahaaaaa Sep 5, 2026
889bff3
feat(core)!: declare upstreams with depends_on and wire them as lists
aaaaahaaaaa Sep 5, 2026
964e2dd
style(core): drop the em-dash from the AssetIdentity docstring
aaaaahaaaaa Sep 5, 2026
39f7794
test(core): pin sibling wiring and optional inference under depends_on
aaaaahaaaaa Sep 5, 2026
15c8a4b
feat(core): resolve declared upstreams in the DAG and check the whole…
aaaaahaaaaa Sep 5, 2026
f42c4af
feat(core)!: read every upstream slot through one path, missing legs …
aaaaahaaaaa Sep 5, 2026
af66c19
feat(core): require equal partition granularity across upstream edges
aaaaahaaaaa Sep 5, 2026
487b5f2
fix(core): read upstream data from the configured default destination
aaaaahaaaaa Sep 5, 2026
7196360
fix(core): skip an optional upstream that is absent from the DAG inst…
aaaaahaaaaa Sep 5, 2026
46d3648
docs: document depends_on, il.Dependency, upstreams and DAG resolutio…
aaaaahaaaaa Sep 5, 2026
6129ca9
docs: state that missing upstream data yields None for every slot and…
aaaaahaaaaa Sep 6, 2026
e39b119
fix(core): reject several upstreams on a single-valued slot
aaaaahaaaaa Sep 6, 2026
72d19a2
docs: state when an upstream leg is None and refresh stale dependency…
aaaaahaaaaa Sep 6, 2026
a52526e
docs: drop an em-dash and complete the DependencyContractError docstring
aaaaahaaaaa Sep 6, 2026
62dc987
feat(core): add the Relation primitive, ComponentIdentity and the Bou…
aaaaahaaaaa Sep 7, 2026
c04f60e
feat(core)!: Component declares and binds relations through one Relat…
aaaaahaaaaa Sep 7, 2026
27dc110
feat(core): trickle, defaults, relation validation and definition on …
aaaaahaaaaa Sep 7, 2026
dafd100
feat(core)!: kinds declare anchor relations; sources bind siblings an…
aaaaahaaaaa Sep 7, 2026
1f8622b
feat(core)!: assets infer relations from data() and inject bound inst…
aaaaahaaaaa Sep 7, 2026
2e8480c
feat(core): DAG edges from bound relations; bound upstreams outside t…
aaaaahaaaaa Sep 7, 2026
f81ad1d
feat(core)!: manifests are to_spec() output; parented targets seriali…
aaaaahaaaaa Sep 7, 2026
fdf37ab
refactor(assets): declare connections by annotation and upstreams as …
aaaaahaaaaa Sep 7, 2026
7b7060f
docs: describe the Relation model, annotation and explicit forms, and…
aaaaahaaaaa Sep 7, 2026
92854e5
chore(core): remove the unraised DependencyContractError and sweep re…
aaaaahaaaaa Sep 7, 2026
d6a340d
style(db): sort the relation store imports after the identity rename
aaaaahaaaaa Sep 7, 2026
aca97b5
fix(core): one write path for bindings; assignment trickles and singl…
aaaaahaaaaa Sep 7, 2026
046c27d
fix(core): id-based trickle detection on copies; forbid unknown Relat…
aaaaahaaaaa Sep 7, 2026
9f1c2b8
fix(core): fetch-provider validation handles kind-declared relations;…
aaaaahaaaaa Sep 7, 2026
6f72fe5
feat(core)!: one decorator engine with three channels; destinations= …
aaaaahaaaaa Sep 8, 2026
9b60bc4
refactor(core): drop _data_fn and _defer_validation from the componen…
aaaaahaaaaa Sep 8, 2026
52fe649
refactor(core): relation completeness is checked where the graph is w…
aaaaahaaaaa Sep 9, 2026
c3eb648
refactor(core): reconstruction keeps its own state in a Document; Com…
aaaaahaaaaa Sep 9, 2026
1e9e9f9
feat(core): on_rebind is the public hook for a binding change; unbind…
aaaaahaaaaa Sep 9, 2026
d9d8639
refactor(core): drop the unused public methods from the component sur…
aaaaahaaaaa Sep 9, 2026
64c4f85
refactor(core): the relation collector is a class-creation internal, …
aaaaahaaaaa Sep 9, 2026
4ec77da
refactor(core): Source reads top-down; the hook body is the hook
aaaaahaaaaa Sep 9, 2026
8281d2c
refactor(core)!: Relation.name and Relation.target are derived, not d…
aaaaahaaaaa Sep 9, 2026
0707e82
docs: writing a kind, step by step; the decorator section follows the…
aaaaahaaaaa Sep 9, 2026
131ae0d
refactor(core)!: one SerializationContext for writing and reading a m…
aaaaahaaaaa Sep 9, 2026
93015b4
refactor(core): Component reads top-down
aaaaahaaaaa Sep 9, 2026
d7cf2de
refactor(core)!: Relation reads as a declaration: kinds and keys are …
aaaaahaaaaa Sep 9, 2026
84df7d9
docs: manifest examples stop redeclaring what cascades; the specs exa…
aaaaahaaaaa Sep 9, 2026
e947121
refactor(assets)!: source-owned assets read self.connection instead o…
aaaaahaaaaa Sep 9, 2026
93d71f3
docs: assets read self.connection; upstreams are il.Upstream everywhe…
aaaaahaaaaa Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ class ShopConnection(il.Connection):

### Sources and assets

A source groups assets. Configuration fields live on the class; assets are methods and read them
through `self`. Resources are injected by type annotation, a parameter named after a sibling
asset is a dependency, and a schema types the data on write and on read-back.
A source groups assets. Configuration fields and the connection live on the class; assets are
methods and read both through `self`. A parameter annotated `il.Upstream` and named after a
sibling asset is a dependency, and a schema types the data on write and on read-back.

```python
import datetime as dt
Expand All @@ -72,23 +72,26 @@ class OrderStats(il.Schema):
revenue: float | None


@il.source(tags=["Commerce"], resources={"connection": ShopConnection})
@il.source(tags=["Commerce"])
class Shop(il.Source):
connection: ShopConnection

account: str = il.InputField(description="Shop account id", discriminator=True)

@il.asset(schema=Order)
def orders(self, connection: ShopConnection) -> list[dict]:
def orders(self) -> list[dict]:
rows: list[dict] = []
paginator = il.PageNumberPaginator(total_path="meta.pages")
for page in connection.client.paginate("/orders", paginator, data_selector="data"):
for page in self.connection.client.paginate("/orders", paginator, data_selector="data"):
rows.extend(page)
return rows

@il.asset(schema=OrderStats, partitioning=il.TimePartitionConfig(column="date"), tags=["Report"])
def order_stats(self, context: il.ExecutionContext, orders: list[dict]) -> list[dict]:
def order_stats(self, context: il.ExecutionContext, orders: il.Upstream) -> list[dict]:
day = context.partition_date # also: context.partition, .window, .logger, .metadata
context.logger.info(f"{len(orders)} orders for {self.account}")
return [{"date": day, "orders": len(orders), "revenue": sum(o["total"] for o in orders)}]
rows = orders.data or [] # what `orders` wrote, read back from its destination
context.logger.info(f"{len(rows)} orders for {self.account}")
return [{"date": day, "orders": len(rows), "revenue": sum(o["total"] for o in rows)}]
```

### Destinations
Expand Down
205 changes: 163 additions & 42 deletions docs/extending/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ Everything a developer defines in Interloper is a **component**: assets, sources
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.
or new kinds. The reference comes first; [Writing a kind](#writing-a-kind) at the end walks
through it in the order you would do it.

## Serializable

Expand All @@ -13,7 +14,10 @@ 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](../guide/specs.md).
See [Specs and serialization](../guide/specs.md). Several roots written or read as one document
share a `SerializationContext`, 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's `internal_fields` stripped.
- **`build_class(decorated, classvars=..., fields=...)`**: the factory behind every decorator.
Expand All @@ -32,9 +36,9 @@ On top of `Serializable`:
| `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. |
| `resource_types` | class | Slot name to resource class. Filled from typed annotations and `ResourceRef` descriptors. |
| `resources` | instance | Slot name to resource instance. |
| `relation_types` | class | The relation vocabulary (below). |
| `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. |
Expand All @@ -55,10 +59,7 @@ declares `source`, `asset`, `destination`, `resource`, `connection`, `config`, `
class Report(il.Component):
"""A rendered document built from assets."""

relation_types = {
"input": il.RelationDefinition(kinds=["asset"], field="inputs"),
}
inputs: list[il.Asset] = []
inputs: list[il.Asset] = il.Relation("asset", many=True, optional=True)
```

```toml
Expand All @@ -70,50 +71,170 @@ A catalog containing a component of an unregistered kind raises `ConfigError`.

### Relations

A relation type describes how instances of a kind point at other components. It is declared in
`relation_types` and is what the platform validates when it stores an edge, and what UIs render
pickers from:

| `RelationDefinition` field | Meaning |
|---------------------------|---------|
| `kinds` | Component kinds the relation may point at. |
| `field` | The instance field carrying the relation: a `list` for unslotted types, a `dict[slot, ...]` for slotted ones. Must exist on the class. |
| `slotted` | Whether each relation fills a named slot (resource slots, dependency parameters). |
| `inline` | Whether the field holds component instances (default) or bare ids resolved at run time (asset dependencies). |
| `keys` | Allowed destination keys, as picker metadata. |
| `slots` | The slots a concrete class declares (`RelationSlot(key, required)`). |
| `on_delete` | What deleting the relation's target does to the referrer: `block` (default, for consumption relations) or `detach` (for orchestration pointers such as a job's targets or a hook's watches). |
| `on_unbind` | What explicitly unbinding a bound required slot does: `detach` (default) or `block` (asset dependencies). |

Declarations are **extend-only**: a subclass's `relation_types` merges over its parent's, so
`TriggerHook` adds `target` without losing `watch` and `resource`. `relation_definitions()`
returns the vocabulary enriched with the class's own slots: resource slots from
`resource_types`, dependency slots from `requires`, allowed destination keys from
`destination_types`.

A relation whose `field` does not exist on the class raises `ValueError` when the definition is
built.
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. Says nothing about data. |
| `default` | A zero-argument factory producing the value an unbound relation resolves to. |
| `on_delete` | What deleting the target does to the referrer: `block` (default, for consumption relations) or `detach` (for orchestration pointers such as a job's targets or a hook's watches). |

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:

```py
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 is explicitly bound: a list for `many`, the single component or `None`. |
| `resolve(name)` | What a reader gets: `bound(name)`, else the relation's fallback for a single-valued relation, an empty list for a `many` one. Fallbacks are never bound. |
| `trickle(child)` | Fills a child's unbound relations from this component's own bindings, by name, keeping only what the child's relation accepts. Never overrides an explicit binding. |
| `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. |

**Extension point**: `on_rebind(name)` is called once after every write to a binding, whichever
way it was written (constructor kwarg, `bind`, `unbind`, attribute assignment, a parent's
`trickle`), with the new binding already in place. The base does nothing. A kind that cascades its
wiring overrides it; this is the whole of how a source's connection reaches its assets:

```py
class Source(Component):
def on_rebind(self, name: str) -> None:
for asset in self.assets:
self.trickle(asset)
for destination in self.destinations:
self.trickle(destination)
```

Bind on other components from inside the hook, never on `self`: that would re-enter it.

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, clearing a non-optional relation raises `ConfigError`, and
whatever the owner cascades into its children is cascaded again.

`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_field()`,
`discriminator` and `instance_name()` expose it; sources use it for per-instance table names.
Two marked fields raise `TypeError`.
One configuration field may carry `discriminator=True`. `discriminator` and `instance_name()`
expose it; 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.

1. **Subclass the anchor you extend, or `il.Component` for a new kind.** `kind` derives from the
class name for a direct child of `Component`; a subclass of an existing kind inherits its.
Give the class a docstring: it becomes the description users see.

```py
class Report(il.Component):
"""A rendered document built from assets."""
```

2. **Declare fields for what the user configures.** Ordinary pydantic fields, with `il.InputField`,
`il.SelectField`, `il.SecretField` and friends where the UI needs to know how to render them.
Fields the framework fills belong in `internal_fields`. One field may carry
`discriminator=True`; it names instances.

3. **Declare relations for what fills the component.** An annotation naming a component class is
the shorthand; an `il.Relation` value 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.

```py
class Report(il.Component):
"""A rendered document built from assets."""

title: str = il.InputField()
inputs: list[il.Asset] = il.Relation("asset", many=True, optional=True)
storage: il.Destination
```

4. **Override `on_rebind` only if the kind cascades its wiring.** A source pushes its connection
into its assets, a job its destinations into its targets. A kind whose bindings are its own
business leaves the base hook alone.

5. **Register the anchor.** One line in `pyproject.toml`; the catalog raises `ConfigError` for a
component whose kind it does not know.

```toml
[project.entry-points."interloper.kinds"]
report = "my_package.report:Report"
```

6. **Check `definition()`.** It is what the catalog, the API and the app read: `config_schema`
from the fields, `relations` from the declarations, `state_schema` from `state_model` if 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](operations.md).

## Writing a decorator

A decorator for a new kind wraps `build_class`:
A kind that wants `@il.report(...)` sugar wraps the one decorator engine,
`interloper.component.decorator.decorate`. The engine routes three channels and nothing else:
plain keyword arguments are matched against the anchor (a public `ClassVar` becomes a class
attribute, a pydantic field a default, anything else a `TypeError` naming what is accepted),
`relations=` declares relations (an `il.Relation`, a component class, or a list of classes
narrowing a relation the anchor declares), and `build=` is the kind's own step: how the
decorated thing becomes a class.

```py
def report(cls=None, /, *, key=None, name=None, tags=None):
classvars = {k: v for k, v in {"key": key, "name": name, "tags": tags}.items() if v is not None}
from interloper.component.decorator import declare, decorate


def report(cls=None, /, *, relations=None, **overrides):
if cls is not None:
return Report.build_class(cls, classvars=classvars)
return lambda cls: Report.build_class(cls, classvars=classvars)
return decorate(Report, cls, build=_build_report, relations=relations, **overrides)
return lambda cls: decorate(Report, cls, build=_build_report, relations=relations, **overrides)


def _build_report(cls, *, classvars, fields, relations):
return declare(Report.build_class(cls, classvars=classvars, fields=fields), relations)
```

`classvars` are stamped as class attributes; `fields` override defaults of existing pydantic
fields and must name fields the receiving class has.
`build_class` builds a subclass with the class attributes stamped and the field defaults
overridden through the pydantic metaclass, so `model_fields` stays correct; `declare` adds the
decorator's relations to it. Nothing is hand-listed: what the decorator accepts is what the
anchor declares, so a new `ClassVar` on `Report` is a new decorator option with no other change.

## Definitions in the catalog

Expand Down
11 changes: 6 additions & 5 deletions docs/extending/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@ plain defaults that make any subclass a valid node; `Asset` overrides them with
|--------|---------|-------|
| `id`, `kind`, `key`, `qualified_key` | from `Component` | qualified with the source key |
| `materializable` | `True` | field |
| `dependencies` | `{}` | parameter name to upstream id |
| `optional_requires` | `{}` | class contract |
| `relations` | `{}` | every relation the class declares, by name |
| `upstream_relations()` | `{}` | the relations whose kind is `asset`: the graph's edges |
| `bound(name)` | from `Component` | what is bound to one relation |
| `source` | `None` | the owning source |
| `partitioning` | `None` | the partition config |
| `effective_partition(scope)` | scope if partitioned else `None` | same |
| `validate_dependencies(nodes)` | no-op | checks `requires` contracts |
| `_event_metadata(metadata, scope)` | component identity | adds `qualified_key`, `source_id` |

## Writing an operation
Expand Down Expand Up @@ -71,8 +71,9 @@ class Vacuum(il.Component, il.Operation):
return il.OperationResult(error=f"Vacuum of {self.table} failed: {type(error).__name__}")
```

`il.DAG(vacuum)` runs it like any node; wiring it after an asset is a `dependencies` entry.
`invoke` calls a sync or async callable uniformly.
`il.DAG(vacuum)` runs it like any node; ordering it after an asset is an `asset`-kind relation
bound to that asset, which is what `upstream_relations()` reports. `invoke` calls a sync or
async callable uniformly.

## Where effects go

Expand Down
Loading
Loading