Ingest from a custom data source¶
Some sources have no built-in Spark connector: a REST API, an internal protocol,
a queue only your platform speaks. For those you write a PySpark DataSource
class — and then you have to wire it into the pipeline: register it on the
session, read it with the right format string, and make it survive the trip to
the executors.
You could hand-write that wiring: a spark.dataSource.register(...) call, a
spark.readStream.format(...).load() inside a @dp.temporary_view, and — the
line everyone forgets — a cloudpickle registration so the class serializes to the
executors. Or you declare a type: custom_datasource load, point it at your
module and class, and let Lakehouse Plumber write all of that. That is the idea
on every page: declare your ETL, don’t hand-write it.
Let’s ingest live currency exchange rates from a REST API through a
DataSource you supply, and land them in a bronze streaming table.
Before you start¶
You need Lakehouse Plumber installed and a project to work in. You also need the
DataSource implementation itself — writing one is a PySpark skill, not a
Lakehouse Plumber one, so this guide brings a finished class and focuses on
wiring it in. If the source you need already has a native load type
(cloudfiles, delta, jdbc, kafka), reach for that instead; a
custom data source is for the systems Spark cannot read on its own.
Bring the DataSource class¶
Put the PySpark DataSource under data_sources/ at the project root.
This one streams exchange rates from an HTTP endpoint:
"""A PySpark DataSource that streams currency exchange rates from a REST API.
Spark ships no connector for an arbitrary HTTP endpoint, so this implements the
PySpark ``DataSource`` interface directly. The streaming reader fetches one
snapshot of rates per micro-batch and lets Spark manage the offsets.
"""
from typing import Iterator, Tuple
from pyspark.sql.datasource import (
DataSource,
DataSourceStreamReader,
InputPartition,
)
from pyspark.sql.types import StructType
class ExchangeRatesDataSource(DataSource):
"""Live currency exchange rates from a REST API."""
@classmethod
def name(cls):
return "exchange_rates"
def schema(self):
return (
"base_currency string, "
"quote_currency string, "
"rate double, "
"fetched_at timestamp"
)
def streamReader(self, schema: StructType):
return ExchangeRatesStreamReader(self.options)
class _FetchWindow(InputPartition):
def __init__(self, fetched_at_ms: int):
self.fetched_at_ms = fetched_at_ms
class ExchangeRatesStreamReader(DataSourceStreamReader):
"""Fetch one batch of rates per micro-batch from the configured endpoint."""
def __init__(self, options):
self.api_url = options.get("api_url", "https://api.example.com/rates")
self.base_currencies = options.get("base_currencies", "USD").split(",")
def initialOffset(self) -> dict:
import time
return {"fetch_time": int(time.time() * 1000)}
def latestOffset(self) -> dict:
import time
return {"fetch_time": int(time.time() * 1000)}
def partitions(self, start: dict, end: dict):
return [_FetchWindow(end.get("fetch_time", 0))]
def read(self, partition) -> Iterator[Tuple]:
from datetime import datetime, timezone
import requests
fetched_at = datetime.now(timezone.utc)
for base in self.base_currencies:
response = requests.get(self.api_url, params={"base": base}, timeout=10)
rates = response.json().get("rates", {})
for quote, rate in rates.items():
yield (base, quote, float(rate), fetched_at)
Two things in this file are load-bearing for the wiring. First, the name()
classmethod returns "exchange_rates" — that string, not the class name,
becomes the .format(...) argument in the generated read. Second, because
readMode will be stream, the class implements streamReader and a
DataSourceStreamReader so Spark manages the offsets. A batch source would
implement reader / DataSourceReader instead.
Declare the custom data source load¶
A flowgroup is a short YAML file describing a sequence of actions. This one has
two — a load that reads through your DataSource, and a write that
appends the result into a streaming table. Create
pipelines/exchange_rates.yaml:
# Ingest live currency exchange rates from a REST API through a user-supplied
# PySpark DataSource. Action chain: load (custom_datasource) -> write.
pipeline: bronze_rates
flowgroup: exchange_rates
actions:
# 1. Load: read through the ExchangeRatesDataSource class. LHP copies the
# module, registers the class, and reads it as a streaming source.
- name: load_exchange_rates
type: load
readMode: stream
source:
type: custom_datasource
module_path: "data_sources/exchange_rates_source.py"
custom_datasource_class: ExchangeRatesDataSource
options:
api_url: "${exchange_api_url}"
base_currencies: "USD,EUR,GBP"
target: v_exchange_rates
# 2. Write: append the fetched rates into a bronze streaming table.
- name: write_rates_bronze
type: write
source: v_exchange_rates
write_target:
type: streaming_table
catalog: "${catalog}"
schema: "${bronze_schema}"
table: exchange_rates
The source block names the class by two required fields: module_path
points at the .py file relative to the project root, and
custom_datasource_class names the class inside it. Everything under
options is handed to your reader — here an api_url and a
base_currencies list, which surface as self.options in the
DataSourceStreamReader. readMode: stream selects the streaming reader;
drop it and stream is the default anyway.
The ${...} tokens are placeholders you resolve per environment, so the same
flowgroup runs unchanged in dev, staging, and prod.
Point the tokens at your environment¶
The ${exchange_api_url} in options and the ${catalog} /
${bronze_schema} in the write resolve from a per-environment substitutions
file. Create substitutions/dev.yaml:
# Development environment tokens.
# Referenced from flowgroups with ${token} syntax.
dev:
catalog: dev_catalog
bronze_schema: bronze
exchange_api_url: https://api.exchangerates.example.com/v1/latest
This is the only place environment-specific values live. Substitution reaches
into options and into the copied DataSource file itself, so a production
endpoint — or a ${secret:scope/key} reference for an API key — is a change
to prod.yaml, never to the flowgroup or the class.
Generate the pipeline¶
Now compile the YAML into Lakeflow code. Validate first, then generate:
$ lhp validate --env dev
✓ discover (0.01s)
✓ preflight (0.00s)
✓ bronze_rates 0 files
✓ validate (0.33s)
1 validated · 0.3s
$ lhp generate --env dev
✓ discover (0.01s)
✓ preflight (0.00s)
✓ bronze_rates 1 file
✓ generate (0.35s)
✓ format (0.05s)
✓ monitoring (0.00s)
1 pipeline generated · 1 file · 0.4s
validate resolves every token and checks the actions before you commit to
generating; generate writes the Python — and copies your DataSource
module next to it.
Read what Lakehouse Plumber wrote¶
Open generated/dev/bronze_rates/exchange_rates.py. This is the entire
output — nothing is hidden behind a runtime:
# Generated by LakehousePlumber
# Pipeline: bronze_rates
# FlowGroup: exchange_rates
from pyspark import cloudpickle as _lhp_cloudpickle
from pyspark import pipelines as dp
from custom_python_functions.exchange_rates_source import ExchangeRatesDataSource
import custom_python_functions
_lhp_cloudpickle.register_pickle_by_value(custom_python_functions)
# Pipeline Configuration
PIPELINE_ID = "bronze_rates"
FLOWGROUP_ID = "exchange_rates"
# ============================================================================
# SOURCE VIEWS
# ============================================================================
# Try to register the custom data source
try:
spark.dataSource.register(ExchangeRatesDataSource)
except Exception:
pass # Ignore if already registered
@dp.temporary_view()
def v_exchange_rates():
"""Load data from custom data source: ExchangeRatesDataSource"""
df = (
spark.readStream.format("exchange_rates")
.option("api_url", "https://api.exchangerates.example.com/v1/latest")
.option("base_currencies", "USD,EUR,GBP")
.load()
)
return df
# ============================================================================
# TARGET TABLES
# ============================================================================
# Create the streaming table
dp.create_streaming_table(
name="dev_catalog.bronze.exchange_rates", comment="Streaming table: exchange_rates"
)
# Define append flow(s)
@dp.append_flow(
target="dev_catalog.bronze.exchange_rates",
name="f_rates_bronze",
comment="Append flow to dev_catalog.bronze.exchange_rates",
)
def f_rates_bronze():
"""Append flow to dev_catalog.bronze.exchange_rates"""
# Streaming flow
df = spark.readStream.table("v_exchange_rates")
return df
Every piece of the wiring is here. LHP registered the class with
spark.dataSource.register(ExchangeRatesDataSource), read it with
spark.readStream.format("exchange_rates") — that format string came from your
name() method, not the class name — and passed the resolved api_url and
base_currencies through as .option(...) calls. The
@dp.temporary_view view feeds a create_streaming_table and an
@dp.append_flow that land the rates in dev_catalog.bronze.exchange_rates.
The one line you would never have remembered is
_lhp_cloudpickle.register_pickle_by_value(custom_python_functions). LHP copied
your module into a custom_python_functions/ package beside the generated file
(with a # LHP-SOURCE / DO-NOT-EDIT header) and registered that package with
PySpark’s vendored cloudpickle, so the DataSource class survives
serialization when the pipeline ships it to the executors. Miss that line by hand
and the pipeline fails at run time with a pickling error.
What you just did¶
Twenty-two lines of flowgroup YAML compiled to sixty-two lines of Lakeflow
Python. Zero lines of the wiring came from you — not the
spark.dataSource.register call, not the format(...).load() read, not the
@dp.temporary_view, not the create_streaming_table / append_flow, and
not the cloudpickle registration.
The DataSource class stays yours: LHP does not write your fetch logic, and it
does not touch the class it copies. What it owns is the plumbing that turns a
class on disk into a registered, executor-safe, wired-up streaming source — the
part that is fiddly to get right and identical every time.
What’s next¶
Switch to a batch source. Set
readMode: batchand implementreader/DataSourceReaderon the class instead of the streaming pair; LHP emitsspark.read.format(...)in place ofspark.readStream.Feed a secret in. Reference
${secret:scope/key}insideoptionsfor an API key or token — it resolves to adbutils.secrets.get(...)call in the generated read, so no credential lands in YAML.See every field.
module_path,custom_datasource_class,options, and thereadModerules are all covered in the load action reference.