From c6099ca3507d2824be4ee3390f16390f7f46de7e Mon Sep 17 00:00:00 2001 From: Jeremy Beard Date: Mon, 22 Jun 2026 11:45:16 -0400 Subject: [PATCH 1/4] Show blueprint source path in `blueprint list` Add a Location column to `blueprint list` so users can see which file each blueprint is defined in, rendered relative to the current working directory. The registry already tracked locations but stored them relative to an internal base dir, which is not meaningful at the CLI. Store the resolved absolute path instead and add `display_path()` to render paths relative to cwd (falling back to absolute when outside the tree). Duplicate/multiple DAG-args error messages now use the same cwd-relative rendering. --- blueprint/cli.py | 7 ++++-- blueprint/registry.py | 48 ++++++++++++++++++++++++++++-------------- tests/test_cli.py | 29 +++++++++++++++++++++++++ tests/test_registry.py | 40 ++++++++++++++++++++++++++++++++++- 4 files changed, 105 insertions(+), 19 deletions(-) diff --git a/blueprint/cli.py b/blueprint/cli.py index 576b6bd..7d2a07a 100644 --- a/blueprint/cli.py +++ b/blueprint/cli.py @@ -14,7 +14,7 @@ from rich.table import Table from blueprint.loaders import discover_blueprints, get_blueprint_info, validate_yaml -from blueprint.registry import BlueprintRegistry +from blueprint.registry import BlueprintRegistry, display_path console = Console() @@ -134,11 +134,14 @@ def list_blueprints(template_dir: str | None): table.add_column("Versions", style="green", no_wrap=True) table.add_column("Description", overflow="fold") table.add_column("Class", style="dim", no_wrap=False) + table.add_column("Location", style="dim", overflow="fold") for bp in blueprints: versions_str = ", ".join(str(v) for v in bp["versions"]) desc = bp["description"].split("\n")[0] if bp["description"] else "-" - table.add_row(bp["name"], versions_str, desc, bp["class"]) + location = bp["locations"].get(bp["latest_version"]) + location_str = display_path(location) if location else "-" + table.add_row(bp["name"], versions_str, desc, bp["class"], location_str) console.print(table) diff --git a/blueprint/registry.py b/blueprint/registry.py index bcdcab4..bb85172 100644 --- a/blueprint/registry.py +++ b/blueprint/registry.py @@ -22,6 +22,26 @@ _BLUEPRINT_BASE_NAMES = frozenset({"Blueprint", "BlueprintDagArgs"}) +def display_path(path: str | Path) -> str: + """Render a path relative to the current working directory for display. + + Falls back to the absolute path when it is not located under the working + directory (e.g. a sibling tree or a different drive on Windows). + + Args: + path: Absolute or relative filesystem path. + + Returns: + The path relative to ``Path.cwd()`` when it is below it, otherwise the + absolute path. + """ + resolved = Path(path).resolve() + try: + return str(resolved.relative_to(Path.cwd())) + except ValueError: + return str(resolved) + + def _defines_blueprint_subclass(py_file: Path) -> bool: """Return True if the file's source defines a Blueprint or BlueprintDagArgs subclass. @@ -150,28 +170,25 @@ def _discover_in_directory(self, directory: Path) -> None: and obj is not Blueprint and obj.__module__ == module_name ): - self._register_class(obj, py_file, directory) + self._register_class(obj, py_file) elif ( isinstance(obj, type) and issubclass(obj, BlueprintDagArgs) and obj not in (BlueprintDagArgs, DefaultDagArgs) and obj.__module__ == module_name ): - self._register_dag_args(obj, py_file, directory) + self._register_dag_args(obj, py_file) except (DuplicateBlueprintError, MultipleDagArgsError, ValueError): raise except (ImportError, SyntaxError) as e: logger.warning("Failed to load %s: %s", py_file, e) - def _register_class(self, cls: type[Blueprint], py_file: Path, base_dir: Path) -> None: + def _register_class(self, cls: type[Blueprint], py_file: Path) -> None: """Register a blueprint class with its parsed name and version.""" bp_name, version = cls.parse_name_and_version() - try: - location = str(py_file.relative_to(base_dir.parent.parent)) - except ValueError: - location = str(py_file) + location = str(py_file.resolve()) if bp_name not in self._blueprints: self._blueprints[bp_name] = {} @@ -180,21 +197,20 @@ def _register_class(self, cls: type[Blueprint], py_file: Path, base_dir: Path) - if version in self._blueprints[bp_name]: existing_loc = self._blueprint_locations[bp_name][version] dup_name = f"{bp_name} (v{version})" - raise DuplicateBlueprintError(dup_name, [existing_loc, location]) + raise DuplicateBlueprintError( + dup_name, [display_path(existing_loc), display_path(location)] + ) self._blueprints[bp_name][version] = cls self._blueprint_locations[bp_name][version] = location - def _register_dag_args( - self, cls: type[BlueprintDagArgs], py_file: Path, base_dir: Path - ) -> None: - try: - location = str(py_file.relative_to(base_dir.parent.parent)) - except ValueError: - location = str(py_file) + def _register_dag_args(self, cls: type[BlueprintDagArgs], py_file: Path) -> None: + location = str(py_file.resolve()) if self._dag_args is not None: - raise MultipleDagArgsError([self._dag_args_location or "", location]) + raise MultipleDagArgsError( + [display_path(self._dag_args_location or ""), display_path(location)] + ) self._dag_args = cls self._dag_args_location = location diff --git a/tests/test_cli.py b/tests/test_cli.py index 48ac490..2adbc76 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ """Tests for the Blueprint CLI.""" import json +from pathlib import Path from click.testing import CliRunner @@ -80,6 +81,34 @@ def render(self, config): assert "1" in result.output assert "2" in result.output + def test_list_shows_location(self): + runner = CliRunner() + with runner.isolated_filesystem(): + template_dir = Path("dags") + template_dir.mkdir() + (template_dir / "bp.py").write_text(""" +from pydantic import BaseModel +from blueprint.core import Blueprint + +class FooConfig(BaseModel): + x: int = 1 + +class Foo(Blueprint[FooConfig]): + '''Foo blueprint.''' + def render(self, config): + pass +""") + + result = runner.invoke( + cli, + ["list", "--template-dir", "dags"], + env={"COLUMNS": "200"}, + ) + + assert result.exit_code == 0 + assert "Location" in result.output + assert "dags/bp.py" in result.output + def test_describe_command(self, tmp_path): template_dir = tmp_path / "dags" template_dir.mkdir() diff --git a/tests/test_registry.py b/tests/test_registry.py index a952154..8f130ec 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -1,5 +1,7 @@ """Tests for the version-aware Blueprint registry.""" +from pathlib import Path + import pytest from pydantic import BaseModel @@ -10,7 +12,7 @@ MultipleDagArgsError, NonContiguousVersionError, ) -from blueprint.registry import BlueprintRegistry, _defines_blueprint_subclass +from blueprint.registry import BlueprintRegistry, _defines_blueprint_subclass, display_path class SimpleConfig(BaseModel): @@ -673,3 +675,39 @@ def test_blueprint_file_is_still_executed(self, tmp_path): bp_names = [bp["name"] for bp in reg.list_blueprints()] assert "etl" in bp_names + + +class TestDisplayPath: + """Test the cwd-relative path renderer used for blueprint locations.""" + + def test_path_under_cwd_is_relative(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + nested = tmp_path / "dags" / "bp.py" + assert display_path(nested) == "dags/bp.py" + + def test_path_outside_cwd_is_absolute(self, tmp_path, monkeypatch): + cwd = tmp_path / "project" + cwd.mkdir() + monkeypatch.chdir(cwd) + sibling = (tmp_path / "elsewhere" / "bp.py").resolve() + assert display_path(sibling) == str(sibling) + + def test_list_blueprints_reports_absolute_location(self, tmp_path): + template_dir = tmp_path / "dags" + template_dir.mkdir() + (template_dir / "etl.py").write_text( + "from pydantic import BaseModel\n" + "from blueprint.core import Blueprint\n" + "class Cfg(BaseModel):\n" + " x: str = 'a'\n" + "class Etl(Blueprint[Cfg]):\n" + " def render(self, config):\n" + " pass\n" + ) + + reg = BlueprintRegistry(template_dirs=[template_dir]) + reg.discover(force=True) + + location = reg.list_blueprints()[0]["locations"][1] + assert Path(location).is_absolute() + assert Path(location) == (template_dir / "etl.py").resolve() From 9e6fd0f244b75e743c160a223a5fe5e4ae030f06 Mon Sep 17 00:00:00 2001 From: Jeremy Beard Date: Mon, 22 Jun 2026 11:58:14 -0400 Subject: [PATCH 2/4] Address review: lazy error rendering, move display_path to utils - Move display_path to a neutral blueprint/utils.py so the registry no longer owns a display concern and errors.py can reuse it without a cycle. - Render DuplicateBlueprintError / MultipleDagArgsError lazily in __str__, so locations are resolved relative to cwd at display time rather than at raise time (avoids stale hints if cwd changes between discovery and print). - Capture cwd once in 'blueprint list' and pass it as display_path(base=...). - Make the CLI location assertion OS-separator agnostic. - Add a docstring to _register_dag_args; split display_path tests into tests/test_utils.py and keep the absolute-location test in test_registry. --- blueprint/cli.py | 6 ++++-- blueprint/errors.py | 20 +++++++++++++------- blueprint/registry.py | 29 +++-------------------------- blueprint/utils.py | 26 ++++++++++++++++++++++++++ tests/test_cli.py | 4 +++- tests/test_registry.py | 18 +++--------------- tests/test_utils.py | 26 ++++++++++++++++++++++++++ 7 files changed, 78 insertions(+), 51 deletions(-) create mode 100644 blueprint/utils.py create mode 100644 tests/test_utils.py diff --git a/blueprint/cli.py b/blueprint/cli.py index 7d2a07a..12049c4 100644 --- a/blueprint/cli.py +++ b/blueprint/cli.py @@ -14,7 +14,8 @@ from rich.table import Table from blueprint.loaders import discover_blueprints, get_blueprint_info, validate_yaml -from blueprint.registry import BlueprintRegistry, display_path +from blueprint.registry import BlueprintRegistry +from blueprint.utils import display_path console = Console() @@ -136,11 +137,12 @@ def list_blueprints(template_dir: str | None): table.add_column("Class", style="dim", no_wrap=False) table.add_column("Location", style="dim", overflow="fold") + cwd = Path.cwd() for bp in blueprints: versions_str = ", ".join(str(v) for v in bp["versions"]) desc = bp["description"].split("\n")[0] if bp["description"] else "-" location = bp["locations"].get(bp["latest_version"]) - location_str = display_path(location) if location else "-" + location_str = display_path(location, base=cwd) if location else "-" table.add_row(bp["name"], versions_str, desc, bp["class"], location_str) console.print(table) diff --git a/blueprint/errors.py b/blueprint/errors.py index d86f7f2..4a80d77 100644 --- a/blueprint/errors.py +++ b/blueprint/errors.py @@ -6,6 +6,8 @@ import yaml +from blueprint.utils import display_path + # Constants MAX_SUGGESTION_VALUES = 10 @@ -207,16 +209,18 @@ class DuplicateBlueprintError(BlueprintError): def __init__(self, blueprint_name: str, locations: list[str]): self.blueprint_name = blueprint_name self.locations = locations + super().__init__() - message = f"Duplicate blueprint name '{blueprint_name}' found in multiple locations:" - for loc in locations: - message += f"\n • {loc}" + def __str__(self) -> str: + message = f"Duplicate blueprint name '{self.blueprint_name}' found in multiple locations:" + for loc in self.locations: + message += f"\n • {display_path(loc)}" message += "\n\n💡 Suggestions:" message += "\n • Rename one of the blueprint classes" message += "\n • Use unique names for each blueprint" - super().__init__(message) + return message class DuplicateDAGIdError(BlueprintError): @@ -276,15 +280,17 @@ class MultipleDagArgsError(BlueprintError): def __init__(self, locations: list[str]): self.locations = locations + super().__init__() + def __str__(self) -> str: message = "Multiple BlueprintDagArgs templates found. Only one is allowed per project:" - for loc in locations: - message += f"\n • {loc}" + for loc in self.locations: + message += f"\n • {display_path(loc) if loc else loc}" message += "\n\n💡 Suggestions:" message += "\n • Remove all but one BlueprintDagArgs subclass" - super().__init__(message) + return message class InvalidVersionError(BlueprintError): diff --git a/blueprint/registry.py b/blueprint/registry.py index bb85172..a4e65db 100644 --- a/blueprint/registry.py +++ b/blueprint/registry.py @@ -22,26 +22,6 @@ _BLUEPRINT_BASE_NAMES = frozenset({"Blueprint", "BlueprintDagArgs"}) -def display_path(path: str | Path) -> str: - """Render a path relative to the current working directory for display. - - Falls back to the absolute path when it is not located under the working - directory (e.g. a sibling tree or a different drive on Windows). - - Args: - path: Absolute or relative filesystem path. - - Returns: - The path relative to ``Path.cwd()`` when it is below it, otherwise the - absolute path. - """ - resolved = Path(path).resolve() - try: - return str(resolved.relative_to(Path.cwd())) - except ValueError: - return str(resolved) - - def _defines_blueprint_subclass(py_file: Path) -> bool: """Return True if the file's source defines a Blueprint or BlueprintDagArgs subclass. @@ -197,20 +177,17 @@ def _register_class(self, cls: type[Blueprint], py_file: Path) -> None: if version in self._blueprints[bp_name]: existing_loc = self._blueprint_locations[bp_name][version] dup_name = f"{bp_name} (v{version})" - raise DuplicateBlueprintError( - dup_name, [display_path(existing_loc), display_path(location)] - ) + raise DuplicateBlueprintError(dup_name, [existing_loc, location]) self._blueprints[bp_name][version] = cls self._blueprint_locations[bp_name][version] = location def _register_dag_args(self, cls: type[BlueprintDagArgs], py_file: Path) -> None: + """Register the single BlueprintDagArgs template, tracking its location.""" location = str(py_file.resolve()) if self._dag_args is not None: - raise MultipleDagArgsError( - [display_path(self._dag_args_location or ""), display_path(location)] - ) + raise MultipleDagArgsError([self._dag_args_location or "", location]) self._dag_args = cls self._dag_args_location = location diff --git a/blueprint/utils.py b/blueprint/utils.py new file mode 100644 index 0000000..66b9e30 --- /dev/null +++ b/blueprint/utils.py @@ -0,0 +1,26 @@ +"""Common utilities shared across the blueprint package.""" + +from pathlib import Path + + +def display_path(path: str | Path, base: Path | None = None) -> str: + """Render a path relative to a base directory for display. + + Falls back to the absolute path when it is not located under the base + directory (e.g. a sibling tree or a different drive on Windows). + + Args: + path: Absolute or relative filesystem path. + base: Directory to render relative to. Defaults to the current working + directory, resolved at call time. + + Returns: + The path relative to ``base`` when it is below it, otherwise the + absolute path. + """ + base = (base or Path.cwd()).resolve() + resolved = Path(path).resolve() + try: + return str(resolved.relative_to(base)) + except ValueError: + return str(resolved) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2adbc76..396c559 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -107,7 +107,9 @@ def render(self, config): assert result.exit_code == 0 assert "Location" in result.output - assert "dags/bp.py" in result.output + # display_path renders with the OS separator, so build the expected + # path the same way rather than hard-coding a POSIX separator. + assert str(Path("dags") / "bp.py") in result.output def test_describe_command(self, tmp_path): template_dir = tmp_path / "dags" diff --git a/tests/test_registry.py b/tests/test_registry.py index 8f130ec..83e58dd 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -12,7 +12,7 @@ MultipleDagArgsError, NonContiguousVersionError, ) -from blueprint.registry import BlueprintRegistry, _defines_blueprint_subclass, display_path +from blueprint.registry import BlueprintRegistry, _defines_blueprint_subclass class SimpleConfig(BaseModel): @@ -677,20 +677,8 @@ def test_blueprint_file_is_still_executed(self, tmp_path): assert "etl" in bp_names -class TestDisplayPath: - """Test the cwd-relative path renderer used for blueprint locations.""" - - def test_path_under_cwd_is_relative(self, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - nested = tmp_path / "dags" / "bp.py" - assert display_path(nested) == "dags/bp.py" - - def test_path_outside_cwd_is_absolute(self, tmp_path, monkeypatch): - cwd = tmp_path / "project" - cwd.mkdir() - monkeypatch.chdir(cwd) - sibling = (tmp_path / "elsewhere" / "bp.py").resolve() - assert display_path(sibling) == str(sibling) +class TestBlueprintLocations: + """Test how the registry tracks and reports blueprint source locations.""" def test_list_blueprints_reports_absolute_location(self, tmp_path): template_dir = tmp_path / "dags" diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..0dd5654 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,26 @@ +"""Tests for shared blueprint utilities.""" + +from blueprint.utils import display_path + + +class TestDisplayPath: + """Test the cwd-relative path renderer used for blueprint locations.""" + + def test_path_under_cwd_is_relative(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + nested = tmp_path / "dags" / "bp.py" + assert display_path(nested) == "dags/bp.py" + + def test_path_outside_cwd_is_absolute(self, tmp_path, monkeypatch): + cwd = tmp_path / "project" + cwd.mkdir() + monkeypatch.chdir(cwd) + sibling = (tmp_path / "elsewhere" / "bp.py").resolve() + assert display_path(sibling) == str(sibling) + + def test_explicit_base_overrides_cwd(self, tmp_path, monkeypatch): + other = tmp_path / "other" + other.mkdir() + monkeypatch.chdir(other) + nested = tmp_path / "dags" / "bp.py" + assert display_path(nested, base=tmp_path) == "dags/bp.py" From 3a889eda40b88201b2f4a583bdaca34610c0ad12 Mon Sep 17 00:00:00 2001 From: Jeremy Beard Date: Mon, 22 Jun 2026 12:13:34 -0400 Subject: [PATCH 3/4] Address second review pass: portable test paths, picklable errors - tests/test_utils.py: build expected paths via Path() instead of POSIX literals, and ground the under-cwd test on a real file. - errors.py: pass raw constructor args to super().__init__ so repr() stays informative and the exceptions pickle correctly (round-trips reconstruct via __init__); __str__ still renders cwd-relative paths lazily. - utils.display_path: return falsy input unchanged instead of '.'. - MultipleDagArgsError: skip empty locations rather than rendering an empty bullet. --- blueprint/errors.py | 10 +++++++--- blueprint/utils.py | 4 +++- tests/test_utils.py | 8 ++++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/blueprint/errors.py b/blueprint/errors.py index 4a80d77..526304a 100644 --- a/blueprint/errors.py +++ b/blueprint/errors.py @@ -209,7 +209,9 @@ class DuplicateBlueprintError(BlueprintError): def __init__(self, blueprint_name: str, locations: list[str]): self.blueprint_name = blueprint_name self.locations = locations - super().__init__() + # Pass raw args (not the rendered message) so repr() stays informative + # while __str__ renders cwd-relative paths lazily at display time. + super().__init__(blueprint_name, locations) def __str__(self) -> str: message = f"Duplicate blueprint name '{self.blueprint_name}' found in multiple locations:" @@ -280,12 +282,14 @@ class MultipleDagArgsError(BlueprintError): def __init__(self, locations: list[str]): self.locations = locations - super().__init__() + super().__init__(locations) def __str__(self) -> str: message = "Multiple BlueprintDagArgs templates found. Only one is allowed per project:" for loc in self.locations: - message += f"\n • {display_path(loc) if loc else loc}" + if not loc: + continue + message += f"\n • {display_path(loc)}" message += "\n\n💡 Suggestions:" message += "\n • Remove all but one BlueprintDagArgs subclass" diff --git a/blueprint/utils.py b/blueprint/utils.py index 66b9e30..810adc0 100644 --- a/blueprint/utils.py +++ b/blueprint/utils.py @@ -16,8 +16,10 @@ def display_path(path: str | Path, base: Path | None = None) -> str: Returns: The path relative to ``base`` when it is below it, otherwise the - absolute path. + absolute path. A falsy ``path`` is returned unchanged. """ + if not path: + return str(path) base = (base or Path.cwd()).resolve() resolved = Path(path).resolve() try: diff --git a/tests/test_utils.py b/tests/test_utils.py index 0dd5654..95d92b7 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,7 @@ """Tests for shared blueprint utilities.""" +from pathlib import Path + from blueprint.utils import display_path @@ -9,7 +11,9 @@ class TestDisplayPath: def test_path_under_cwd_is_relative(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) nested = tmp_path / "dags" / "bp.py" - assert display_path(nested) == "dags/bp.py" + nested.parent.mkdir() + nested.write_text("") + assert display_path(nested) == str(Path("dags") / "bp.py") def test_path_outside_cwd_is_absolute(self, tmp_path, monkeypatch): cwd = tmp_path / "project" @@ -23,4 +27,4 @@ def test_explicit_base_overrides_cwd(self, tmp_path, monkeypatch): other.mkdir() monkeypatch.chdir(other) nested = tmp_path / "dags" / "bp.py" - assert display_path(nested, base=tmp_path) == "dags/bp.py" + assert display_path(nested, base=tmp_path) == str(Path("dags") / "bp.py") From b701ce541bad6de8b7c726cd318d2ce9b2fbb4fa Mon Sep 17 00:00:00 2001 From: Jeremy Beard Date: Mon, 22 Jun 2026 12:22:06 -0400 Subject: [PATCH 4/4] Show list locations relative to --template-dir when provided When 'blueprint list --template-dir DIR' is given, render each blueprint's Location relative to DIR (so paths read as 'blueprints.py' or 'etl/bp.py' rather than the full path from cwd). Falls back to cwd-relative when no template dir is supplied. --- blueprint/cli.py | 4 ++-- tests/test_cli.py | 16 +++++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/blueprint/cli.py b/blueprint/cli.py index 12049c4..5b841ca 100644 --- a/blueprint/cli.py +++ b/blueprint/cli.py @@ -137,12 +137,12 @@ def list_blueprints(template_dir: str | None): table.add_column("Class", style="dim", no_wrap=False) table.add_column("Location", style="dim", overflow="fold") - cwd = Path.cwd() + base = Path(template_dir).resolve() if template_dir else Path.cwd() for bp in blueprints: versions_str = ", ".join(str(v) for v in bp["versions"]) desc = bp["description"].split("\n")[0] if bp["description"] else "-" location = bp["locations"].get(bp["latest_version"]) - location_str = display_path(location, base=cwd) if location else "-" + location_str = display_path(location, base=base) if location else "-" table.add_row(bp["name"], versions_str, desc, bp["class"], location_str) console.print(table) diff --git a/tests/test_cli.py b/tests/test_cli.py index 396c559..fe00906 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -81,12 +81,13 @@ def render(self, config): assert "1" in result.output assert "2" in result.output - def test_list_shows_location(self): + def test_list_location_is_relative_to_template_dir(self): + """When --template-dir is given, locations are shown relative to it.""" runner = CliRunner() with runner.isolated_filesystem(): - template_dir = Path("dags") - template_dir.mkdir() - (template_dir / "bp.py").write_text(""" + nested = Path("dags") / "etl" + nested.mkdir(parents=True) + (nested / "bp.py").write_text(""" from pydantic import BaseModel from blueprint.core import Blueprint @@ -107,9 +108,10 @@ def render(self, config): assert result.exit_code == 0 assert "Location" in result.output - # display_path renders with the OS separator, so build the expected - # path the same way rather than hard-coding a POSIX separator. - assert str(Path("dags") / "bp.py") in result.output + # Relative to the template dir ("dags"), not cwd. display_path renders + # with the OS separator, so build the expected path the same way. + assert str(Path("etl") / "bp.py") in result.output + assert str(Path("dags") / "etl" / "bp.py") not in result.output def test_describe_command(self, tmp_path): template_dir = tmp_path / "dags"