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
26 changes: 16 additions & 10 deletions packages/cli/src/mdl_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)"
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/mdl_core/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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")

Expand Down
45 changes: 43 additions & 2 deletions packages/core/src/mdl_core/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir>"
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)
Expand All @@ -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))
Expand Down
77 changes: 77 additions & 0 deletions packages/core/tests/test_project_root.py
Original file line number Diff line number Diff line change
@@ -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
Loading