Version v0.4.0 of the documentation is no longer actively maintained. The site that you are currently viewing is an archived snapshot. For up-to-date documentation, see the latest version.
DAG authoring
A Leoflow DAG is two files in a project directory, compiled into an immutable
artifact (dag.json + a container image, versioned together — ADR 0003).
dags/my_pipeline/
dag.py # real Apache Airflow SDK 3.2.x code
leoflow.yaml # Leoflow deploy config (not an Airflow file)
Scaffold one with leoflow init dags/my_pipeline.
Workspace layout (multi-DAG, Lite only)
Lite-specific. Multi-DAG workspace discovery and the hot-reload watcher exist only in
leoflow lite— the developer-mode loop. Pro does not have a “workspace”; in Pro every DAG ships as its own image-and-dag.jsonpair, built by CI and registered vialeoflow push dag.json. See The development → deploy lifecycle.
leoflow lite watches a workspace that can hold many DAGs as sibling
subdirectories. The default workspace is ~/leoflow/ (set by leoflow setup).
~/leoflow/ # workspace root
recurring_print/ # one DAG project per subdir
leoflow.yaml
dag.py
recurring_parallel/
leoflow.yaml
dag.py
ml/
train/ # nested subdirs are scanned too
leoflow.yaml
dag.py
Recommended layout (best practice, not enforced): name the subdirectory the
same as the dag_id. The binding is by dag_id (yaml field, or the subdir
basename when no yaml is present — see below), but matching names make the
workspace navigable by humans and grep-friendly. ~/leoflow/sales_etl/ for a
DAG named sales_etl.
Discovery rules
leoflow lite walks the workspace and treats every subdirectory containing a
dag.py (with or without a leoflow.yaml) as a project. The scan:
- Goes at most 5 levels deep from the workspace root. A DAG at
<ws>/a/b/c/d/dag.pyis the deepest valid case. Deeper paths are skipped. - Skips the
exclude_pathsdefaults:.git,__pycache__,*.pyc,.venv,venv, and any other hidden directory (.*). - Fails loud on a duplicate
dag_id: if two subdirs resolve to the same id, lite refuses to compile any of them and prints both paths so you can rename one. There is no last-write-wins.
Every compile log line names the resolved config source — either the absolute
path of the leoflow.yaml that was loaded, or auto-defaults: <subdir> when
none exists. This is the one line to grep when “which config did it pick up?”
is the debugging question.
leoflow.yaml is optional
A subdir with just a dag.py is a valid project. Leoflow synthesizes a config
with dag_id = <subdir-basename> and every other field filled from the schema
defaults (see Configuration → Defaults). Add a
leoflow.yaml when you need to pin a Python version, declare dependencies, set
per-task overrides, or change the dag_id to something other than the subdir
name.
dag.py — the Airflow dialect
dag.py is real Airflow SDK 3.2.x code, imported by the Python parser via the
Airflow DagBag. TaskFlow and classic operators both work:
from airflow.sdk import DAG, task
@task
def extract() -> dict:
return {"rows": 100}
@task
def transform(data: dict) -> dict:
return {"rows": data["rows"], "doubled": data["rows"] * 2}
with DAG("my_pipeline", schedule="@daily", catchup=False, tags=["etl"]):
transform(extract())
Supported task types
Leoflow runs the common types on a native fast path and everything else — any of Airflow’s ~1,500 provider operators and poke-mode sensors — through a generic executor, native-first by type. Nothing is silently dropped or mistranslated.
| Task type | Airflow operator | Where it runs | Notes |
|---|---|---|---|
python | @task (TaskFlow) or PythonOperator | agent in a pod (Pro) / subprocess (Lite) | The general-purpose escape hatch — any provider library you pip install is callable from inside a @task. |
bash | BashOperator | agent | Executes a shell command. |
http_api | — | — | Removed (ADR 0047/0048, #512). HttpOperator now compiles to airflow_operator and runs in a pod, like any other provider operator — declare connectors: [http]. The old inline path ran the request in the control-plane process (an SSRF surface) and is gone. |
airflow_operator | any provider operator/sensor (Snowflake, S3, Postgres, BigQuery, …) | agent in a pod | The generic executor (ADR 0040): the runtime imports the class, instantiates it with your args, and calls execute(). Declare the provider or compile fails. |
A bash_command is Jinja-rendered with the run context ({{ ds }},
{{ params.x }}, {{ var.value.x }}). The command text you write is the
shell structure and is used verbatim; every interpolated value is
automatically shell-quoted (shlex.quote) before it reaches bash -c. This is
a security boundary: params is the run’s conf, which anyone who can
trigger the DAG supplies (execute:dag, a lower bar than authoring it), so an
un-quoted value would let a trigger inject arbitrary shell (issue #489).
Practical consequence — write interpolations unquoted, and do not wrap them in your own quotes:
- ✅
process --name {{ params.name }}— the value is quoted for you. - ❌
process --name "{{ params.name }}"— your quotes fight the auto-quoting. - A value can no longer expand into multiple shell words/flags; if you need
that, pass the pieces as separate
paramsand interpolate each. For secrets, reference$AIRFLOW_VAR_*/$AIRFLOW_CONN_*env vars instead of rendering them into the command.
Also supported:
- Trigger rules:
all_success,all_failed,all_done,one_success,one_failed. - XCom: TaskFlow data-flow (
transform(extract())) is resolved automatically into typed inputs (xcom_input) — for@taskand operator args. - Schedule: cron strings and presets (
@daily,0 * * * *). - Dependencies: linear ordering via TaskFlow calls or
a >> b.
Connector cookbooks (postgres, mysql, sqlite, redis, http) are
pythontasks that read a managed Connection (AIRFLOW_CONN_*) — that is the hook-inside-@taskstyle. Theairflow_operatortype below is the operator-class style; both resolve the same managed connections.
Provider operators & sensors (airflow_operator)
Write the operator exactly as you would in Airflow — Leoflow captures it and runs it in its own task pod:
from airflow.sdk import DAG, task
from airflow.providers.snowflake.operators.snowflake import SQLExecuteQueryOperator
@task
def build_sql() -> str:
return "SELECT count(*) FROM events"
with DAG("rollup", schedule="@daily"):
# an upstream task's output flows straight into an operator arg
SQLExecuteQueryOperator(task_id="rollup", conn_id="snowflake_default",
sql=build_sql())
# leoflow.yaml — installs apache-airflow-providers-snowflake into the image
connectors: [snowflake] # or: dependencies: [apache-airflow-providers-snowflake]
How it works, and the Phase-A limits:
- The operator runs in its own task pod; its hooks resolve connections from
AIRFLOW_CONN_*exactly as inside a@task. - Constructor args must be JSON-serializable or an upstream task’s output
(wired as XCom, like
sql=build_sql()above). A non-serializable arg — a callable, adatetime, an arbitrary object — is a loud compile error; move that logic into a@task. - Literal args are for small constants. A task’s literal args (
@taskcall_argsand operatoroperator_args) ride as a single environment variable at dispatch, which POSIX caps at ~128 KiB;leoflow compilerejects a payload over 100 KiB with a clear error naming the task. For large data, pass a Connection or an external-storage reference (S3/GCS) and fetch it inside the task — never a big dict/list literal. - The provider must be declared in
leoflow.yaml(connectors:ordependencies:). If it isn’t,leoflow compilefails and prints the exact line to add — no surpriseModuleNotFoundErrorin the pod. - Sensors run in poke mode (the pod holds until the condition is met).
mode="reschedule"is not supported yet and fails with a clear message. - Jinja templating is best-effort (
render_template_fieldsruns with a minimal context); for rich macros ({{ ds }},{{ ti }}, custom params), compute the value in a@taskand pass it in. - A native task type (
bash/python) always wins when it matches, soBashOperator/HttpOperator/PythonOperatorkeep their fast path.
Not supported — leoflow compile rejects these
The contract is loud rejection, not silent mistranslation: every “skipped” branch would otherwise actually execute at runtime, so a DAG that imports a sensor or interpolates a Jinja template is refused at compile with a clear error naming the construct (#225).
The unsupported set, with the things Airflow users most often expect to “just work” called out first:
- Reschedule-mode sensors — a sensor with
mode="reschedule"(frees the worker between pokes) is not supported yet; it fails at runtime with a clear message. Use the defaultmode="poke"(the pod holds until the condition is met). Poke-mode sensors and provider operators ARE supported — seeairflow_operatorabove. - Deferrable operators / sensors — anything with
deferrable=True(it suspends the task onto a trigger) is not supported yet: Leoflow has no triggerer (ADR 0040 Phase C). It fails at runtime with a clear message. Passdeferrable=False— the operator runs synchronously in the pod (poke-style). - Jinja templating in
@task—{{ ds }},{{ ti }},{{ var.value.x }}, everytemplates_dict=knob. The control plane never re-parses Python and the templating step is intentionally not implemented. Workaround: build the values inside the@taskfromairflow.sdkcontext. - Branching (
BranchPythonOperator,@task.branch) and short-circuit (@task.short_circuit,ShortCircuitOperator) — refused for now: the current scheduler does not model skipped-vs-executed downstream paths. On the backlog if user demand justifies the scheduler change. - Virtualenv operators (
PythonVirtualenvOperator,@task.virtualenv) — refused because each DAG already ships as its own image with its own dependencies; spinning up a venv at runtime is the problem Leoflow’s one-image-per-DAG model already solved. - Dynamic task mapping (
.expand/.partial) — refused. Static fan-in (collecting a list of task calls into a downstream task) works today; what does not work is dynamic fan-out — expanding one task into N parallel instances at runtime from an upstream result. That is tracked separately. - KubernetesPodOperator — refused; the pod is the runtime substrate for every Leoflow task already, so wrapping a user task in another pod is redundant and adds an isolation hole.
- Datasets / Assets triggers — not implemented yet; the asset graph is a 3.x Airflow feature on the backlog.
- Per-task
default_argsindag.pyare ignored at the parser level — useleoflow.yaml’stasks.<id>:override block instead, which is checked at compile time.
leoflow.yaml — deploy config
These are Leoflow concerns, not Airflow operator attributes (you cannot invent kwargs on an operator — the parser imports real Airflow and would raise).
schema_version: "1.0"
dag_id: my_pipeline
description: Daily ETL.
owner: data-eng
tags: [etl]
python_version: "3.11"
dependencies: # pip packages baked into the image
- pandas==2.1.0
defaults: # DAG-level defaults (applied to every task)
retries: 1
retry_delay_seconds: 30
staging: # opt-in shared per-run RWX volume (ADR 0022)
enabled: false
tasks: # per-task overrides, keyed by task_id (ADR 0023)
transform:
retries: 3
resources:
requests: { cpu: "2", memory: 4Gi }
Binding + override layers (ADR 0023)
Config binds to the DAG by dag_id and to tasks by task_id. Three layers,
most specific wins:
task override (tasks.<id>) > DAG default (defaults) > platform default (server)
tasks.<id>is merged at compile time onto the task indag.json.- Platform defaults are applied at dispatch time, filling only gaps the artifact left empty (keeps the artifact portable across clusters).
stagingis DAG-level only — one RWX volume is shared atomically by the whole run, so it cannot be per-task.
Guardrails (fail loudly, never silently)
- A
tasks:entry naming atask_idabsent from the DAG → compile error. - A duplicate
task_idkey in the YAML → parse error. - Across a monorepo, a duplicate
dag_idis a CI-gate concern (one image per DAG).
The development → deploy lifecycle
flowchart LR I[leoflow init] --> D[leoflow lite<br/>hot-reload loop] D -->|save & iterate| D D --> G[git push] G --> CI[CI: leoflow compile --build --push] CI --> REG[leoflow push dag.json] REG --> PROD[(control plane<br/>immutable artifact)]
1 · Develop (fast, isolated)
leoflow init dags/my_pipeline # scaffold dag.py + leoflow.yaml
leoflow lite dags/my_pipeline # cluster-mode: real pods on an isolated k3d
# or: leoflow lite --executor=subprocess dags/my_pipeline (fastest, host venv)
Open http://localhost:8088 — the UI is marked Leoflow Lite (silver
edition badge); log in with the admin password generated by leoflow setup
(or run leoflow lite reset-password if you misplaced it). Edit
dag.py/leoflow.yaml and save; Leoflow recompiles, re-runs the guardrails,
and re-registers. A bad binding prints an error in the terminal immediately:
✗ leoflow.yaml tasks: unknown task_id "transfrom"; the DAG defines [extract load transform]
Lite is fully isolated from Demo/Pro (own database, cluster, and ports) — no split brain. See Operating modes.
2 · Deploy (authoritative, immutable)
On git push, CI compiles + builds + pushes the artifact. The same parser,
overlay, and guardrails run as a gate, so what you tested in Lite is what ships:
leoflow compile dags/my_pipeline --image ghcr.io/org/my_pipeline:$GIT_SHA --build --push
leoflow push dag.json
Full, copy-pasteable pipelines for GitHub Actions, GitLab CI, Google Cloud Build/Run, and generic runners are in CI/CD & deploy examples.
See also: Concepts & glossary · Operating modes · HTTP API · ADR 0023 — binding & overrides.