|
10 | 10 |
|
11 | 11 | from dagfactory import __version__ |
12 | 12 | 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 |
13 | 16 | from dagfactory.utils import update_yaml_structure |
14 | 17 |
|
15 | 18 | DESCRIPTION = """ |
@@ -184,5 +187,101 @@ def convert( |
184 | 187 | ) |
185 | 188 |
|
186 | 189 |
|
| 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 | + |
187 | 286 | if __name__ == "__main__": # pragma: no cover |
188 | 287 | app() |
0 commit comments