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
11 changes: 9 additions & 2 deletions src/profiles/core/config/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,15 @@ def collect_config_issues(text: str) -> list[dict[str, str]]:
raw = read_yaml_text(text)
except Exception as exc: # noqa: BLE001 — any parser failure is report data
return [{"severity": "error", "message": f"YAML syntax error: {exc}"}]
if not raw:
if not raw and not isinstance(raw, (int, float, bool)):
return [{"severity": "error", "message": "configuration file is empty"}]
if not isinstance(raw, dict):
return [{"severity": "error", "message": "configuration root must be a mapping"}]

issues = [{"severity": "error", "message": str(error)} for error in collect_issues(raw)]
try:
issues = [{"severity": "error", "message": str(error)} for error in collect_issues(raw)]
except Exception as exc: # noqa: BLE001
issues = [{"severity": "error", "message": f"semantic validation error: {exc}"}]

try:
AppConfigYaml.model_validate(raw)
Expand All @@ -53,6 +58,8 @@ def collect_config_issues(text: str) -> list[dict[str, str]]:
issues.append(
{"severity": "error", "message": f"{location}: {message}" if location else message}
)
except Exception as exc: # noqa: BLE001
issues.append({"severity": "error", "message": f"schema validation error: {exc}"})
return issues


Expand Down
20 changes: 12 additions & 8 deletions src/profiles/core/config/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@ def collect_issues(raw: dict) -> list[ConfigError]:
"""
errors: list[ConfigError] = []

if not isinstance(raw, dict):
return [ConfigError("", "configuration root must be a mapping")]

for key in raw:
if key not in _KNOWN_TOP_LEVEL:
errors.append(ConfigError(key, f"unknown top-level key '{key}'"))
if not isinstance(key, str) or key not in _KNOWN_TOP_LEVEL:
errors.append(ConfigError(str(key), f"unknown top-level key '{key}'"))

configs = raw.get("configs") or {}
if not isinstance(configs, dict):
Expand All @@ -46,7 +49,7 @@ def collect_issues(raw: dict) -> list[ConfigError]:
extends = cfg.get("extends")
if extends is None:
continue
if extends not in configs:
if not isinstance(extends, str) or extends not in configs:
errors.append(
ConfigError(
f"configs.{name}.extends",
Expand Down Expand Up @@ -76,13 +79,14 @@ def visit(name: str, stack: tuple[str, ...]) -> None:
return
visiting.add(name)
extends = configs[name].get("extends")
if extends is not None:
if isinstance(extends, str):
visit(extends, (*stack, name))
visiting.discard(name)
visited.add(name)

for name in configs:
visit(name, (name,))
if isinstance(name, str):
visit(name, (name,))

return errors

Expand All @@ -100,9 +104,9 @@ def validate(raw: dict) -> None:
ConfigError: On unknown top-level keys, unknown ``extends``
references, or inheritance cycles.
"""
errors = collect_issues(raw)
if errors:
raise errors[0]
issues = collect_issues(raw)
if issues:
raise issues[0]


__all__ = ["collect_issues", "validate"]
13 changes: 13 additions & 0 deletions tests/core/config/test_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,16 @@ def test_all_issues_carry_error_severity() -> None:
issues = collect_config_issues(content)
assert issues
assert all(issue["severity"] == "error" for issue in issues)


def test_non_mapping_root_reports_error() -> None:
issues_scalar = collect_config_issues("42\n")
assert any("root must be a mapping" in i["message"] for i in issues_scalar)

issues_list = collect_config_issues("- item1\n- item2\n")
assert any("root must be a mapping" in i["message"] for i in issues_list)


def test_non_hashable_extends_reports_error_without_crash() -> None:
issues = collect_config_issues("configs: {a: {extends: [b]}}\n")
assert any("configs.a.extends" in i["message"] for i in issues)
10 changes: 10 additions & 0 deletions tests/core/config/test_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,13 @@ def test_valid_content_collects_nothing(self) -> None:
"configs": {"prod": {"extends": "base"}, "base": {}},
}
assert collect_issues(raw) == []

def test_non_mapping_root(self) -> None:
errors = collect_issues(42) # type: ignore[arg-type]
assert len(errors) == 1
assert "root must be a mapping" in errors[0].message

def test_non_string_extends(self) -> None:
raw = {"configs": {"a": {"extends": ["b"]}}}
errors = collect_issues(raw)
assert any(e.path == "configs.a.extends" for e in errors)
Loading