Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion blueprint/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from blueprint.loaders import discover_blueprints, get_blueprint_info, validate_yaml
from blueprint.registry import BlueprintRegistry
from blueprint.utils import display_path

console = Console()

Expand Down Expand Up @@ -134,11 +135,15 @@ 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")

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 "-"
table.add_row(bp["name"], versions_str, desc, bp["class"])
location = bp["locations"].get(bp["latest_version"])
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)

Expand Down
24 changes: 17 additions & 7 deletions blueprint/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

import yaml

from blueprint.utils import display_path

# Constants
MAX_SUGGESTION_VALUES = 10

Expand Down Expand Up @@ -207,16 +209,20 @@ class DuplicateBlueprintError(BlueprintError):
def __init__(self, blueprint_name: str, locations: list[str]):
self.blueprint_name = blueprint_name
self.locations = locations
# 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)

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):
Expand Down Expand Up @@ -276,15 +282,19 @@ class MultipleDagArgsError(BlueprintError):

def __init__(self, locations: list[str]):
self.locations = locations
super().__init__(locations)

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:
if not loc:
continue
message += f"\n • {display_path(loc)}"

message += "\n\n💡 Suggestions:"
message += "\n • Remove all but one BlueprintDagArgs subclass"

super().__init__(message)
return message


class InvalidVersionError(BlueprintError):
Expand Down
21 changes: 7 additions & 14 deletions blueprint/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,28 +150,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] = {}
Expand All @@ -185,13 +182,9 @@ def _register_class(self, cls: type[Blueprint], py_file: Path, base_dir: Path) -
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:
"""Register the single BlueprintDagArgs template, tracking its location."""
location = str(py_file.resolve())

if self._dag_args is not None:
raise MultipleDagArgsError([self._dag_args_location or "", location])
Expand Down
28 changes: 28 additions & 0 deletions blueprint/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""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. A falsy ``path`` is returned unchanged.
"""
if not path:
return str(path)
base = (base or Path.cwd()).resolve()
resolved = Path(path).resolve()
try:
return str(resolved.relative_to(base))
except ValueError:
return str(resolved)
33 changes: 33 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for the Blueprint CLI."""

import json
from pathlib import Path

from click.testing import CliRunner

Expand Down Expand Up @@ -80,6 +81,38 @@ def render(self, config):
assert "1" in result.output
assert "2" in result.output

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():
nested = Path("dags") / "etl"
nested.mkdir(parents=True)
(nested / "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
# 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"
template_dir.mkdir()
Expand Down
26 changes: 26 additions & 0 deletions tests/test_registry.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Tests for the version-aware Blueprint registry."""

from pathlib import Path

import pytest
from pydantic import BaseModel

Expand Down Expand Up @@ -673,3 +675,27 @@ 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 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"
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()
30 changes: 30 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Tests for shared blueprint utilities."""

from pathlib import Path

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"
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"
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) == str(Path("dags") / "bp.py")