Assets¶
An asset is the unit of data in Interloper: something that produces data and can be materialized into destinations. This page covers how to define one, what it can receive, how to run it, and what happens between the function returning and the data landing.
Defining an asset¶
The usual form is a method on a source, decorated with @il.asset:
import interloper as il
@il.source
class Shop(il.Source):
@il.asset
def users(self) -> list[dict]:
return fetch_users()
A standalone asset is a decorated function:
And an asset can be a class, when it needs more than one method:
All three produce an Asset definition (a class). Calling it creates an instance:
The key defaults to the snake_cased function or class name and can be overridden with
key=. The docstring becomes the asset's description in the catalog.
Sync or async¶
The function may be sync or async def. Sync functions are offloaded to a worker thread; async
functions are awaited on the event loop. Either way the engine never blocks:
What an asset can return¶
Anything. Tabular data is what destinations and schemas are built for: a list[dict], or a
pandas DataFrame when interloper-pandas is installed. As a convenience the asset unwraps a few
Python shapes into rows before anything else sees them: a single dict becomes one row,
pydantic models or a list of them are dumped, a generator of rows is consumed, and None is no
rows. Non-tabular objects pass through untouched when the asset declares no schema, which is
what a FileDestination storing pickles expects; with a schema they are an error.
Parameters¶
Every parameter of data() declares a relation, read off its annotation. self, context,
source and **kwargs are reserved and declare nothing.
context: a parameter named context receives an ExecutionContext.
Resources: a parameter annotated with a component class (a config, a connection, or your own) receives whatever is bound to it, or the fallback that class can fill itself with. See Resources.
Upstreams: a parameter annotated il.Upstream receives an upstream asset and the data read
from it, list[il.Upstream] a many-valued one. The parameter name is the bare asset key it
expects, so inside a source it names a sibling; relations= declares anything else. See
Dependencies.
@il.asset(relations={"raw": il.Relation("asset", "warehouse.raw_orders")})
def orders(
self,
context: il.ExecutionContext, # the run's context
connection: ShopConnection, # a resource, by annotation
users: il.Upstream, # a sibling asset, by name
raw: il.Upstream, # a cross-source asset, by relations=
) -> list[dict]:
...
A parameter annotated with anything else is a TypeError at class creation: nothing could ever
fill it.
Decorator options¶
@il.asset(
key="ads_stats", # override the derived key
name="Ads statistics", # display name
icon="carbon:chart-line", # icon identifier for UIs
tags=["Report"], # catalog tags
schema=AdsStats, # output schema
partitioning=il.TimePartitionConfig(column="date"),
relations={ # the relation channel, keyed by parameter
"destinations": [il.CSVDestination], # allowed destination classes
"connection": il.Relation(AdsConnection),
"campaigns": il.Relation("asset", "ads.campaigns"),
"budget": il.Relation("asset", "finance.budget", optional=True),
},
materialization_strategy=il.MaterializationStrategy.RECONCILE,
normalizer=il.Normalizer(flatten_max_level=1),
)
def ads_stats(
self,
context: il.ExecutionContext,
connection: AdsConnection,
campaigns: il.Upstream,
budget: il.Upstream | None = None,
) -> list[dict]:
...
The three channels are described in Decorator options. A list of
classes under relations["destinations"] restricts the destination classes an asset accepts;
instances are supplied at construction.
Instance configuration¶
An instance carries the runtime state a definition does not know about:
| Field | Meaning |
|---|---|
destinations |
Destination instances to write to. A single destination is accepted and wrapped in a list. |
dataset |
Namespace (schema, folder) the asset materializes into. Defaults to the source's. |
default_destination_key |
With several destinations, the one downstream readers load from. |
enabled |
False turns the asset into a read-only dependency: it is skipped by runners but its stored output is still readable. |
materialization_strategy |
How strictly the data is checked against the schema. |
normalizer |
The normalizer applied before conform. |
id |
Instance identity, a UUID by default. |
Its relations carry the rest: connection, config, destinations, every upstream. They are
constructor keywords named after the relation, and bind, unbind and assignment change them
afterwards. See Dependencies.
Set them at construction, or derive a reconfigured copy by calling an existing instance:
asset = users(destinations=il.CSVDestination(base_path="./data"), dataset="shop")
read_only = asset(enabled=False)
strict = asset(materialization_strategy=il.MaterializationStrategy.STRICT)
bare = asset(normalizer=None) # unset: the source's normalizer, or none
Naming a field or a relation changes it, leaving it out keeps it; None clears a relation and
makes a setting the asset inherits from its source inherit again. An asset owned by a source
reads its dataset, default_destination_key, normalizer, materialization_strategy,
retry and unbound relations through the source on every read (see
Sources), and runs only while the source is
enabled too.
asset.default_destination is the destination downstream assets read it from: the one whose key
is default_destination_key, else the first.
Unknown keyword arguments raise TypeError rather than being silently dropped.
Running and materializing¶
| Call | Effect |
|---|---|
asset.run(window, dag, metadata) |
Execute, normalize, conform. Return the data. Write nothing. |
asset.materialize(window, dag, metadata) |
Everything run does, then write to every destination. Returns the data, or None when the asset is disabled. |
await asset.run_async(...), await asset.materialize_async(...) |
The same, for async callers. |
window is required for partitioned assets and ignored for unpartitioned ones; a bare partition
is accepted as the window of one. dag is required when the asset has a bound upstream, because
upstream data is read through the DAG's own node. metadata is a free-form dict (run id, backfill id) carried onto every event
the run emits.
An asset that produces no data skips its destination writes and emits a warning log event rather than writing an empty table.
What happens on materialize¶
- The run's window is checked by the asset's partitioning.
- Every relation is resolved: a resource to what is bound or its fallback, an upstream to an
il.Upstreamread from its destination. data()runs, wrapped inasset_data_*events and a tracing span.- The normalizer reshapes the result, when one is configured.
- The result is conformed to the schema according to the materialization strategy. Without a schema, one is inferred so destinations know the column types.
- Each destination's
write()is called with anIOContext, wrapped indest_write_*events.
Steps 4 and 5 run off the event loop. Every step emits events and spans.
Execution context¶
An asset that declares a context parameter receives an ExecutionContext:
| Accessor | Gives you |
|---|---|
context.asset |
The asset executing: key, table, dataset, source, partitioning. |
context.metadata |
The run's metadata dict (run_id, backfill_id, anything the caller passed). |
context.logger |
A logger whose debug, info, warning, error emit LOG events attributed to the asset. |
context.window |
The PartitionWindow the run covers; a window of one for assets without allow_window. |
context.partition |
The window's only partition: .key, and .bounds or .value depending on the kind. |
context.partition_date |
The partition value as a date, for daily assets only. |
Each partition accessor raises an AttributeError explaining why it is unavailable: the asset
is not partitioned, no window was given, the window covers several partitions, or the granularity is not
daily. See Partitioning.
Identity¶
| Property | Value |
|---|---|
asset.key |
The class-level key, unique within its source. |
asset.qualified_key |
source_key.asset_key, unique across sources; the bare key for standalone assets. |
asset.identity |
The ComponentIdentity pair (source_key, asset_key). |
asset.table |
The physical table or folder name, derived by the owning source and coerced to a valid identifier. |
asset.source |
The owning Source instance, or None. |
Asset.classpath() |
The import path. Source-owned assets use the composite form module:Source.Asset. |
Asset.definition() |
An AssetDefinition with config schema, output schema, partitioning and relations. |
Row counts per partition¶
asset.partition_row_counts() asks the first destination for row counts grouped by the
partition column. It raises when the asset is not partitioned or has no destination.