Python · 3163 bytes Raw Blame History
1 """Versioned frontmatter validation — run migrations before Pydantic.
2
3 The plain `DlmFrontmatter.model_validate(raw)` path refuses unknown
4 keys (`extra="forbid"`) and would therefore reject *any* v2+ document
5 with a field added after v1. That turns "forgot to run `dlm migrate`"
6 into a confusing `SchemaValidationError` instead of an actionable
7 version-drift message.
8
9 This module threads the raw dict through the migration registry first
10 (bringing a v1 dict up to `CURRENT_SCHEMA_VERSION`) and only then hands
11 it to Pydantic. Older-but-runnable documents parse cleanly; newer
12 documents raise a typed `DlmVersionError` pointing at the dlm upgrade.
13
14 One entry point:
15
16 validate_versioned(raw: dict, *, path: Path | None) -> DlmFrontmatter
17
18 Callers: `dlm.doc.parser._validate_frontmatter` (post-YAML, pre-Pydantic).
19 """
20
21 from __future__ import annotations
22
23 from pathlib import Path
24
25 from pydantic import ValidationError
26
27 from dlm.doc.errors import DlmVersionError, SchemaValidationError, UnsupportedMigrationError
28 from dlm.doc.migrations.dispatch import apply_pending
29 from dlm.doc.schema import CURRENT_SCHEMA_VERSION, DlmFrontmatter
30
31
32 def validate_versioned(raw: dict[str, object], *, path: Path | None = None) -> DlmFrontmatter:
33 """Dispatch: migrate (if needed) then Pydantic-validate.
34
35 Raises:
36 DlmVersionError: `raw["dlm_version"]` is newer than this parser
37 supports, or a required intermediate migrator is missing.
38 SchemaValidationError: Pydantic validation failed after any
39 applicable migrations.
40 """
41 version = raw.get("dlm_version", 1)
42 # `isinstance(v, int)` is True for `bool`, so exclude it explicitly —
43 # `dlm_version: true` would otherwise coerce to version 1 silently.
44 if not isinstance(version, int) or isinstance(version, bool):
45 raise SchemaValidationError(
46 f"dlm_version must be an integer, got {type(version).__name__}",
47 path=path,
48 line=2,
49 )
50 try:
51 migrated, applied = apply_pending(raw, target_version=CURRENT_SCHEMA_VERSION)
52 except UnsupportedMigrationError:
53 raise
54 except DlmVersionError as exc:
55 raise DlmVersionError(exc.message, path=path, line=2) from exc
56 if applied:
57 # The parser's own error path logs migrations as an info line; the
58 # migration framework's data-rewrite happens here without
59 # side-effects on the source file. `dlm migrate <path>` is the
60 # write-path — parsing never mutates the on-disk document.
61 pass
62
63 try:
64 return DlmFrontmatter.model_validate(migrated)
65 except ValidationError as exc:
66 raise SchemaValidationError(
67 _format_pydantic_error(exc),
68 path=path,
69 line=2,
70 ) from exc
71
72
73 def _format_pydantic_error(exc: ValidationError) -> str:
74 """Collapse Pydantic's error-tree into a single-line message."""
75 parts = []
76 for err in exc.errors():
77 loc = ".".join(str(p) for p in err.get("loc", ())) or "<root>"
78 msg = err.get("msg", "invalid value")
79 parts.append(f"{loc}: {msg}")
80 return "; ".join(parts) or "validation failed"