Skip to content

Commit a0eab98

Browse files
feat: add dag_codegen module and generate CLI command
Adds a `generate` CLI command that pre-compiles YAML DAG configs into static Airflow DAG .py files, so DAGs can be produced once at build/CI time instead of being re-parsed from YAML on every DAG-processor cycle. The generated code correctly propagates `dependencies` (task ordering), merges `default_args` into each task, parses `start_date`/`end_date` into real datetime objects, and keeps `dag_id` identical to the YAML DAG name for parity with the runtime YAML loader. Not yet supported: TaskFlow (`decorator`) tasks and `task_groups` — both raise a clear error and are left to the runtime loader.
1 parent 33fd178 commit a0eab98

7 files changed

Lines changed: 1720 additions & 2 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,5 @@ webserver_config.py
131131

132132
# Astro
133133
dev/include/dag_factory-*
134+
135+
*/generated/*

dagfactory/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Modules and methods to export for easier access"""
22

33
from .dagfactory import load_yaml_dags
4+
from .dag_codegen import generate_dag_block, generate_dags_file
45

56
__version__ = "1.0.1"
67
__all__ = [

dagfactory/__main__.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010

1111
from dagfactory import __version__
1212
from dagfactory._yaml import load_yaml_file
13+
from dagfactory.constants import DEFAULTS_FILE_NAMES
14+
from dagfactory.dag_codegen import generate_dag_block, generate_dags_file
15+
from dagfactory.dagfactory import _DagFactory
1316
from dagfactory.utils import update_yaml_structure
1417

1518
DESCRIPTION = """
@@ -184,5 +187,101 @@ def convert(
184187
)
185188

186189

190+
@app.command()
191+
def generate(
192+
yaml_file_dir: Path = typer.Argument(..., help="Path to a directory containing YAML files to generate DAGs from"),
193+
py_dags_dir: Path = typer.Argument(
194+
..., help="Path to a directory where the generated .py DAG files will be written"
195+
),
196+
):
197+
198+
if not yaml_file_dir.exists():
199+
console.print(f"[red]Error:[/red] Path '{yaml_file_dir}' does not exist.")
200+
raise typer.Exit(1)
201+
202+
if not py_dags_dir.exists():
203+
py_dags_dir.mkdir(parents=True)
204+
205+
all_yaml_files = list(yaml_file_dir.rglob("*.yaml")) + list(yaml_file_dir.rglob("*.yml"))
206+
# `defaults.yml`/`defaults.yaml` files hold shared default_args for other DAGs in the
207+
# directory tree — they are not DAG definitions themselves and must not be generated as one.
208+
yaml_files = [f for f in all_yaml_files if f.name not in DEFAULTS_FILE_NAMES]
209+
if not yaml_files:
210+
console.print(f"[yellow]No YAML files found in '{yaml_file_dir}'.[/yellow]")
211+
raise typer.Exit(0)
212+
213+
errors = []
214+
skipped = []
215+
for yaml_file in yaml_files:
216+
try:
217+
# `cast_types=False` keeps `__type__` dicts as-is, so `dag_codegen` can re-emit them
218+
# as real constructor source code instead of an already-instantiated, unrenderable object.
219+
config = load_yaml_file(str(yaml_file), cast_types=False)
220+
default_config = config.get("default", {})
221+
222+
# Merge in the shared `defaults.yml`, if any, the same way the runtime YAML loader does:
223+
# global default_args are lowest priority, this file's own `default:` args take precedence.
224+
factory = _DagFactory(
225+
config_filepath=str(yaml_file.resolve()), defaults_config_path=str(yaml_file_dir.resolve())
226+
)
227+
global_default_args = factory._global_default_args()
228+
dag_level_args = {}
229+
if isinstance(global_default_args, dict):
230+
default_config["default_args"] = factory._merge_default_args_from_list_configs(
231+
[global_default_args, default_config]
232+
)
233+
dag_level_args = factory._merge_dag_args_from_list_configs([global_default_args])
234+
235+
dags_to_generate = {}
236+
for dag_name in config:
237+
if dag_name == "default":
238+
continue
239+
if not isinstance(config[dag_name], dict):
240+
continue
241+
dag_config = {**dag_level_args, **deepcopy(config[dag_name])}
242+
for key, value in default_config.items():
243+
if key not in dag_config:
244+
dag_config[key] = deepcopy(value)
245+
try:
246+
generate_dag_block(dag_name, dag_config) # validate first
247+
dags_to_generate[dag_name] = dag_config
248+
console.print(f"[green]✓ DAG {dag_name} generated successfully")
249+
except ValueError as e:
250+
console.print(
251+
f"[yellow]⚠ Skipping DAG '{dag_name}' in '{yaml_file.name}': {e} "
252+
f"— tasks may be inheriting config not supported by generate[/yellow]"
253+
)
254+
skipped.append(f"{yaml_file.name}::{dag_name}")
255+
if dags_to_generate:
256+
py_file = py_dags_dir / (yaml_file.stem + ".py")
257+
py_file.write_text(generate_dags_file(dags_to_generate))
258+
except yaml.YAMLError as e:
259+
console.print(f"[yellow]⚠ Skipping '{yaml_file.name}': invalid YAML syntax — {e}[/yellow]")
260+
skipped.append(yaml_file.name)
261+
except Exception as e:
262+
error_msg = str(e)
263+
if "No module named" in error_msg:
264+
import re
265+
266+
match = re.search(r"No module named '([^']+)'", error_msg)
267+
package = match.group(1) if match else "unknown"
268+
console.print(
269+
f"[yellow]⚠ Skipping '{yaml_file.name}': missing optional package '{package}'. "
270+
f"Install it with: pip install {package}[/yellow]"
271+
)
272+
skipped.append(yaml_file.name)
273+
else:
274+
console.print(f"[red]✗ Skipping '{yaml_file.name}': {e}[/red]")
275+
errors.append(yaml_file)
276+
277+
if skipped:
278+
console.print(
279+
f"[yellow]{len(skipped)} DAG(s)/file(s) were skipped and NOT generated — "
280+
f"see warnings above. Failing so this isn't silently missed in CI.[/yellow]"
281+
)
282+
if errors or skipped:
283+
raise typer.Exit(1)
284+
285+
187286
if __name__ == "__main__": # pragma: no cover
188287
app()

dagfactory/_yaml.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@
99
from .utils import cast_with_type
1010

1111

12-
def load_yaml_file(file_path: str) -> dict[str, any]:
12+
def load_yaml_file(file_path: str, cast_types: bool = True) -> dict[str, any]:
1313
"""
1414
Load a YAML file into a dictionary.
15+
16+
:param cast_types: Whether `__type__` dicts are instantiated into live Python objects
17+
(the default, used by the runtime DAG loader). Pass False to keep the raw dict shape,
18+
which callers that need to re-emit source code (e.g. `dagfactory generate`) require.
1519
"""
1620

1721
def _flatten_logical_expressions_helper(data):
@@ -47,7 +51,8 @@ def _flatten_logical_expressions(data):
4751
with open(file_path, "r", encoding="utf-8") as fp:
4852
config_with_env = os.path.expandvars(fp.read())
4953
config: dict[str, any] = yaml.load(stream=config_with_env, Loader=yaml.FullLoader)
50-
config = cast_with_type(config)
54+
if cast_types:
55+
config = cast_with_type(config)
5156
config = _flatten_logical_expressions(config)
5257

5358
return config

0 commit comments

Comments
 (0)