Tutorial¶
This tutorial builds a small but complete source against the public Open Meteo API. Each step adds one concept: an asset, a source with configuration, a connection carrying an HTTP client, a dependency, a schema, partitioning, and finally running from the CLI. The finished code is at the end.
1. An asset¶
An asset is a function returning data. Here it fetches yesterday's hourly forecast for Berlin:
import datetime as dt
import httpx
import interloper as il
URL = "https://historical-forecast-api.open-meteo.com/v1/forecast"
@il.asset
def forecast() -> list[dict]:
date = dt.date.today() - dt.timedelta(days=1)
params = {
"latitude": 52.52,
"longitude": 13.41,
"start_date": date.isoformat(),
"end_date": date.isoformat(),
"hourly": ["temperature_2m", "precipitation", "wind_speed_10m"],
}
payload = httpx.get(URL, params=params).json()["hourly"]
return [dict(zip(payload, values)) for values in zip(*payload.values())]
Run it:
forecast is a definition (a class); forecast() is an instance. The distinction matters as
soon as you configure instances differently, which the next steps do.
Sync functions run on a worker thread, so they never block the engine. An async def asset is
awaited natively:
@il.asset
async def forecast() -> list[dict]:
async with httpx.AsyncClient() as client:
response = await client.get(URL, params=params)
...
2. A source with configuration¶
Coordinates are configuration, not code. Move the asset into a source and declare the coordinates as fields:
@il.source(tags=["Weather"])
class OpenMeteo(il.Source):
"""Hourly weather from Open Meteo."""
latitude: float = il.InputField(default=52.52, description="Latitude of the location")
longitude: float = il.InputField(default=13.41, description="Longitude of the location")
@il.asset
def forecast(self) -> list[dict]:
date = dt.date.today() - dt.timedelta(days=1)
params = {
"latitude": self.latitude,
"longitude": self.longitude,
"start_date": date.isoformat(),
"end_date": date.isoformat(),
"hourly": ["temperature_2m", "precipitation", "wind_speed_10m"],
}
payload = httpx.get(URL, params=params).json()["hourly"]
return [dict(zip(payload, values)) for values in zip(*payload.values())]
The asset is now a method: self is the source instance, so it reads the configured
coordinates. The InputField helper is a pydantic Field that also tells a UI how to render the
field; the default alone would work too.
3. Materialize¶
Give the source a destination and materialize. Every asset without its own destination inherits the source's:
source = OpenMeteo(destinations=il.CSVDestination(base_path="./data"))
source.forecast.materialize()
# ./data/open_meteo/forecast/data.csv
The path is {base_path}/{dataset}/{table}/data.csv. The dataset defaults to the source key
(open_meteo), the table to the asset key.
4. A connection with an HTTP client¶
Open Meteo needs no credentials, but most APIs do, and a client is worth sharing between assets. That is what a connection is for: a resource holding credentials and exposing a client. Here is one for a hypothetical authenticated weather API:
from functools import cached_property
@il.connection(name="Weather API")
class WeatherConnection(il.Connection):
api_key: str = il.SecretField(description="API key")
@cached_property
def client(self) -> il.RESTClient:
return il.RESTClient("https://api.example.com", auth=il.HTTPBearerAuth(self.api_key))
Inject it into an asset by annotating a parameter with the connection type:
@il.source
class Weather(il.Source):
@il.asset
def stations(self, connection: WeatherConnection) -> list[dict]:
return connection.client.get("/stations").json()
Resolution is a cascade: an instance passed on the asset, then one passed on the source, then
one built from the environment (API_KEY=...). For paginated endpoints the client has a
paginate() method with pluggable paginators; see Connections.
5. A dependency¶
Add an asset that consumes another. Naming a parameter after a sibling asset declares the dependency:
@il.source
class OpenMeteo(il.Source):
...
@il.asset
def forecast(self) -> list[dict]:
...
@il.asset
def daily_summary(self, forecast: list[dict]) -> list[dict]:
temperatures = [row["temperature_2m"] for row in forecast]
return [{"min": min(temperatures), "max": max(temperatures)}]
When the DAG runs, forecast is materialized first, then read back from its destination and
passed to daily_summary. Run the whole source:
6. A schema¶
Declare what forecast returns. With a schema the data is reconciled to it on every
materialization: columns aligned, values coerced, mismatches reported.
class ForecastRow(il.Schema):
time: dt.datetime
temperature_2m: float | None
precipitation: float | None
wind_speed_10m: float | None
@il.asset(schema=ForecastRow)
def forecast(self) -> list[dict]:
...
Without a schema, one is inferred from the data so that destinations still know the column
types. How strictly the schema is enforced is the asset's materialization_strategy; see
Schemas and data contracts.
7. Partitioning¶
A daily fetch should be driven by the run, not by dt.date.today(). Declare time partitioning
and read the partition from the context:
@il.asset(schema=ForecastRow, partitioning=il.TimePartitionConfig(column="date"))
def forecast(self, context: il.ExecutionContext) -> list[dict]:
date = context.partition_date
params = {
"latitude": self.latitude,
"longitude": self.longitude,
"start_date": date.isoformat(),
"end_date": date.isoformat(),
"hourly": ["temperature_2m", "precipitation", "wind_speed_10m"],
}
payload = httpx.get(URL, params=params).json()["hourly"]
rows = [dict(zip(payload, values)) for values in zip(*payload.values())]
for row in rows:
row["date"] = date
return rows
The schema gains a date: dt.date column, the partition column. A partitioned asset must be
run for a partition:
dag.materialize(il.TimePartition(dt.date(2026, 1, 15)))
# ./data/open_meteo/forecast/date=2026-01-15/data.csv
Backfilling a range is a loop over a window, newest first:
window = il.TimePartitionWindow(dt.date(2026, 1, 1), dt.date(2026, 1, 7))
for partition in window:
dag.materialize(partition)
daily_summary depends on a partitioned asset, so it must be partitioned too; the DAG rejects a
non-partitioned asset downstream of a partitioned one.
8. Runners¶
dag.materialize() uses an AsyncRunner with four concurrent slots. Runners are async-native;
from sync code, drive them with il.run:
from interloper.events import ConsoleEventHandler
runner = il.AsyncRunner(max_workers=8, fail_fast=False, on_event=ConsoleEventHandler())
result = il.run(runner.run(dag, il.TimePartition(dt.date(2026, 1, 15))))
print(result.status, result.failed_ids)
on_event streams the run's lifecycle events through the logging stack. See
Execution and Events and logging.
9. The CLI¶
With the source saved in weather.py, inspect the plan without writing a driver script. The
module must be importable, so either install your package or put its directory on the path:
Import paths instantiate the class with its defaults, which here means no destinations, and
daily_summary needs forecast to have been written somewhere. Destinations and configuration
for a CLI run live in a spec file, a YAML document reconstructing a job, source or asset:
# weather.yaml
path: weather.OpenMeteo
init:
latitude: 48.86
longitude: 2.35
destinations:
- path: interloper.destination.csv.CSVDestination
init: { base_path: ./data }
The CLI also takes --start-date and --end-date, but that is a single run covering the
whole window, which only assets declaring allow_window=True accept. For one run per day, loop
as in step 7, or declare the window support described in Partitioning.
The runner comes from interloper.yaml or INTERLOPER_RUNNER_* variables. See
CLI and Specs and serialization.
The finished source¶
import datetime as dt
import httpx
import interloper as il
URL = "https://historical-forecast-api.open-meteo.com/v1/forecast"
PARTITIONING = il.TimePartitionConfig(column="date")
class ForecastRow(il.Schema):
date: dt.date
time: dt.datetime
temperature_2m: float | None
precipitation: float | None
wind_speed_10m: float | None
class DailySummary(il.Schema):
date: dt.date
min: float
max: float
@il.source(tags=["Weather"])
class OpenMeteo(il.Source):
"""Hourly weather from Open Meteo."""
latitude: float = il.InputField(default=52.52, description="Latitude of the location")
longitude: float = il.InputField(default=13.41, description="Longitude of the location")
@il.asset(schema=ForecastRow, partitioning=PARTITIONING, tags=["Report"])
def forecast(self, context: il.ExecutionContext) -> list[dict]:
date = context.partition_date
params = {
"latitude": self.latitude,
"longitude": self.longitude,
"start_date": date.isoformat(),
"end_date": date.isoformat(),
"hourly": ["temperature_2m", "precipitation", "wind_speed_10m"],
}
payload = httpx.get(URL, params=params).json()["hourly"]
rows = [dict(zip(payload, values)) for values in zip(*payload.values())]
for row in rows:
row["date"] = date
return rows
@il.asset(schema=DailySummary, partitioning=PARTITIONING, tags=["Report"])
def daily_summary(self, context: il.ExecutionContext, forecast: list[dict]) -> list[dict]:
temperatures = [row["temperature_2m"] for row in forecast if row["temperature_2m"] is not None]
return [{"date": context.partition_date, "min": min(temperatures), "max": max(temperatures)}]
if __name__ == "__main__":
source = OpenMeteo(destinations=il.CSVDestination(base_path="./data"))
result = il.DAG(source).materialize(il.TimePartition(dt.date(2026, 1, 15)))
print(result)