diff --git a/packages/cli/src/mdl_cli/main.py b/packages/cli/src/mdl_cli/main.py index fe5e6dc..b6c48b1 100644 --- a/packages/cli/src/mdl_cli/main.py +++ b/packages/cli/src/mdl_cli/main.py @@ -510,7 +510,7 @@ def unmanage( days = int(expires.rstrip("d") or "14") try: apply_command(model_dir, "set_unmanaged", {"id": le.id, "unmanaged": True}) - except CommandError as e: + except (CommandError, FileNotFoundError) as e: typer.secho(str(e), fg=typer.colors.RED, err=True) raise typer.Exit(1) from e entry = add_debt(model_dir, entity, reason, days) @@ -679,7 +679,7 @@ def ontology_promote( raise typer.Exit(1) try: apply_command(model_dir, "promote_alignment", {"id": obj.id}) - except CommandError as e: + except (CommandError, FileNotFoundError) as e: typer.secho(str(e), fg=typer.colors.RED, err=True) raise typer.Exit(1) from e typer.secho(f"alignment on {name!r} promoted to accepted", fg=typer.colors.GREEN) @@ -1227,7 +1227,7 @@ def delete_entity( try: apply_command(model_dir, "delete_entity", {"id": le.id, "cascade": cascade}) - except CommandError as e: + except (CommandError, FileNotFoundError) as e: typer.secho(str(e), fg=typer.colors.RED, err=True) raise typer.Exit(1) from e typer.secho(f"deleted entity {name!r}", fg=typer.colors.GREEN) @@ -1250,7 +1250,9 @@ def new_entity( "create_entity", {"name": name, "subject_area": subject_area, "definition": definition, "layer": layer}, ) - except CommandError as e: + except (CommandError, FileNotFoundError) as e: + # FileNotFoundError carries the "no mdl-project.yaml … run from my-model/" + # guidance (issue #7); show it plainly, not as a traceback. typer.secho(str(e), fg=typer.colors.RED, err=True) raise typer.Exit(1) from e typer.secho(f"created entity {name!r} ({result.created_id})", fg=typer.colors.GREEN) @@ -1262,11 +1264,15 @@ def new_subject_area( model_dir: Path = typer.Option(Path("."), "--model-dir", "-m"), definition: str = typer.Option(None, "--definition"), ) -> None: - from mdl_core.commands import apply_command + from mdl_core.commands import CommandError, apply_command - result = apply_command( - model_dir, "create_subject_area", {"name": name, "definition": definition} - ) + try: + result = apply_command( + model_dir, "create_subject_area", {"name": name, "definition": definition} + ) + except (CommandError, FileNotFoundError) as e: + typer.secho(str(e), fg=typer.colors.RED, err=True) + raise typer.Exit(1) from e typer.secho(f"created subject area {name!r} ({result.created_id})", fg=typer.colors.GREEN) @@ -1296,7 +1302,7 @@ def new_term( "alignment": alignment, }, ) - except CommandError as e: + except (CommandError, FileNotFoundError) as e: typer.secho(str(e), fg=typer.colors.RED, err=True) raise typer.Exit(1) from e typer.secho(f"created term {name!r} ({result.created_id})", fg=typer.colors.GREEN) @@ -1980,7 +1986,7 @@ def gov_import( apply_command(model_dir, op, payload) applied += 1 typer.echo(f" {op} {payload.get('id', '')}") - except CommandError as e: + except (CommandError, FileNotFoundError) as e: typer.secho(f"skip {op}: {e}", fg=typer.colors.YELLOW) note = "" if sot == "collibra" else " (note: source_of_truth is git; this is a one-off import)" diff --git a/packages/core/src/mdl_core/commands.py b/packages/core/src/mdl_core/commands.py index 92661fe..d3008b0 100644 --- a/packages/core/src/mdl_core/commands.py +++ b/packages/core/src/mdl_core/commands.py @@ -22,7 +22,7 @@ from mdl_core.diagnostics import Severity from mdl_core.ids import is_ulid, new_ulid -from mdl_core.repo import PROJECT_FILE, ModelRepo +from mdl_core.repo import PROJECT_FILE, ModelRepo, find_project_root from mdl_core.validate import validate # --------------------------------------------------------------------------- @@ -63,7 +63,11 @@ class CommandResult: def apply_command( model_dir: Path, op: str, payload: dict, base_fingerprint: str | None = None ) -> CommandResult: - model_dir = Path(model_dir) + # Resolve to the model dir first, so a command run from a nested dir or the + # project root (not only from the exact folder holding mdl-project.yaml) acts on + # the right tree — and so the fingerprint below matches ModelRepo.load's own + # walk-up (issue #7). + model_dir = find_project_root(Path(model_dir)) if base_fingerprint and dir_fingerprint(model_dir) != base_fingerprint: raise StaleModelError("model changed on disk — refresh before editing") diff --git a/packages/core/src/mdl_core/repo.py b/packages/core/src/mdl_core/repo.py index be5b3b5..ac66181 100644 --- a/packages/core/src/mdl_core/repo.py +++ b/packages/core/src/mdl_core/repo.py @@ -38,6 +38,45 @@ PROJECT_FILE = "mdl-project.yaml" +def find_project_root(start: Path) -> Path: + """The model directory for `start`: `start` itself if it holds an + `mdl-project.yaml`, else the nearest ancestor that does, else `start` unchanged. + + This is what lets every command run from ANYWHERE inside a model — a nested + working dir, or the project root one level above — instead of forcing the user to + `cd` into the exact folder holding the project file (issue #7). Returning `start` + unchanged when nothing is found keeps the caller's "no mdl-project.yaml in " + error pointing at the directory the user actually named.""" + start = Path(start) + # A file path is meaningless as a root; resolve to its directory first. + base = start if start.is_dir() else start.parent + base = base.resolve() + for d in (base, *base.parents): + if (d / PROJECT_FILE).is_file(): + return d + return start + + +def _no_project_message(where: Path) -> str: + """A not-found message that helps rather than confuses (issue #7): when the model + is in a subdirectory just below `where` (the classic "I'm at the project root, the + model is in my-model/" case), name it so the user knows where to go, instead of a + bare 'no mdl-project.yaml in .'.""" + base = f"no {PROJECT_FILE} in {where}" + try: + subdirs = [ + p.parent.name + for p in Path(where).glob(f"*/{PROJECT_FILE}") + if p.is_file() + ] + except OSError: + subdirs = [] + if subdirs: + which = ", ".join(sorted(subdirs)) + return f"{base} — the model is in {which}/ (run from there, or pass -m {subdirs[0]})" + return base + + class ModelRepo: def __init__(self, root: Path) -> None: self.root = Path(root) @@ -51,14 +90,16 @@ def __init__(self, root: Path) -> None: @classmethod def load(cls, root: Path) -> ModelRepo: - repo = cls(root) + # Walk up to the model dir so a load works from anywhere inside the project, + # not only from the exact folder holding mdl-project.yaml (issue #7). + repo = cls(find_project_root(root)) repo._load() return repo def _load(self) -> None: project_path = self.root / PROJECT_FILE if not project_path.exists(): - raise FileNotFoundError(f"no {PROJECT_FILE} in {self.root}") + raise FileNotFoundError(_no_project_message(self.root)) project_raw = load_file(project_path) self.raw[PROJECT_FILE] = project_raw config = ProjectConfig.model_validate(_to_plain(project_raw)) diff --git a/packages/core/tests/test_project_root.py b/packages/core/tests/test_project_root.py new file mode 100644 index 0000000..7a3494b --- /dev/null +++ b/packages/core/tests/test_project_root.py @@ -0,0 +1,77 @@ +"""Commands must run from anywhere inside a model, not only from the exact folder +holding mdl-project.yaml (issue #7).""" + +from __future__ import annotations + +import pytest + +from mdl_core.commands import apply_command +from mdl_core.repo import ModelRepo, find_project_root + +from model_builders import write_model + + +@pytest.fixture +def project(tmp_path): + """The issue's layout: a project root with the model one level down in my-model/.""" + model = tmp_path / "my-model" + model.mkdir() + write_model(model) + return tmp_path, model + + +def test_find_root_from_model_dir(project): + _, model = project + assert find_project_root(model) == model.resolve() + + +def test_find_root_from_nested_dir(project): + _, model = project + nested = model / "conceptual" / "entities" + assert find_project_root(nested) == model.resolve() + + +def test_load_from_the_model_dir(project): + _, model = project + assert ModelRepo.load(model).model.config.name == "testmodel" + + +def test_load_from_a_nested_dir_walks_up(project): + """The core of issue #7: standing in conceptual/entities/ must still load the + model, instead of failing because mdl-project.yaml is not in that exact dir.""" + _, model = project + nested = model / "logical" / "entities" + assert ModelRepo.load(nested).model.config.name == "testmodel" + + +def test_add_entity_from_a_nested_dir_walks_up(project): + """Adding an entity — the command the issue says only worked from a specific dir — + now works from anywhere inside the model.""" + _, model = project + nested = model / "conceptual" / "entities" + result = apply_command(nested, "create_entity", {"name": "Widget"}) + assert result.ok and result.created_id + # it landed in the real model at the resolved root, not in the nested dir + reloaded = ModelRepo.load(model).model + assert any(e.name == "Widget" for e in reloaded.conceptual_entities.values()) + # and nothing was written into the nested working dir + assert not (nested / "mdl-project.yaml").exists() + + +def test_helpful_message_when_run_from_the_project_root_above(project): + """Running from the root ABOVE the model (where walking up finds nothing) gives a + message that points into the model, not a bare 'no mdl-project.yaml in .'.""" + root, _ = project + with pytest.raises(FileNotFoundError) as ei: + ModelRepo.load(root) + msg = str(ei.value) + assert "my-model" in msg # names the subdir to go into + assert "-m my-model" in msg # and how to point at it + + +def test_plain_message_when_no_model_anywhere(tmp_path): + with pytest.raises(FileNotFoundError) as ei: + ModelRepo.load(tmp_path) + msg = str(ei.value) + assert "no mdl-project.yaml" in msg + assert "my-model" not in msg # nothing to suggest