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
2 changes: 1 addition & 1 deletion src/flightdeck/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def _org(root: Path) -> Org:
try:
return load_org(root)
except ConfigError as exc:
err.print(f"[red]config error:[/red] {exc}")
err.print(f"[red]config error:[/red] {escape(str(exc))}")
raise typer.Exit(2) from None


Expand Down
18 changes: 16 additions & 2 deletions src/flightdeck/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,20 @@ def _read_yaml(path: Path) -> dict:
return raw


def _read_collection(path: Path, key: str) -> list[object]:
"""Load a strict single-key document whose value is a list or null."""
raw = _read_yaml(path)
if unknown := sorted(repr(name) for name in raw if name != key):
names = ", ".join(unknown)
raise ConfigError(f"{path}: unknown top-level key(s): {names}; expected only {key!r}")
items = raw.get(key)
if items is None:
return []
if not isinstance(items, list):
raise ConfigError(f"{path}: {key!r} must be a list, got {type(items).__name__}")
return items


def _validation_error(path: Path, exc: ValidationError) -> ConfigError:
lines = []
for err in exc.errors():
Expand Down Expand Up @@ -152,7 +166,7 @@ def load_org(root: Path | str) -> Org:

models_path = root / MODELS_FILE
models = _load_indexed_items(
_read_yaml(models_path).get("models") or [],
_read_collection(models_path, "models"),
path=models_path,
item_type=ModelSpec,
kind="model",
Expand All @@ -164,7 +178,7 @@ def load_org(root: Path | str) -> Org:
usecases_path = root / USECASES_FILE
if usecases_path.exists():
usecases = _load_indexed_items(
_read_yaml(usecases_path).get("usecases") or [],
_read_collection(usecases_path, "usecases"),
path=usecases_path,
item_type=UseCase,
kind="use case",
Expand Down
17 changes: 17 additions & 0 deletions tests/test_cli_and_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@ def test_init_scaffolds_a_loadable_org(tmp_path):
assert "mock-fast" in org.models


def test_config_error_survives_rich_markup_in_unknown_wrapper_key(tmp_path):
from rich.errors import MarkupError

root = _init(tmp_path)
path = root / "models.yaml"
document = yaml.safe_load(path.read_text(encoding="utf-8"))
document["[/]"] = []
path.write_text(yaml.safe_dump(document), encoding="utf-8")

result = invoke("report", "--dir", str(root))

assert not isinstance(result.exception, MarkupError)
assert result.exit_code == 2
assert "unknown top-level key" in result.output
assert "[/]" in result.output


def test_init_refuses_to_overwrite(tmp_path):
root = _init(tmp_path)
result = invoke("init", "--dir", str(root))
Expand Down
46 changes: 46 additions & 0 deletions tests/test_store_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,52 @@ def test_missing_or_null_use_case_collection_loads_empty(tmp_path, collection):
assert load_org(root).usecases == {}


@pytest.mark.parametrize(
("filename", "key"),
[("models.yaml", "models"), ("usecases.yaml", "usecases")],
)
def test_scalar_config_collections_fail_as_config_errors(tmp_path, filename, key):
root = write_org(tmp_path / "org", workflows=[])
path = root / filename
path.write_text(yaml.safe_dump({key: 7}), encoding="utf-8")

with pytest.raises(ConfigError) as excinfo:
load_org(root)

assert str(path) in str(excinfo.value)
assert f"{key!r} must be a list, got int" in str(excinfo.value)


@pytest.mark.parametrize(
("filename", "key", "items"),
[
("models.yaml", "models", [MODELS[0]]),
("usecases.yaml", "usecases", []),
],
)
def test_unknown_config_collection_keys_fail_loudly(tmp_path, filename, key, items):
root = write_org(tmp_path / "org", workflows=[])
path = root / filename
typo = f"{key}_typo"
path.write_text(yaml.safe_dump({key: items, typo: []}), encoding="utf-8")

with pytest.raises(ConfigError) as excinfo:
load_org(root)

message = str(excinfo.value)
assert repr(typo) in message
assert f"expected only {key!r}" in message


def test_non_string_config_collection_key_is_a_config_error(tmp_path):
root = write_org(tmp_path / "org", workflows=[])
path = root / "models.yaml"
path.write_text(yaml.safe_dump({"models": [MODELS[0]], 7: []}), encoding="utf-8")

with pytest.raises(ConfigError, match=r"models\.yaml.*7"):
load_org(root)


def test_absent_workflow_directory_loads_empty(tmp_path):
root = write_org(tmp_path / "org", workflows=[])

Expand Down
Loading