Dependencies¶
An asset can consume the output of other assets. Inside a DAG, each upstream is materialized
first, read back from its destination, and handed to the downstream asset as an il.Upstream:
the upstream asset plus the data read for the run's scope.
An upstream is one relation among the others a component declares (a connection, a config, a
destination). One primitive, il.Relation, declares them all; only the ones whose kind is
asset become edges in the graph. See Resources for the rest.
Inside a source¶
Annotate a parameter il.Upstream and name it after a sibling asset. The source binds it when
it builds its assets:
import interloper as il
@il.source
class Shop(il.Source):
@il.asset(partitioning=il.TimePartitionConfig(column="date"))
def orders(self, context: il.ExecutionContext) -> list[dict]:
return [{"date": context.partition_date, "id": 1, "total": 9.99}]
@il.asset(partitioning=il.TimePartitionConfig(column="date"))
def order_stats(self, context: il.ExecutionContext, orders: il.Upstream) -> list[dict]:
rows = orders.data or []
return [{"date": context.partition_date, "count": len(rows)}]
The parameter name is the relation name and the bare asset key it expects, so
orders: il.Upstream declares il.Relation("asset", "orders"), a sibling of the same source
instance. A None default makes the relation optional, meaning its wiring may be absent, and
declares on_delete="detach" with it: a parameter that tolerates a missing leg has no claim on
the upstream, so deleting that upstream is allowed and simply drops the wiring, where a required
upstream refuses the deletion while it is bound.
@il.asset(partitioning=il.TimePartitionConfig(column="date"))
def report(
self,
context: il.ExecutionContext,
orders: il.Upstream,
refunds: il.Upstream | None = None,
) -> list[dict]:
...
self, context, source and **kwargs are reserved. Any other parameter that is neither an
il.Upstream nor a component class is a TypeError at class creation: nothing could ever fill
it.
Declaring relations explicitly¶
When the parameter name is not the upstream's key, or the upstream lives in another source,
write the relation on the decorator. relations= is keyed by parameter name and wins over the
annotation:
@il.source
class Finance(il.Source):
currency: str = il.InputField(default="EUR")
@il.asset(
partitioning=il.TimePartitionConfig(column="date"),
relations={
"orders": il.Relation("asset", "shop.orders"),
"fx": il.Relation("asset", "rates.daily_fx", optional=True),
},
)
def revenue(
self,
context: il.ExecutionContext,
orders: il.Upstream,
fx: il.Upstream | None,
) -> list[dict]:
...
A class-based asset writes the same relation as a typed class attribute:
class Revenue(il.Asset):
orders: il.Asset = il.Relation("asset", "shop.orders")
partitioning = il.TimePartitionConfig(column="date")
def data(self, context: il.ExecutionContext, orders: il.Upstream) -> list[dict]:
...
The annotation is for the type checker; the relation itself says what it accepts. Keys come in four forms:
| Key | Selects |
|---|---|
orders |
The asset of that key in the declaring asset's own source instance. |
shop.orders |
That asset key, in any instance of the source keyed shop. |
*.orders |
That asset key, in any source at all. |
["shop.orders", "wms.orders"] |
Any of the listed keys. |
Many upstreams¶
many=True binds every matching asset at once, and data() receives a list[il.Upstream]:
@il.source(tags=["Analytics"])
class CampaignMatcher(il.Source):
@il.asset(
partitioning=il.TimePartitionConfig(column="date"),
tags=["Entity"],
relations={"campaigns": il.Relation("asset", "*.dim_campaigns", many=True)},
)
def campaign_matches(
self,
context: il.ExecutionContext,
campaigns: list[il.Upstream],
) -> list[dict]:
return [
{"date": context.partition_date, **row}
for leg in campaigns
if leg.data is not None
for row in leg.data
]
The DAG binds every dim_campaigns asset it holds, so the set of legs is decided by wiring, not by
the class. list[il.Upstream] on its own infers the same relation on the parameter's bare key,
which is a many-valued sibling; relations= is what reaches outside the source.
Fan-in across sources¶
The relation above is not hypothetical: interloper_assets.CampaignMatcher declares exactly this
wildcard (same campaign_matches decorator shown above, plus schema=schemas.CampaignMatches),
matching campaigns across every advertising platform into one lookup table. It reads each
platform's star schema rather than its connector: not every connector collects a campaigns
entity, so a star schema's dim_campaigns is built from whatever carries the campaigns (a
snapshot, a campaign report, the ad facts), and the matcher always finds them under one key.
Each row names the
campaign's platform and account, the normalised canonical_name it is matched on and the
match_id shared by every campaign matched to it. A key_pattern on the source selects the part
of a name that identifies the campaign under a naming convention, and a similarity_threshold
below 1.0 also merges names that merely resemble each other.
A star schema fans in one level below, with a qualified key: FacebookAdsStarSchema declares
facebook_ads.ads_stats with many=True on its fact_ad_performance, one leg per Facebook Ads
account. The star schemas are placeholders for now: they declare their wiring but materialize no
rows.
Building a DAG over the star schemas and the matcher wires every dim_campaigns asset held into
the campaigns relation, one leg per platform:
fb = FacebookAdsStarSchema(...) # declares a dim_campaigns asset
tt = TiktokAdsStarSchema(...) # declares a dim_campaigns asset
matcher = CampaignMatcher(destinations=[dest])
dag = il.DAG(fb, tt, matcher) # binds matcher.campaign_matches.campaigns to [fb.dim_campaigns, tt.dim_campaigns]
A matcher built against upstreams already materialized elsewhere reads them read-only instead of running them, the same explicit-bind pattern as any other relation:
matcher.campaign_matches.bind("campaigns", fb.dim_campaigns)
dag = il.DAG(matcher) # fb.dim_campaigns joins the DAG as enabled=False
examples/campaign_matcher.yaml is the same wiring written as a manifest: two star schemas each
override their dim_campaigns asset to enabled: false, and campaign_matches names both by
{ref: ...} since an asset always travels under its own source. See Specs for the
manifest format.
How wiring works¶
Wiring is always an instance bound to a relation, never an id. It happens in three places:
- At construction, for siblings:
Source.sibling_bindings()says which of an asset's relations resolve inside the source, and the source binds them to the instances it just built, right after building them. - In the DAG, for everything a bare key cannot reach: a qualified or wildcard key is matched
against the DAG's other assets. A
manyrelation binds every candidate, a single-valued one the only candidate there is. Several candidates for a single-valued relation raiseDAGErrorand ask to be bound by hand. - By hand, at any point:
dest = il.MemoryDestination()
shop = Shop(destinations=[dest])
fin = Finance(destinations=[dest])
dag = il.DAG(shop, fin) # binds fin.revenue.orders to shop.orders
fin.revenue.bound("orders") # the shop.orders instance
fin.revenue.bind("orders", shop.orders) # explicit; accumulates for a many relation
fin.revenue.orders = shop.orders # assignment replaces atomically
fin.revenue.unbind("orders", shop.orders) # refused if it would empty a non-optional relation
Binding goes through bind() everywhere, so an explicit binding is never overwritten by the
DAG, and a hydrated one is never overwritten by construction.
In a spec, a bound upstream is a {ref: id} under the relation's name, since an asset always
travels inside its own source's document. See Specs.
Reading upstream data¶
Each leg is read from the upstream's default_destination_key destination, or its first, scoped
to the window the upstream consumes, joined into one table when it covers several partitions.
il.Upstream carries the asset and its data, plus one accessor:
| Attribute | Meaning |
|---|---|
asset |
The upstream asset the data came from; its source, key and id tell the legs apart. |
data |
Whatever that destination's read() yields: rows for the built-in destinations, a DataFrame for BigQuery; None when nothing is materialized for the partition. |
records |
The data as list[dict] whatever the destination handed back, [] when nothing is materialized. The one line a consumer needs to be indifferent to where its upstream is stored. |
A view's upstreams are not read at all: data holds the destination's reference to the upstream instead. See Views & references.
data is None, with a LOG warning naming the leg, when the upstream has nothing
materialized where the destination looks: no table or object for that scope at all
(MemoryDestination and the file destinations key storage per partition, so a missing partition
is exactly this; BigQueryDestination reaches it only when the table itself does not exist). An
existing but empty scope is not this case, and comes back as whatever the destination returns
for an empty read, an empty list or frame. optional governs wiring only, never data: the asset
decides what a missing leg means. Any other read failure fails the asset.
A bound upstream the run does not hold at all is skipped with its own warning, so its leg simply does not exist.
Reads emit dest_read_* events and an interloper.destination.read span.
Rules the DAG enforces¶
| Rule | Error |
|---|---|
| Every operation id is unique | DAGError |
| A single-valued relation matches at most one asset in the DAG | DAGError |
Every data() parameter is fillable (context, source, a component class or il.Upstream) |
TypeError, at class creation |
| A non-optional relation is bound, unless it can fill itself | ConfigError |
| A bound target is one its relation accepts | ConfigError |
| A non-optional relation points at an asset the DAG holds | ConfigError |
| No cycles | CircularDependencyError |
| A non-partitioned asset never depends on a partitioned one | DAGError |
| Time-partitioned ends of an edge share a granularity | DAGError |
validate_relations(nodes) is the check behind the three ConfigError rows. Constructing a
component binds what it is given and checks each binding, but not that every required relation is
bound: components are wired piecewise (a source's assets read its bindings through it, a manifest
binds its references once every component exists), so the check runs where the graph is whole. The
DAG runs it with its own nodes; loading a manifest runs it without nodes on each root, which is
what lets a relation only the graph can fill stay unbound until then.
A non-partitioned asset never depends on a partitioned one because a partitioned upstream is read one partition at a time, which an unpartitioned downstream cannot express. A run has one partition scope, so both ends of an edge must agree on granularity, read-only upstreams included.
Running one asset with its parents¶
A bound upstream the run does not materialize joins the DAG anyway, as a disabled copy under the same id: it is a live instance with its own destinations, so it can be read without being run. That is the whole mechanism behind running one asset with its parents:
mini = dag.mini_dag(fin.revenue.id)
[(op.qualified_key, op.enabled) for op in mini.operations]
# [("finance.revenue", True), ("shop.orders", False)]
Only the target executes; the parents are read, not rewritten. Partition checks still apply
along those edges. A source offers the same idea over its own assets through select.