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:
@il.asset
async def events(self, connection: MyConnection) -> list[dict]:
return await connection.client.get("/events")
What an asset can return¶
Anything. Tabular data is what destinations and schemas are built for: a list[dict], a
single dict, pydantic models or a list of them, a generator of rows, or a pandas DataFrame
when interloper-pandas is installed. Non-tabular objects pass through untouched when the asset
declares no schema, which is what a FileDestination storing pickles expects.
Parameters¶
The engine inspects the function signature and fills each parameter from one of three places.
context: a parameter named context receives an ExecutionContext.
Resources: a parameter annotated with a Resource subclass (a config, a connection, or
your own) receives a resolved instance. Declaring the slot explicitly with
resources={"connection": MyConnection} on the decorator does the same and wins over the
annotation. See Resources for the resolution cascade.
Dependencies: any other parameter is an upstream asset. Inside a source, a parameter named
after a sibling asset is wired automatically; requires and optional_requires declare the
rest. See Dependencies.
@il.asset(requires={"raw": "warehouse.raw_orders"})
def orders(
self,
context: il.ExecutionContext, # the run's context
connection: ShopConnection, # a resource, by annotation
users: list[dict], # a sibling asset, by name
raw: list[dict], # a cross-source asset, by requires
) -> list[dict]:
...
self, source and **kwargs are ignored by the inspection.
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"),
destinations=[il.CSVDestination], # allowed destination classes
resources={"connection": AdsConnection}, # explicit resource slots
requires={"campaigns": "ads.campaigns"}, # mandatory upstream assets
optional_requires={"budget": "finance.budget"}, # optional upstream assets
materialization_strategy=il.MaterializationStrategy.RECONCILE,
normalizer=il.Normalizer(flatten_max_level=1),
)
def ads_stats(self, context: il.ExecutionContext, connection: AdsConnection, campaigns, budget=None):
...
Every option is listed in Decorator options. 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 should prefer. Carried for the platform; the core reads upstream data from the first resolved destination. |
materializable |
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. |
dependencies |
Parameter name to upstream asset id. Filled by the source; can be set by hand. |
id |
Instance identity, a UUID by default. |
resources |
Slot name to resource instance. |
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(materializable=False)
strict = asset(materialization_strategy=il.MaterializationStrategy.STRICT)
bare = asset(normalizer=None) # None explicitly clears the normalizer
Every keyword of the call means "leave unchanged" when omitted. resources merges over the
existing map; destinations replaces the list.
Unknown keyword arguments raise TypeError rather than being silently dropped.
Running and materializing¶
| Call | Effect |
|---|---|
asset.run(partition, dag, metadata) |
Execute, normalize, conform. Return the data. Write nothing. |
asset.materialize(partition, dag, metadata) |
Everything run does, then write to every destination. Returns the data, or None when the asset is not materializable. |
await asset.run_async(...), await asset.materialize_async(...) |
The same, for async callers. |
partition is required for partitioned assets and ignored (with a warning) for unpartitioned
ones. dag is required when the asset has mandatory dependencies, because upstream data is read
through the DAG. 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 partition scope is validated against the asset's partitioning.
- Resources are resolved and upstream assets are read from their destinations.
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_key |
The asset's key. |
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.partition |
The Partition this run covers: .value, .id, .granularity, .bounds. |
context.window |
The PartitionWindow, for assets declaring allow_window=True. A single partition is presented as a one-partition window. |
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 partition was given, the run holds a window, 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 (source_key, asset_key) pair. |
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.