Skip to content

Commit 576a3cf

Browse files
dgarrosclaude
andcommitted
refactor(schema): validate against InfrahubSchemaWrite root instead of per-collection map
Validate nodes and generics in one pass against the generated write document model; keep the separate extension gating (extension nodes are kind-only). Field-level dotted errors unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent aa401e0 commit 576a3cf

1 file changed

Lines changed: 33 additions & 28 deletions

File tree

infrahub_sdk/schema/validate.py

Lines changed: 33 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,10 @@
1717

1818
from .generated.write import (
1919
AttributeSchemaWrite,
20-
GenericSchemaWrite,
21-
NodeSchemaWrite,
20+
InfrahubSchemaWrite,
2221
RelationshipSchemaWrite,
2322
)
2423

25-
# Maps each collection in a schema-root payload to the write model its items must satisfy.
26-
_WRITE_MODELS_BY_COLLECTION: dict[str, type[BaseModel]] = {
27-
"nodes": NodeSchemaWrite,
28-
"generics": GenericSchemaWrite,
29-
}
30-
3124

3225
class SchemaValidationErrorDetail(BaseModel):
3326
"""A single field-level validation problem in a schema payload."""
@@ -59,34 +52,45 @@ def raise_for_status(self) -> None:
5952
raise ValueError("; ".join(self.messages))
6053

6154

62-
def _format_error_location(prefix: str, loc: tuple[Any, ...]) -> str:
63-
"""Render a dotted field path from a base prefix and a pydantic error location.
55+
def _format_error_location(loc: tuple[Any, ...], prefix: str = "") -> str:
56+
"""Render a dotted field path from a pydantic error location, optionally under a base prefix.
6457
6558
Integer elements index into the preceding segment (``attributes`` + ``1`` becomes
66-
``attributes[1]``); everything else is appended as a new dotted segment.
59+
``attributes[1]``); everything else is appended as a new dotted segment. A ``prefix`` is used
60+
when the location is relative to an item validated on its own (e.g. an extension attribute).
6761
"""
68-
parts = [prefix]
62+
parts = [prefix] if prefix else []
6963
for element in loc:
7064
if isinstance(element, int):
71-
parts[-1] = f"{parts[-1]}[{element}]"
65+
if parts:
66+
parts[-1] = f"{parts[-1]}[{element}]"
67+
else:
68+
parts.append(f"[{element}]")
7269
else:
7370
parts.append(str(element))
7471
return ".".join(parts)
7572

7673

74+
def _collect_validation_errors(
75+
exc: PydanticValidationError, errors: list[SchemaValidationErrorDetail], prefix: str = ""
76+
) -> None:
77+
"""Append a field-level detail for every problem in a pydantic validation error."""
78+
for error in exc.errors():
79+
location = _format_error_location(loc=error["loc"], prefix=prefix)
80+
message = f"{location}: {error['msg']}"
81+
if error["type"] != "missing" and "input" in error:
82+
message += f" (received: {error['input']!r})"
83+
errors.append(SchemaValidationErrorDetail(field=location, message=message))
84+
85+
7786
def _validate_item(model: type[BaseModel], item: Any, prefix: str, errors: list[SchemaValidationErrorDetail]) -> None:
7887
"""Validate a single mapping against a write model, appending field-level errors under ``prefix``."""
7988
if not isinstance(item, dict):
8089
return
8190
try:
8291
model.model_validate(item)
8392
except PydanticValidationError as exc:
84-
for error in exc.errors():
85-
location = _format_error_location(prefix=prefix, loc=error["loc"])
86-
message = f"{location}: {error['msg']}"
87-
if error["type"] != "missing" and "input" in error:
88-
message += f" (received: {error['input']!r})"
89-
errors.append(SchemaValidationErrorDetail(field=location, message=message))
93+
_collect_validation_errors(exc=exc, errors=errors, prefix=prefix)
9094

9195

9296
def _validate_extensions(extensions: Any, errors: list[SchemaValidationErrorDetail]) -> None:
@@ -126,7 +130,7 @@ def _validate_extensions(extensions: Any, errors: list[SchemaValidationErrorDeta
126130

127131

128132
def validate_schema(schema: dict[str, Any], *, raise_on_error: bool = False) -> SchemaValidationResult:
129-
"""Validate a single schema-root payload against the generated write models.
133+
"""Validate a single schema-root payload against the generated write contract.
130134
131135
Args:
132136
schema: A schema-root mapping, e.g. ``{"version": "1.0", "nodes": [...], "generics": [...]}``.
@@ -135,20 +139,21 @@ def validate_schema(schema: dict[str, Any], *, raise_on_error: bool = False) ->
135139
Returns:
136140
A :class:`SchemaValidationResult` with a field-level message for every field that is not
137141
settable (read-level, internal, or unknown) and for every constrained field set outside
138-
its allowed set. Nodes, generics, and the attributes/relationships nested under
139-
``extensions.nodes`` are all held to the write contract.
142+
its allowed set. The nodes and generics are validated together against the write
143+
document model; the attributes/relationships nested under ``extensions.nodes`` are held to
144+
the same write contract (extension nodes are ``kind``-only, so the document model cannot
145+
cover them).
140146
141147
Raises:
142148
ValueError: When ``raise_on_error`` is True and the payload is invalid.
143149
144150
"""
145151
errors: list[SchemaValidationErrorDetail] = []
146-
for collection, model in _WRITE_MODELS_BY_COLLECTION.items():
147-
items = schema.get(collection)
148-
if not isinstance(items, list):
149-
continue
150-
for index, item in enumerate(items):
151-
_validate_item(model=model, item=item, prefix=f"{collection}[{index}]", errors=errors)
152+
153+
try:
154+
InfrahubSchemaWrite.model_validate(schema)
155+
except PydanticValidationError as exc:
156+
_collect_validation_errors(exc=exc, errors=errors)
152157

153158
_validate_extensions(extensions=schema.get("extensions"), errors=errors)
154159

0 commit comments

Comments
 (0)