From 55e83ecb2dca7065e2daf901151dbccce2c0b7de Mon Sep 17 00:00:00 2001 From: Debasish Bose Date: Thu, 10 Sep 2026 23:11:55 +1000 Subject: [PATCH] Run every command from anywhere inside the model (closes #7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commands resolved the model dir from the exact directory the user stood in, so adding an entity, generating docs, and the rest each only worked from one specific folder — forcing a cd back and forth between the project root and the model. ModelRepo.load and apply_command now walk UP from the given path to the nearest ancestor holding mdl-project.yaml (new find_project_root in repo.py), so any command works from the model dir or any subdirectory of it. When no project is found (e.g. run from the root ABOVE the model), the error now names the model subdir and how to target it — "no mdl-project.yaml in . — the model is in my-model/ (run from there, or pass -m my-model)" — instead of a bare, confusing message. The `new` CLI commands also catch that FileNotFoundError and print it plainly rather than a traceback. Note: current source already used mdl-project.yaml consistently, so the reporter's secondary ".yml vs .yaml" mismatch (v0.1.1) no longer exists. Tests: new test_project_root.py covers load/add from nested dirs and both error messages. 553 passed, 1 skipped; ruff clean; verified on the CLI against the issue's exact layout. --- packages/cli/src/mdl_cli/main.py | 26 +++++--- packages/core/src/mdl_core/commands.py | 8 ++- packages/core/src/mdl_core/repo.py | 45 +++++++++++++- packages/core/tests/test_project_root.py | 77 ++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 14 deletions(-) create mode 100644 packages/core/tests/test_project_root.py 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