Skip to content

fix(airflow): the init DAG helper writes what generate schedule writes, and Snowflake python tasks no longer lose the DAG - #739

Merged
fas89 merged 3 commits into
mainfrom
fix/airflow-init-and-python-tasks
Oct 22, 2026
Merged

fas89 merged 3 commits into
mainfrom
fix/airflow-init-and-python-tasks

Conversation

@fas89

@fas89 fas89 commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #736 (feat/airflow3-dags): it rewrites the same generators. Draft until #736 merges; this branch then gets rebased onto main and retargeted. Do not merge before.

What and why

Two defects the #736 review turned up, plus the non-shipped example.

1. fluid init's DAG helper never ran the generator it called. generate_dag_for_project ran fluid generate-airflow --output-dir ..., a flag that command never had. argparse exited 2 and every project got the basic fallback DAG. When fluid was not on PATH, it got no DAG at all. The helper's tests mocked subprocess.run, so nothing parsed that argv. With valid flags, generate-airflow (deprecated) writes only echo placeholder tasks for the shipped templates. It now does the following, per the decisions made during review:

  • Runs fluid generate schedule --scheduler airflow --output <abs dags> --contract-path contract.fluid.yaml --overwrite. This is the command generate-airflow's own deprecation notice names. It runs in-process through the CLI's own build_parser, as stage 3 (artifact_fanout) runs it.
  • Writes no DAG, and says why, when the contract schedules nothing: no build with a cron trigger and no orchestration.tasks. For example, customer-360's build is trigger.type: manual, and the command would synthesize a nightly DAG that only logs.
  • Writes no DAG when the command refuses the contract, for example an invalid cron. The old fallback wrote a basic DAG on a schedule the contract never declared, which hid the error.
  • Judges both conditions on contract.fluid.yaml as the command loads it, including $ref builds and the legacy singular build:.
  • Writes a dags/README.md that lists each DAG this run wrote and says to edit the contract, not the generated DAG.

create_basic_dag stays a Python helper. Its fluid apply --auto-approve (rejected, exit 2) is now --yes, and its project path is shell-quoted and then emitted as a Python string literal. No CLI command reaches the helper with DAGs on today: fluid demo passes no_dag=True.

2. Snowflake orchestration python tasks lost the whole DAG. providers/snowflake/orchestration/airflow_generator.py emitted python_callable=<name> for a function the module never defined. Airflow raised NameError at parse and dropped the DAG; this reproduces in a real DagBag on 3.2.2 and 2.10.5. The generator now defines a stub of the callable above the task. The stub raises AirflowFailException naming the task, so the DAG loads and that one task fails at once instead of burning retries. A callable name that would rebind something the DAG uses (dag, an operator class, an earlier task, ImportError, a dunder) is refused with a ValueError. sanitize_identifier and py_str_literal escaping are unchanged. This generator is library-only (fluid export uses providers/snowflake/codegen).

3. examples/generated_dags/financial_analytics_dag.py is regenerated through AwsProvider.export and black at width 100. It now uses schedule= and Airflow-3-first imports with the Airflow 2 fallback.

Borrow before build

  • In repo: ship.py::_resolve_fluid_bin; stage 3's in-process generate_schedule.run; fluid_apply.has_scheduled_builds/has_explicit_tasks; codegen_utils escaping; the tests/cli/_schedule_dag_fixtures.load_dag stub-Airflow harness (adapted).
  • Outside: dag-factory resolves callables at parse time from files or dotted paths. Not adopted: contract text must never become code executed at parse time.
  • Airflow @task.stub is for externally executed tasks and needs a recent standard provider, so it is not used.
  • DagBag import_errors integrity checks follow Astronomer's testing guide.

Validation

  • Real Airflow 3.2.2 and 2.10.5 DagBag (uv venvs with the snowflake, amazon and common-sql providers; 3.2.2 also has the standard provider): the Snowflake python-task DAG, first-dag and pipeline-orchestration fluid apply DAGs, and the example all load with 0 import errors. The base code gives NameError: name 'score_customers' is not defined on both versions.
  • airflow tasks test on the stub task: 3.2.2 new_state=failed, 2.10.5 Marking task as FAILED. With NotImplementedError both went UP_FOR_RETRY.
  • Journeys through the real helper:
    • first-dag, pipeline-orchestration and incremental-processing get their DAG plus README.
    • customer-360 gets none, with the reason.
    • An invalid cron gets none; the command's own error and the exit code are shown.
    • A re-run regenerates.
    • A pre-existing dags/helpers.py stays out of the README.
    • A relative project dir and a singular build: contract both work.
    • The README's regenerate command works.
  • The basic DAG's validate, plan and apply --yes ran for real in a project path containing a space and ' (all rc 0).
  • New tests fail against the base code. Targeted suites pass: 509. Lint matches CI: ruff, black 24.10.0 on the whole tree, license headers, lint-imports (6/6).
  • Full suite on duckdb 1.5.6: the only failures are known environment baselines (masking needs numpy, no dbt, macOS temp-root sandbox, live claude/MCP tests), none in touched areas. DuckDB 2.0.0 (released 2026-10-21) breaks 48 unrelated tests repo-wide and is why main CI is red; the DuckDB 2.0 port session owns that.

Reviews

  • /security-review: no findings in round 1, round 2 or the round-3 delta. 69 hostile callable values and 15 hostile project paths were checked: no parse-time code, no shell injection, $ref confined, nothing written outside dags/.
  • /world-class-inspection: round 1 returned CONCERNS (fallback semantics; subprocess design; README contradicting generated DAGs). All are resolved, per the round-2 re-check. Remaining P3, latent: an orchestration.engine: none opt-out is not honoured, and should_generate_dag can miss $ref builds (Trello).
  • /defect-scan: round 1 had 4 hunters plus a referee. Its confirmed defects were fixed and re-verified: fallback on a benign refusal, README listing files the run did not write, relative paths, decoding, the task_vars false positive, retries of the stub, and harness blind spots. Round 2 left 5 Low latent items: engine opt-out; the outer gate on $ref; the message when the command fails after writing; the reason printed twice; the command's Tip missing --overwrite (Trello).

Follow-ups (on the Trello board)

  • Land this PR after fix(airflow): every generated DAG loads on Airflow 3 and on Airflow 2.7+ #736: rebase, retarget, merge on green.
  • Init helper latent items from the last round.
  • Scheduler: synthesis gives manual builds a nightly no-op DAG; the generic scheduler drops bash/python orchestration.tasks.
  • Init DAG path: unreachable from the CLI, dead --no-dag, flat dags/ vs schedule-sync, stale DAGs on regenerate, Jinja in the project path.
  • Snowflake orchestration generator: decide whether to delete it or align it with the schema.
  • AWS generator: Glue key mismatch visible in the example.

@github-actions github-actions Bot added provider Changes or requests related to providers cli Changes to the CLI surface or implementation tests Test coverage or test infrastructure changes labels Oct 22, 2026
@fas89
fas89 force-pushed the feat/airflow3-dags branch from f29bf7b to 90447ed Compare October 22, 2026 13:22
fas89 added 3 commits October 22, 2026 15:54
providers/snowflake/orchestration/airflow_generator.py emitted
`python_callable=<callable>` for a function the generated module never
defined, so Airflow raised NameError parsing the file and dropped the whole
DAG. Reproduced in a real DagBag on Airflow 3.2.2 and 2.10.5:
"NameError: name 'score_customers' is not defined". The generator is a
library function: no fluid command calls it (fluid export uses
providers/snowflake/codegen).

It now defines a stub of that callable right above its PythonOperator. The
stub raises AirflowFailException naming the task and the callable, imported
when it runs (airflow.sdk.exceptions on Airflow 3, airflow.exceptions on 2),
so the DAG loads and that one task fails at once, without the retries a stub
cannot pass, until someone writes the function. `airflow tasks test` marks it
failed on both versions; a NotImplementedError left it up_for_retry. A task
that names no callable gets `<task>_func`, as before. Injection safety is
unchanged: the stub's name goes through sanitize_identifier and its message
is one py_str_literal.

A stub `def` would rebind a name the module already uses above it, so a
callable named `dag`, `DAG`, `default_args`, an operator class, an earlier
task's variable or a `__dunder__` is refused with a ValueError at generation.
A task that only becomes an ERROR or TODO comment binds nothing and reserves
no name. Sharing its own task's name works:
`x = PythonOperator(python_callable=x)` reads the stub before the rebind.

Refusing python tasks outright was the alternative. A stub keeps the DAG a
usable scaffold, the way this generator already marks what it cannot
generate (TODO and ERROR comments). Airflow's @task.stub is for tasks an
external worker runs and needs a recent standard provider, so it is not used.

The new tests load the generated module against stub Airflow modules (the
pattern of tests/cli/_schedule_dag_fixtures.load_dag) whose operators, like
Airflow's, refuse a `dag=` that is not the DAG and `>>` on anything but a
task, so the NameError and any rebinding reproduce without Airflow installed.
… or nothing

generate_dag_for_project ran `fluid generate-airflow --output-dir ...`, a
flag generate-airflow never had. argparse exited 2 ("unrecognized arguments:
--output-dir"), so every project got the basic fallback DAG under a warning
that the command was "not available"; with no `fluid` on PATH the
FileNotFoundError meant no DAG at all. Its tests mocked subprocess.run, so
nothing ever parsed that argv.

The helper now runs `fluid generate schedule --scheduler airflow --output
<abs dags> --contract-path contract.fluid.yaml --overwrite`, the command
generate-airflow's own deprecation notice points to. (With valid flags,
generate-airflow's DAG for the shipped templates is all `echo` placeholder
tasks.) It runs in-process, parsed by the CLI's own build_parser, as stage 3
(artifact_fanout) runs the same command: a flag the command lacks fails here
as on the command line, and with no child process there is no PATH, cwd,
sys.path or output-decoding assumption. --overwrite lets a re-run
regenerate its DAGs.

It writes no DAG, and says why, when:
- the contract schedules nothing: no build with a cron trigger and no
  orchestration.tasks (fluid_apply.has_scheduled_builds/has_explicit_tasks).
  customer-360's build is `trigger.type: manual`; generate schedule would
  have synthesized a nightly DAG whose only task logs and passes.
- the command refuses the contract, an invalid cron for instance. The basic
  validate/plan/apply DAG it used to fall back to ran on a schedule the
  contract never declared and hid the error; stage 3 treats the same exit as
  a hard failure.

Both are judged on contract.fluid.yaml as the command reads it: its loader
resolves `$ref` builds and the legacy singular `build:` key, which the raw
YAML hides. should_generate_dag, the caller's gate, now also accepts a build
with a cron trigger, so the two gates agree.

dags/README.md lists each DAG this run wrote (dag_id and schedule read from
the file with ast), not other files already in dags/, says to edit the
contract and regenerate rather than edit the generated DAG, and says that
regenerating does not delete the DAG of a renamed or removed build.

create_basic_dag stays as a Python helper. Its apply task ran `fluid apply
contract.fluid.yaml --auto-approve`, which fluid apply rejects (exit 2); it
passes --yes now, and its project path is shell-quoted and then emitted as a
py_str_literal, so a path holding a quote no longer breaks the DAG or runs
what follows it.

No CLI command reaches this helper with DAGs enabled today: `fluid demo`
passes no_dag=True and no `fluid init` mode calls it.

tests/test_init_generate_schedule_dag.py parses the helper's argv with the
real build_parser() and runs the real command on shipped templates:
scheduled builds get their fluid apply DAG and README, a manual or
continuous build gets none, a refused contract gets none plus the command's
reason, a re-run regenerates, a file already in dags/ stays out of the
README, a relative project dir works, and a singular `build:` contract gets
its DAG. Older tests that mocked
subprocess.run now mock the in-process call.
…2.7+/3 shape

The example was AwsProvider.export output from before the Airflow 3 work:
DAG(schedule_interval=...) and bare Airflow 2 imports, which fail to import on
Airflow 3, and helper bodies hollowed out by hand. Regenerated with the call
examples/generate_example_dags.py makes (AwsProvider.export, engine airflow)
and black-formatted like the file it replaces: schedule=, and DAG and
PythonOperator imported from airflow.sdk and apache-airflow-providers-standard
with the Airflow 2 fallback. It loads in a real DagBag on Airflow 3.2.2 and
2.10.5 with the amazon provider installed.
@fas89
fas89 force-pushed the fix/airflow-init-and-python-tasks branch from bee71ff to e9bffb0 Compare October 22, 2026 13:56
@fas89
fas89 changed the base branch from feat/airflow3-dags to main October 22, 2026 13:56
@fas89
fas89 force-pushed the fix/airflow-init-and-python-tasks branch from bee71ff to e9bffb0 Compare October 22, 2026 13:56
@fas89
fas89 marked this pull request as ready for review October 22, 2026 13:56
@github-actions github-actions Bot added the needs-docs Pull request needs a linked docs update or justification label Oct 22, 2026
@github-actions

Copy link
Copy Markdown

📄 Documentation Reminder

This PR appears to be missing a documentation reference. Our docs live in a separate repo.

Please update the PR description with one of:

  • Link a docs PR — check the "Docs PR linked" box and paste the URL
  • Mark as no docs needed — check "No docs needed" with a justification
  • Acknowledge docs TODO — check "Docs TODO" and create the docs PR before merge

See the Contributing Guide for details.

@fas89 fas89 closed this Oct 22, 2026
@fas89 fas89 reopened this Oct 22, 2026
@fas89
fas89 merged commit fd232f1 into main Oct 22, 2026
36 of 57 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli Changes to the CLI surface or implementation needs-docs Pull request needs a linked docs update or justification provider Changes or requests related to providers tests Test coverage or test infrastructure changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant