From 6f6e88bfa5b20d72c415d29a275b0e357d8752d1 Mon Sep 17 00:00:00 2001 From: Jeremy Beard Date: Mon, 22 Jun 2026 11:51:59 -0400 Subject: [PATCH 1/3] Rename build_all_dags to build_all_airflow_dags for safe-mode discovery Airflow's DAG file processor runs in safe mode by default and only treats a file as a potential DAG file when its contents contain BOTH the 'airflow' and 'dag' substrings (airflow.utils.file.might_contain_dag). The previous entry point build_all_dags carries 'dag' but not 'airflow', so the minimal one-line loader from blueprint import build_all_dags build_all_dags() is silently skipped by Airflow -- no error, just missing DAGs. Loaders only worked when something else (e.g. 'from airflow import DAG') happened to drag the 'airflow' substring into the file. Rename the entry point to build_all_airflow_dags so the import line itself carries both required substrings. build_all_dags and build_all remain as deprecated aliases that emit DeprecationWarning and forward. Adds tests/integration/test_safe_mode_discovery.py: an independent test that writes the bare-minimum loader (import + call, nothing else) and asserts Airflow's real safe-mode scanner discovers it -- and that the pre-rename loader is skipped, documenting the regression this fixes. --- README.md | 8 +-- blueprint/__init__.py | 11 ++- blueprint/builder.py | 62 ++++++++++++---- blueprint/cli.py | 4 +- examples/advanced/README.md | 2 +- examples/advanced/dags/loader.py | 4 +- examples/simple/README.md | 2 +- examples/simple/dags/loader.py | 4 +- tests/integration/project/dags/loader.py | 4 +- tests/integration/test_safe_mode_discovery.py | 56 +++++++++++++++ tests/test_builder.py | 72 ++++++++++++++----- 11 files changed, 184 insertions(+), 45 deletions(-) create mode 100644 tests/integration/test_safe_mode_discovery.py diff --git a/README.md b/README.md index 96b769a..2c2d872 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,9 @@ The `blueprint:` value is the snake_case form of the class name. `Extract` becom ```python # dags/loader.py -from blueprint import build_all_dags +from blueprint import build_all_airflow_dags -build_all_dags() +build_all_airflow_dags() ``` ### 4. Validate @@ -452,13 +452,13 @@ The `on_dag_built` callback lets you modify each DAG after it's built from YAML. # dags/loader.py from pathlib import Path from airflow import DAG -from blueprint import build_all_dags +from blueprint import build_all_airflow_dags def post_process(dag: DAG, yaml_path: Path) -> None: dag.tags = [*(dag.tags or []), "managed-by-blueprint"] dag.access_control = {"data-team": {"can_read", "can_edit"}} -build_all_dags(on_dag_built=post_process) +build_all_airflow_dags(on_dag_built=post_process) ``` This is useful for applying cross-cutting concerns like access controls, tags, or custom metadata that shouldn't live in individual YAML files. The callback runs once per DAG, after all steps are wired up. diff --git a/blueprint/__init__.py b/blueprint/__init__.py index 6c54517..39635f6 100644 --- a/blueprint/__init__.py +++ b/blueprint/__init__.py @@ -2,7 +2,15 @@ __version__ = "0.3.0" -from .builder import Builder, DAGConfig, OnDagBuilt, StepConfig, build_all, build_all_dags +from .builder import ( + Builder, + DAGConfig, + OnDagBuilt, + StepConfig, + build_all, + build_all_airflow_dags, + build_all_dags, +) from .core import Blueprint, BlueprintDagArgs, DefaultDagArgs, TaskOrGroup from .errors import ( BlueprintError, @@ -73,6 +81,7 @@ "ValidationError", "YAMLParseError", "build_all", + "build_all_airflow_dags", "build_all_dags", "discover_blueprints", "field_validator", diff --git a/blueprint/builder.py b/blueprint/builder.py index 2d79dcc..f2abe88 100644 --- a/blueprint/builder.py +++ b/blueprint/builder.py @@ -495,7 +495,7 @@ def _check_duplicate_dag_id(dag_id: str, yaml_path: Path, dag_id_to_file: dict[s raise DuplicateDAGIdError(dag_id, [dag_id_to_file[dag_id], yaml_path]) -def build_all_dags( +def build_all_airflow_dags( search_path: str | Path | None = None, register_globals: dict | None = None, pattern: str = "*.dag.yaml", @@ -509,6 +509,11 @@ def build_all_dags( This is the top-level convenience function meant to be called from a DAG loader file (e.g., loader.py in your dags/ directory). + The name intentionally contains both ``airflow`` and ``dag`` so that a + one-line loader of ``from blueprint import build_all_airflow_dags; + build_all_airflow_dags()`` satisfies Airflow's safe-mode DAG file scanner, + which only considers a file if its contents contain both substrings. + Args: search_path: Directory to search for YAML files. Defaults to dags/ or the directory containing the calling file. @@ -529,9 +534,9 @@ def build_all_dags( Example: ```python # In dags/loader.py - from blueprint import build_all_dags + from blueprint import build_all_airflow_dags - build_all_dags() + build_all_airflow_dags() ``` """ from blueprint.loaders import render_yaml_template @@ -595,7 +600,7 @@ def build_all_dags( return dags -def build_all( +def build_all_dags( search_path: str | Path | None = None, register_globals: dict | None = None, pattern: str = "*.dag.yaml", @@ -604,22 +609,55 @@ def build_all( bp_registry: BlueprintRegistry | None = None, on_dag_built: OnDagBuilt | None = None, ) -> list["DAG"]: - """Deprecated alias for ``build_all_dags``. + """Deprecated alias for ``build_all_airflow_dags``. - Renamed so a one-line loader ``from blueprint import build_all_dags; - build_all_dags()`` carries the substring ``dag`` and satisfies Airflow's - safe-mode DAG file scanner. + A loader of ``from blueprint import build_all_dags; build_all_dags()`` + carries the substring ``dag`` but not ``airflow``, so Airflow's safe-mode + scanner skips the file. Use ``build_all_airflow_dags`` instead, whose name + carries both required substrings. """ + warnings.warn( + "blueprint.build_all_dags is deprecated and will be removed in a " + "future release; use blueprint.build_all_airflow_dags instead. Its " + "name carries both 'airflow' and 'dag' so a one-line loader satisfies " + "Airflow's safe-mode DAG file scanner.", + DeprecationWarning, + stacklevel=2, + ) + if register_globals is None: + frame = inspect.currentframe() + register_globals = frame.f_back.f_globals if frame and frame.f_back else {} + return build_all_airflow_dags( + search_path=search_path, + register_globals=register_globals, + pattern=pattern, + render_templates=render_templates, + template_context=template_context, + bp_registry=bp_registry, + on_dag_built=on_dag_built, + ) + + +def build_all( + search_path: str | Path | None = None, + register_globals: dict | None = None, + pattern: str = "*.dag.yaml", + render_templates: bool = True, + template_context: dict[str, Any] | None = None, + bp_registry: BlueprintRegistry | None = None, + on_dag_built: OnDagBuilt | None = None, +) -> list["DAG"]: + """Deprecated alias for ``build_all_airflow_dags``.""" warnings.warn( "blueprint.build_all is deprecated and will be removed in a future " - "release; use blueprint.build_all_dags instead.", + "release; use blueprint.build_all_airflow_dags instead.", DeprecationWarning, stacklevel=2, ) if register_globals is None: frame = inspect.currentframe() register_globals = frame.f_back.f_globals if frame and frame.f_back else {} - return build_all_dags( + return build_all_airflow_dags( search_path=search_path, register_globals=register_globals, pattern=pattern, @@ -631,7 +669,7 @@ def build_all( def _get_caller_file() -> str | None: - """Return the __file__ of the module that called build_all_dags(). + """Return the __file__ of the module that called build_all_airflow_dags(). Walks the call stack to find the first frame outside of the blueprint package, making this resilient to internal helper wrappers. @@ -657,7 +695,7 @@ def _resolve_search_path(search_path: str | Path | None) -> Path: Resolution order: 1. Explicit search_path argument - 2. Directory of the file that called build_all_dags() + 2. Directory of the file that called build_all_airflow_dags() 3. Current working directory """ if search_path is not None: diff --git a/blueprint/cli.py b/blueprint/cli.py index 576b6bd..ebe33d1 100644 --- a/blueprint/cli.py +++ b/blueprint/cli.py @@ -502,8 +502,8 @@ def new(template_dir: str | None, output_dir: str): console.print(f"\n[green]Created {file_path}[/green]") console.print("\nTo load this DAG, add a loader.py to your dags/ directory:") - console.print(" from blueprint import build_all_dags") - console.print(" build_all_dags()") + console.print(" from blueprint import build_all_airflow_dags") + console.print(" build_all_airflow_dags()") def main(): diff --git a/examples/advanced/README.md b/examples/advanced/README.md index e4a0a37..87e2603 100644 --- a/examples/advanced/README.md +++ b/examples/advanced/README.md @@ -44,7 +44,7 @@ Custom `BlueprintDagArgs` subclass that converts a `priority` field into a DAG t ### Loader (`dags/loader.py`) -`build_all()` with `on_dag_built` callback and `template_context`. +`build_all_airflow_dags()` with `on_dag_built` callback and `template_context`. ### Programmatic Building (`dags/programmatic_dags.py`) diff --git a/examples/advanced/dags/loader.py b/examples/advanced/dags/loader.py index 4eea698..f47f012 100644 --- a/examples/advanced/dags/loader.py +++ b/examples/advanced/dags/loader.py @@ -2,7 +2,7 @@ from airflow.models import DAG -from blueprint import build_all_dags +from blueprint import build_all_airflow_dags def add_mission_tags(dag: DAG, config_path: Path) -> None: @@ -10,7 +10,7 @@ def add_mission_tags(dag: DAG, config_path: Path) -> None: dag.tags = [*(dag.tags or []), f"source:{config_path.stem}"] -build_all_dags( +build_all_airflow_dags( on_dag_built=add_mission_tags, template_context={"agency": "Deep Space Network"}, ) diff --git a/examples/simple/README.md b/examples/simple/README.md index d0e549a..d8871cb 100644 --- a/examples/simple/README.md +++ b/examples/simple/README.md @@ -1,6 +1,6 @@ # Simple Example -One DAG with two concurrent extract steps followed by a load step. Demonstrates the basics: defining blueprints, composing them via YAML, and loading with `build_all()`. +One DAG with two concurrent extract steps followed by a load step. Demonstrates the basics: defining blueprints, composing them via YAML, and loading with `build_all_airflow_dags()`. ## Quick Start diff --git a/examples/simple/dags/loader.py b/examples/simple/dags/loader.py index 70efe91..8735aef 100644 --- a/examples/simple/dags/loader.py +++ b/examples/simple/dags/loader.py @@ -1,3 +1,3 @@ -from blueprint import build_all_dags +from blueprint import build_all_airflow_dags -build_all_dags() +build_all_airflow_dags() diff --git a/tests/integration/project/dags/loader.py b/tests/integration/project/dags/loader.py index 8b919ca..038cb78 100644 --- a/tests/integration/project/dags/loader.py +++ b/tests/integration/project/dags/loader.py @@ -2,7 +2,7 @@ from airflow import DAG -from blueprint import build_all_dags +from blueprint import build_all_airflow_dags def post_process(dag: DAG, yaml_path: Path) -> None: @@ -10,4 +10,4 @@ def post_process(dag: DAG, yaml_path: Path) -> None: dag.tags = [*(dag.tags or []), "callback-verified"] -build_all_dags(on_dag_built=post_process) +build_all_airflow_dags(on_dag_built=post_process) diff --git a/tests/integration/test_safe_mode_discovery.py b/tests/integration/test_safe_mode_discovery.py new file mode 100644 index 0000000..707dedf --- /dev/null +++ b/tests/integration/test_safe_mode_discovery.py @@ -0,0 +1,56 @@ +"""Tier 0: Airflow safe-mode DAG discovery. + +Airflow's DAG file processor runs in *safe mode* by default: it only treats a +file as a potential DAG file if its raw contents contain both the ``airflow`` +and ``dag`` substrings. A blueprint loader is otherwise a plain function call, +so the entry-point name has to carry both substrings on its own -- otherwise a +minimal one-line loader is silently skipped and no DAGs appear. + +This exercises Airflow's *real* discovery heuristic +(``airflow.utils.file.might_contain_dag``), so it does not need a running +Airflow instance. It is the regression guard for the +``build_all_dags`` -> ``build_all_airflow_dags`` rename: the test writes the +bare-minimum loader (import + call, nothing else) and asserts Airflow would +pick it up. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from airflow.utils.file import might_contain_dag + +pytestmark = pytest.mark.integration + +# The bare-minimum loader: no `from airflow import DAG`, no docstring, nothing +# but the public entry point. This is exactly what `blueprint new` scaffolds +# and what the docs tell users to write. +BARE_MINIMUM_LOADER = "from blueprint import build_all_airflow_dags\nbuild_all_airflow_dags()\n" + +# The pre-rename equivalent, kept to document *why* the rename was necessary. +LEGACY_LOADER = "from blueprint import build_all_dags\nbuild_all_dags()\n" + + +def _write(tmp_path: Path, content: str) -> str: + loader = tmp_path / "loader.py" + loader.write_text(content) + return str(loader) + + +def test_bare_minimum_loader_is_discovered_by_safe_mode(tmp_path: Path): + """The minimal `build_all_airflow_dags` loader satisfies Airflow's scanner.""" + loader = _write(tmp_path, BARE_MINIMUM_LOADER) + assert might_contain_dag(loader, safe_mode=True), ( + "Airflow's safe-mode scanner skipped the bare-minimum loader; the entry " + "point name must contain both 'airflow' and 'dag'." + ) + + +def test_legacy_loader_is_skipped_by_safe_mode(tmp_path: Path): + """The old `build_all_dags` loader is skipped -- the regression we fixed.""" + loader = _write(tmp_path, LEGACY_LOADER) + assert not might_contain_dag(loader, safe_mode=True), ( + "Expected the pre-rename loader to be skipped (it lacks the 'airflow' " + "substring); if this now passes, safe-mode behaviour changed." + ) diff --git a/tests/test_builder.py b/tests/test_builder.py index 5f30f33..e26519d 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -1,4 +1,4 @@ -"""Tests for the DAG builder: DAGConfig, StepConfig, Builder, build_all_dags.""" +"""Tests for the DAG builder: DAGConfig, StepConfig, Builder, build_all_airflow_dags.""" from pathlib import Path from typing import Literal @@ -738,7 +738,7 @@ def test_resolve_explicit_path(self): class TestBuildAll: def test_build_all_discovers_yamls(self, tmp_path): - from blueprint.builder import build_all_dags + from blueprint.builder import build_all_airflow_dags bp_file = tmp_path / "blueprints.py" bp_file.write_text(""" @@ -763,7 +763,7 @@ def render(self, config): """) globals_dict = {} - dags = build_all_dags( + dags = build_all_airflow_dags( search_path=tmp_path, register_globals=globals_dict, render_templates=False, @@ -798,7 +798,7 @@ def render(self, config): """) globals_dict: dict = {} - with pytest.warns(DeprecationWarning, match="build_all_dags"): + with pytest.warns(DeprecationWarning, match="build_all_airflow_dags"): dags = build_all( search_path=tmp_path, register_globals=globals_dict, @@ -808,9 +808,45 @@ def render(self, config): assert dags[0].dag_id == "deprecated_alias_test" assert "deprecated_alias_test" in globals_dict - def test_build_all_with_custom_dag_args(self, tmp_path): + def test_build_all_dags_deprecated_alias_warns_and_forwards(self, tmp_path): from blueprint.builder import build_all_dags + bp_file = tmp_path / "blueprints.py" + bp_file.write_text(""" +from pydantic import BaseModel +from blueprint.core import Blueprint + +class ProcConfig(BaseModel): + cmd: str = "echo hello" + +class Proc(Blueprint[ProcConfig]): + def render(self, config): + from airflow.operators.bash import BashOperator + return BashOperator(task_id=self.step_id, bash_command=config.cmd) +""") + + yaml_file = tmp_path / "pipeline.dag.yaml" + yaml_file.write_text(""" +dag_id: deprecated_dags_alias_test +steps: + step1: + blueprint: proc +""") + + globals_dict: dict = {} + with pytest.warns(DeprecationWarning, match="build_all_airflow_dags"): + dags = build_all_dags( + search_path=tmp_path, + register_globals=globals_dict, + render_templates=False, + ) + assert len(dags) == 1 + assert dags[0].dag_id == "deprecated_dags_alias_test" + assert "deprecated_dags_alias_test" in globals_dict + + def test_build_all_with_custom_dag_args(self, tmp_path): + from blueprint.builder import build_all_airflow_dags + bp_file = tmp_path / "blueprints.py" bp_file.write_text(""" from typing import Any @@ -848,7 +884,7 @@ def render(self, config: MyDagArgsConfig) -> dict[str, Any]: """) globals_dict = {} - dags = build_all_dags( + dags = build_all_airflow_dags( search_path=tmp_path, register_globals=globals_dict, render_templates=False, @@ -858,14 +894,14 @@ def render(self, config: MyDagArgsConfig) -> dict[str, Any]: assert dags[0].default_args["owner"] == "analytics" def test_build_all_no_yamls(self, tmp_path): - from blueprint.builder import build_all_dags + from blueprint.builder import build_all_airflow_dags globals_dict = {} - dags = build_all_dags(search_path=tmp_path, register_globals=globals_dict) + dags = build_all_airflow_dags(search_path=tmp_path, register_globals=globals_dict) assert dags == [] def test_build_all_duplicate_dag_id(self, tmp_path): - from blueprint.builder import build_all_dags + from blueprint.builder import build_all_airflow_dags from blueprint.errors import DuplicateDAGIdError bp_file = tmp_path / "blueprints.py" @@ -890,14 +926,14 @@ def render(self, config): globals_dict = {} with pytest.raises(DuplicateDAGIdError, match="same_id"): - build_all_dags( + build_all_airflow_dags( search_path=tmp_path, register_globals=globals_dict, render_templates=False, ) def test_build_all_duplicate_dag_id_only_first_registered(self, tmp_path): - from blueprint.builder import build_all_dags + from blueprint.builder import build_all_airflow_dags from blueprint.errors import DuplicateDAGIdError bp_file = tmp_path / "blueprints.py" @@ -922,7 +958,7 @@ def render(self, config): globals_dict = {} with pytest.raises(DuplicateDAGIdError): - build_all_dags( + build_all_airflow_dags( search_path=tmp_path, register_globals=globals_dict, render_templates=False, @@ -930,7 +966,7 @@ def render(self, config): assert len(globals_dict) <= 1 def test_build_all_raises_on_error(self, tmp_path): - from blueprint.builder import build_all_dags + from blueprint.builder import build_all_airflow_dags from blueprint.errors import BlueprintNotFoundError bp_file = tmp_path / "blueprints.py" @@ -952,7 +988,7 @@ def render(self, config): globals_dict = {} with pytest.raises(BlueprintNotFoundError): - build_all_dags( + build_all_airflow_dags( search_path=tmp_path, register_globals=globals_dict, render_templates=False, @@ -961,7 +997,7 @@ def render(self, config): class TestOnDagBuilt: def test_build_all_on_dag_built_called(self, tmp_path): - from blueprint.builder import build_all_dags + from blueprint.builder import build_all_airflow_dags bp_file = tmp_path / "blueprints.py" bp_file.write_text(""" @@ -986,7 +1022,7 @@ def callback(dag, yaml_path): calls.append((dag.dag_id, yaml_path)) globals_dict = {} - build_all_dags( + build_all_airflow_dags( search_path=tmp_path, register_globals=globals_dict, render_templates=False, @@ -997,7 +1033,7 @@ def callback(dag, yaml_path): assert calls[0][1] == yaml_file def test_build_all_on_dag_built_mutates_dag(self, tmp_path): - from blueprint.builder import build_all_dags + from blueprint.builder import build_all_airflow_dags bp_file = tmp_path / "blueprints.py" bp_file.write_text(""" @@ -1020,7 +1056,7 @@ def add_tag(dag, _yaml_path): dag.tags = [*(dag.tags or []), "post-processed"] globals_dict = {} - dags = build_all_dags( + dags = build_all_airflow_dags( search_path=tmp_path, register_globals=globals_dict, render_templates=False, From ea79e27185af508e16d6daa4de45bb245a0e088c Mon Sep 17 00:00:00 2001 From: Jeremy Beard Date: Mon, 22 Jun 2026 12:19:11 -0400 Subject: [PATCH 2/3] Prove safe-mode discovery against the live Airflow instance Address review: the safe-mode test no longer calls Airflow's might_contain_dag helper directly. Instead the project ships a real bare-minimum loader at dags/safe_mode_minimal/loader.py (an import + call, deliberately no 'from airflow import DAG') that builds a probe DAG, and the test asserts the running Airflow instance discovered and parsed it via the REST API, with no import errors. The loader is isolated from the project's main loader by a distinct '*.safe.yaml' pattern so the two never build the same dag_id. A small on_dag_built tag callback (no airflow import) satisfies the project's 'every DAG has tags' integrity convention. --- .../dags/safe_mode_minimal/blueprints.py | 19 ++++ .../project/dags/safe_mode_minimal/loader.py | 8 ++ .../dags/safe_mode_minimal/probe.safe.yaml | 5 ++ tests/integration/test_safe_mode_discovery.py | 90 ++++++++++--------- 4 files changed, 80 insertions(+), 42 deletions(-) create mode 100644 tests/integration/project/dags/safe_mode_minimal/blueprints.py create mode 100644 tests/integration/project/dags/safe_mode_minimal/loader.py create mode 100644 tests/integration/project/dags/safe_mode_minimal/probe.safe.yaml diff --git a/tests/integration/project/dags/safe_mode_minimal/blueprints.py b/tests/integration/project/dags/safe_mode_minimal/blueprints.py new file mode 100644 index 0000000..97c666b --- /dev/null +++ b/tests/integration/project/dags/safe_mode_minimal/blueprints.py @@ -0,0 +1,19 @@ +"""Blueprint for the bare-minimum safe-mode discovery probe.""" + +from airflow.operators.bash import BashOperator + +from blueprint import BaseModel, Blueprint + + +class SafeModeProbeConfig(BaseModel): + message: str = "safe-mode probe" + + +class SafeModeProbe(Blueprint[SafeModeProbeConfig]): + """Single-task blueprint used to prove safe-mode discovery end-to-end.""" + + def render(self, config: SafeModeProbeConfig) -> BashOperator: + return BashOperator( + task_id=self.step_id, + bash_command=f"echo '{config.message}'", + ) diff --git a/tests/integration/project/dags/safe_mode_minimal/loader.py b/tests/integration/project/dags/safe_mode_minimal/loader.py new file mode 100644 index 0000000..49d69cb --- /dev/null +++ b/tests/integration/project/dags/safe_mode_minimal/loader.py @@ -0,0 +1,8 @@ +from blueprint import build_all_airflow_dags + + +def _tag(dag, yaml_path): + dag.tags = [*(dag.tags or []), "safe-mode-probe"] + + +build_all_airflow_dags(pattern="*.safe.yaml", on_dag_built=_tag) diff --git a/tests/integration/project/dags/safe_mode_minimal/probe.safe.yaml b/tests/integration/project/dags/safe_mode_minimal/probe.safe.yaml new file mode 100644 index 0000000..978b4c5 --- /dev/null +++ b/tests/integration/project/dags/safe_mode_minimal/probe.safe.yaml @@ -0,0 +1,5 @@ +dag_id: safe_mode_minimal_probe +steps: + probe: + blueprint: safe_mode_probe + message: "discovered via build_all_airflow_dags" diff --git a/tests/integration/test_safe_mode_discovery.py b/tests/integration/test_safe_mode_discovery.py index 707dedf..e8d857f 100644 --- a/tests/integration/test_safe_mode_discovery.py +++ b/tests/integration/test_safe_mode_discovery.py @@ -1,56 +1,62 @@ -"""Tier 0: Airflow safe-mode DAG discovery. +"""Tier 1: Airflow safe-mode DAG discovery. -Airflow's DAG file processor runs in *safe mode* by default: it only treats a -file as a potential DAG file if its raw contents contain both the ``airflow`` -and ``dag`` substrings. A blueprint loader is otherwise a plain function call, +Airflow's DAG file processor runs in *safe mode* by default: it only parses a +file as a potential DAG file when its contents contain both the ``airflow`` +and ``dag`` substrings. A Blueprint loader is otherwise a plain function call, so the entry-point name has to carry both substrings on its own -- otherwise a minimal one-line loader is silently skipped and no DAGs appear. -This exercises Airflow's *real* discovery heuristic -(``airflow.utils.file.might_contain_dag``), so it does not need a running -Airflow instance. It is the regression guard for the -``build_all_dags`` -> ``build_all_airflow_dags`` rename: the test writes the -bare-minimum loader (import + call, nothing else) and asserts Airflow would -pick it up. +This is the end-to-end regression guard for the +``build_all_dags`` -> ``build_all_airflow_dags`` rename. The project ships a +bare-minimum loader at ``dags/safe_mode_minimal/loader.py`` -- an import and a +call, with deliberately no ``from airflow import DAG`` -- and these tests run +against the live Airflow instance to assert that Airflow actually discovered +and parsed the DAG that loader builds. """ from __future__ import annotations -from pathlib import Path +import time +from typing import TYPE_CHECKING import pytest -from airflow.utils.file import might_contain_dag - -pytestmark = pytest.mark.integration - -# The bare-minimum loader: no `from airflow import DAG`, no docstring, nothing -# but the public entry point. This is exactly what `blueprint new` scaffolds -# and what the docs tell users to write. -BARE_MINIMUM_LOADER = "from blueprint import build_all_airflow_dags\nbuild_all_airflow_dags()\n" - -# The pre-rename equivalent, kept to document *why* the rename was necessary. -LEGACY_LOADER = "from blueprint import build_all_dags\nbuild_all_dags()\n" +from .conftest import DAG_PARSE_TIMEOUT, HEALTH_CHECK_INTERVAL -def _write(tmp_path: Path, content: str) -> str: - loader = tmp_path / "loader.py" - loader.write_text(content) - return str(loader) - - -def test_bare_minimum_loader_is_discovered_by_safe_mode(tmp_path: Path): - """The minimal `build_all_airflow_dags` loader satisfies Airflow's scanner.""" - loader = _write(tmp_path, BARE_MINIMUM_LOADER) - assert might_contain_dag(loader, safe_mode=True), ( - "Airflow's safe-mode scanner skipped the bare-minimum loader; the entry " - "point name must contain both 'airflow' and 'dag'." - ) +if TYPE_CHECKING: + from .conftest import AirflowAPI +pytestmark = pytest.mark.integration -def test_legacy_loader_is_skipped_by_safe_mode(tmp_path: Path): - """The old `build_all_dags` loader is skipped -- the regression we fixed.""" - loader = _write(tmp_path, LEGACY_LOADER) - assert not might_contain_dag(loader, safe_mode=True), ( - "Expected the pre-rename loader to be skipped (it lacks the 'airflow' " - "substring); if this now passes, safe-mode behaviour changed." - ) +PROBE_DAG_ID = "safe_mode_minimal_probe" + + +class TestSafeModeDiscovery: + """Verify the bare-minimum loader is discovered by the live Airflow scanner.""" + + def test_bare_minimum_loader_dag_is_parsed(self, api_client: AirflowAPI): + """Airflow parses the no-`import DAG` loader purely via the entry-point name.""" + deadline = time.monotonic() + DAG_PARSE_TIMEOUT + dag_ids: set[str] = set() + while time.monotonic() < deadline: + dag_ids = api_client.get_dag_ids() + if PROBE_DAG_ID in dag_ids: + break + time.sleep(HEALTH_CHECK_INTERVAL) + + assert PROBE_DAG_ID in dag_ids, ( + f"Airflow did not discover '{PROBE_DAG_ID}'. Its loader contains no " + "'from airflow import DAG' — discovery relies solely on " + "build_all_airflow_dags carrying the 'airflow' substring for safe mode." + ) + + def test_no_import_errors_for_minimal_loader(self, api_client: AirflowAPI): + """The minimal loader parses cleanly, with no import error recorded.""" + resp = api_client.get("/importErrors") + assert resp.status_code == 200, resp.text + offending = [ + e + for e in resp.json().get("import_errors", []) + if "safe_mode_minimal" in (e.get("filename") or "") + ] + assert not offending, f"Import errors for the minimal loader: {offending}" From 7a527535fe2bf5345a29ecd9400b297fffe73ea5 Mon Sep 17 00:00:00 2001 From: Jeremy Beard Date: Mon, 22 Jun 2026 12:28:16 -0400 Subject: [PATCH 3/3] Guard frame capture with try/finally in deprecated aliases Match the cleanup pattern used by _get_caller_file(): wrap the inspect.currentframe() capture in build_all_dags and build_all in try/finally with 'del frame' to avoid lingering frame references. --- blueprint/builder.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/blueprint/builder.py b/blueprint/builder.py index f2abe88..3e968c8 100644 --- a/blueprint/builder.py +++ b/blueprint/builder.py @@ -626,7 +626,10 @@ def build_all_dags( ) if register_globals is None: frame = inspect.currentframe() - register_globals = frame.f_back.f_globals if frame and frame.f_back else {} + try: + register_globals = frame.f_back.f_globals if frame and frame.f_back else {} + finally: + del frame return build_all_airflow_dags( search_path=search_path, register_globals=register_globals, @@ -656,7 +659,10 @@ def build_all( ) if register_globals is None: frame = inspect.currentframe() - register_globals = frame.f_back.f_globals if frame and frame.f_back else {} + try: + register_globals = frame.f_back.f_globals if frame and frame.f_back else {} + finally: + del frame return build_all_airflow_dags( search_path=search_path, register_globals=register_globals,