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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ src/profiles/
│ │ ├── matcher.py # Correspondance config ↔ hostname
│ │ ├── schema.py # Schéma de validation
│ │ ├── conditions.py # Grammaire des conditions if: des hook steps
│ │ ├── validator.py # Validation des valeurs
│ │ ├── validator.py # Validation des valeurs (validate, collect_issues)
│ │ ├── diagnostics.py # collect_config_issues — rapport multi-erreurs éditeur
│ │ ├── inheritance.py # Héritage entre configurations
│ │ ├── template.py # STARTER_CONFIG_TEMPLATE (génération .profiles)
│ │ └── io/yaml_io.py # Lecture/écriture YAML
Expand Down
59 changes: 59 additions & 0 deletions src/profiles/core/config/diagnostics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Editor diagnostics: collect ALL problems in configuration content.

Single Responsibility: turn the fail-fast parse → semantic → schema
pipeline (:class:`~profiles.core.config.reader.ConfigReader.from_content`
raises on the first problem) into a complete issue report for the YAML
editor's Problems panel. Pure and read-only — parses in memory, never
touches the disk, never raises.

Error-collection guarantees:
- YAML *syntax* errors are necessarily single-issue: the underlying
parser (ruamel.yaml) stops at the first one.
- Semantic problems (unknown top-level keys, unknown ``extends``
references, inheritance cycles) are collected exhaustively.
- Schema violations report every Pydantic error, with a dotted path.
"""

from __future__ import annotations

from pydantic import ValidationError

from profiles.core.config.io.yaml_io import read_yaml_text
from profiles.core.config.schema import AppConfigYaml
from profiles.core.config.validator import collect_issues


def collect_config_issues(text: str) -> list[dict[str, str]]:
"""Report every problem found in configuration *text*.

Args:
text: Raw YAML configuration content (a ``.profiles`` draft).

Returns:
A list of ``{"severity": "error", "message": ...}`` dicts — ``[]``
when the content parses, passes semantic validation and the
schema. The first YAML syntax error (if any) ends the report:
no tree exists to validate until it is fixed.
"""
try:
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:
return [{"severity": "error", "message": "configuration file is empty"}]

issues = [{"severity": "error", "message": str(error)} for error in collect_issues(raw)]

try:
AppConfigYaml.model_validate(raw)
except ValidationError as exc:
for error in exc.errors():
location = ".".join(str(part) for part in error["loc"])
message = error["msg"]
issues.append(
{"severity": "error", "message": f"{location}: {message}" if location else message}
)
return issues


__all__ = ["collect_config_issues"]
67 changes: 52 additions & 15 deletions src/profiles/core/config/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,32 @@
_KNOWN_TOP_LEVEL = {"version", "defaults", "columns", "hooks", "configs"}


def validate(raw: dict) -> None:
"""Validate the raw YAML mapping semantically.
def collect_issues(raw: dict) -> list[ConfigError]:
"""Collect ALL semantic problems in the raw YAML mapping, in order.

Same checks as :func:`validate` — unknown top-level keys, unknown
``extends`` references, inheritance cycles — but every occurrence is
reported instead of failing fast at the first one. Built for editor
diagnostics (issue: the validator surfaced only the first error);
:func:`validate` keeps its fail-fast contract on top of this.

Args:
raw: The parsed YAML dict (from :func:`yaml_io.read_yaml`).

Raises:
ConfigError: On unknown top-level keys, unknown ``extends``
references, or inheritance cycles.
Returns:
Every semantic problem found, in document order (unknown keys,
then unknown ``extends`` references, then inheritance cycles).
``[]`` when the mapping is clean.
"""
errors: list[ConfigError] = []

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

configs = raw.get("configs") or {}
if not isinstance(configs, dict):
return
return errors

for name, cfg in configs.items():
if not isinstance(cfg, dict):
Expand All @@ -38,25 +47,33 @@ def validate(raw: dict) -> None:
if extends is None:
continue
if extends not in configs:
raise ConfigError(
f"configs.{name}.extends",
f"unknown config '{extends}'",
errors.append(
ConfigError(
f"configs.{name}.extends",
f"unknown config '{extends}'",
)
)

# Cycle detection via DFS
# Cycle detection via DFS; each cycle met along the way is reported.
visiting: set[str] = set()
visited: set[str] = set()

def visit(name: str, stack: tuple[str, ...]) -> None:
if name in visiting:
cycle = " -> ".join((*stack, name))
closed = stack[-1]
raise ConfigError(
f"configs.{closed}.extends",
f"inheritance cycle: {cycle}",
errors.append(
ConfigError(
f"configs.{closed}.extends",
f"inheritance cycle: {cycle}",
)
)
return
if name in visited:
return
if not isinstance(configs.get(name), dict):
visited.add(name)
return
visiting.add(name)
extends = configs[name].get("extends")
if extends is not None:
Expand All @@ -67,5 +84,25 @@ def visit(name: str, stack: tuple[str, ...]) -> None:
for name in configs:
visit(name, (name,))

return errors


def validate(raw: dict) -> None:
"""Validate the raw YAML mapping semantically.

Fail-fast twin of :func:`collect_issues`: raises the FIRST problem
found, preserving the historical load-path contract.

Args:
raw: The parsed YAML dict (from :func:`yaml_io.read_yaml`).

Raises:
ConfigError: On unknown top-level keys, unknown ``extends``
references, or inheritance cycles.
"""
errors = collect_issues(raw)
if errors:
raise errors[0]


__all__ = ["validate"]
__all__ = ["collect_issues", "validate"]
29 changes: 13 additions & 16 deletions src/profiles/server/routes/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel

from profiles.core.config.diagnostics import collect_config_issues
from profiles.core.config.io.yaml_io import CONFIG_FILE_NAMES
from profiles.core.config.models import AppConfig
from profiles.core.config.reader import ConfigReader
Expand Down Expand Up @@ -176,27 +177,23 @@ def _apply_config_from_disk(state: ServerState, config_path: Path) -> dict:
return _config_response(state)


def validate_issues(content: str, display_name: str) -> list[dict[str, str]]:
def validate_issues(content: str) -> list[dict[str, str]]:
"""Validate configuration *content* in memory without touching live state.

Parses *content* strictly with :meth:`ConfigReader.from_content` and
reports the outcome as a plain issue list, so the YAML editor can
validate content that is not applied to the session.
Delegates to :func:`collect_config_issues`, which reports EVERY
problem in the draft — unknown keys, bad ``extends`` references,
cycles, schema violations — so the editor's Problems panel can list
them all. Only the first YAML *syntax* error is reported (the parser
stops there); until it is fixed there is no tree to validate further.

Args:
content: Raw YAML configuration content.
display_name: Human-readable file name used in error messages.

Returns:
``[]`` when the content parses and validates, otherwise a single
``{"severity": "error", "message": ...}`` dict describing the
failure.
``[]`` when the content parses and validates, otherwise
``{"severity": "error", "message": ...}`` dicts, one per problem.
"""
try:
ConfigReader.from_content(content, display_name=display_name)
except ValueError as exc:
return [{"severity": "error", "message": str(exc)}]
return []
return collect_config_issues(content)


def has_disk_path(state: ServerState) -> bool:
Expand Down Expand Up @@ -360,7 +357,7 @@ async def validate_config_endpoint(payload: ValidateRequest) -> dict:
Returns ``{"valid": bool, "issues": [...]}`` rather than an HTTP error
status, so invalid drafts reach the editor as data to display.
"""
issues = validate_issues(payload.content, display_name=payload.filename)
issues = validate_issues(payload.content)
return {"valid": not issues, "issues": issues}


Expand Down Expand Up @@ -425,7 +422,7 @@ async def save_config_endpoint(payload: SaveRequest, request: Request) -> dict:
)

config_path = state.active_config_path()
issues = validate_issues(payload.content, display_name=config_path.name)
issues = validate_issues(payload.content)
if issues and not payload.force:
raise HTTPException(status_code=400, detail=issues)

Expand Down Expand Up @@ -484,7 +481,7 @@ async def save_as_config_endpoint(payload: SaveAsRequest, request: Request) -> d
"Retry with overwrite=true to replace it.",
)

issues = validate_issues(payload.content, display_name=payload.filename)
issues = validate_issues(payload.content)
if issues and not payload.force:
raise HTTPException(status_code=400, detail=issues)

Expand Down

Large diffs are not rendered by default.

Loading
Loading