289 lines
10 KiB
Python
289 lines
10 KiB
Python
# SPDX-FileCopyrightText: 2026 Art Sea LLC
|
|
#
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
"""Validate shape by reference-owned structure and primitive compatibility.
|
|
|
|
Complex reference types define shape through member presence: objects require
|
|
their declared members and reject undeclared members, while lists apply their
|
|
first declared member shape without comparing cardinality. An empty reference
|
|
list is intentionally under-specified and therefore leaves list members
|
|
unconstrained. Native primitives use reference-directed compatibility: matching
|
|
primitive types are valid, and an integer can be marshalled to a declared float,
|
|
but a float cannot be marshalled to a declared integer. Disjunctive validation
|
|
admits any complete reference; conjunctive validation recursively overlays
|
|
objects in order, prefers a specified side over null during collisions, and
|
|
lets the right reference replace primitives and lists.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from functools import reduce
|
|
from typing import cast
|
|
|
|
|
|
ITEMS = "__items__"
|
|
_TYPE_LABELS: dict[type[object], str] = {
|
|
dict: "an object",
|
|
list: "a list",
|
|
str: "a string",
|
|
int: "an int",
|
|
float: "a float",
|
|
bool: "a boolean",
|
|
type(None): "null",
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ValidationLocation:
|
|
"""Store validation path segments and render their dotted location.
|
|
|
|
Example: ``ValidationLocation(["A", "b", ITEMS, "0"]).stringify()``
|
|
returns ``"A.b[0]"`` while retaining parseable path parts.
|
|
"""
|
|
|
|
parts: list[str]
|
|
|
|
def child(self, part: str) -> ValidationLocation:
|
|
"""Return an independent location extended by one nested part."""
|
|
|
|
return ValidationLocation([*self.parts, part])
|
|
|
|
def stringify(self) -> str:
|
|
"""Return the location as one dotted path."""
|
|
|
|
rendered: list[str] = []
|
|
item = False
|
|
# Render ordinary members with dots and reserved list members with brackets.
|
|
for part in self.parts:
|
|
# Reserve the item marker for the following list index.
|
|
if part == ITEMS:
|
|
item = True
|
|
# Attach a list index to its owning list without a dotted separator.
|
|
elif item:
|
|
# Preserve bracket rendering for a root-level list item.
|
|
if rendered:
|
|
rendered[-1] = f"{rendered[-1]}[{part}]"
|
|
else:
|
|
rendered.append(f"[{part}]")
|
|
item = False
|
|
# Retain ordinary object members as dotted path segments.
|
|
else:
|
|
rendered.append(part)
|
|
return ".".join(rendered)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ValidationError:
|
|
"""Pair one invalid value's owning location with its validation error.
|
|
|
|
Example: ``error.stringify()`` combines ``A`` with ``b must be a string``.
|
|
"""
|
|
|
|
location: ValidationLocation
|
|
error: str
|
|
|
|
def stringify(self) -> str:
|
|
"""Return the location and validation error as one correction message."""
|
|
|
|
location = self.location.stringify()
|
|
return f"{location}: {self.error}" if location else self.error
|
|
|
|
|
|
class SemanticValidationError(ValidationError):
|
|
"""Mark a shape-admitted domain error without changing feedback rendering."""
|
|
|
|
|
|
class MechanicalValidationError(ValidationError):
|
|
"""Mark a deterministic verification error without changing feedback rendering."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ValidationResult:
|
|
"""Record valid admission or every structured invalid result.
|
|
|
|
Example: ``result.valid`` admits a value, while ``result.stringify()``
|
|
renders all invalid locations and errors for a retrying caller.
|
|
"""
|
|
|
|
errors: tuple[ValidationError, ...] = ()
|
|
|
|
@property
|
|
def valid(self) -> bool:
|
|
"""Return whether no validation error rejected the value."""
|
|
|
|
return not self.errors
|
|
|
|
def stringify(self) -> str:
|
|
"""Return every structured validation error on its own line."""
|
|
|
|
return "\n".join(map(ValidationError.stringify, self.errors))
|
|
|
|
|
|
_VALID = ValidationResult()
|
|
|
|
|
|
def _merge(left: object, right: object) -> object:
|
|
"""Overlay two references, preferring a specified side over null."""
|
|
|
|
# Preserve the specified side when null collides with a declaration.
|
|
if left is None or right is None:
|
|
return right if left is None else left
|
|
# Replace primitives, lists, and cross-kind references from the right.
|
|
if not isinstance(left, dict) or not isinstance(right, dict):
|
|
return right
|
|
|
|
merged = dict(left)
|
|
# Recursively resolve only members declared by both object references.
|
|
for name, value in right.items():
|
|
# Apply collision rules without changing unmatched member declarations.
|
|
if name in merged:
|
|
merged[name] = _merge(merged[name], value)
|
|
else:
|
|
merged[name] = value
|
|
return merged
|
|
|
|
|
|
def _validate_reference(
|
|
data: object,
|
|
reference: object,
|
|
*,
|
|
location: ValidationLocation,
|
|
member: str | None = None,
|
|
item_index: int | None = None,
|
|
) -> ValidationResult:
|
|
"""Validate one structural or primitive value against one reference."""
|
|
|
|
value_location = location
|
|
member_name = member
|
|
# Advance object members and list members to their respective value locations.
|
|
if item_index is not None:
|
|
value_location = location.child(ITEMS).child(str(item_index))
|
|
member_name = f"[{item_index}]"
|
|
elif member is not None:
|
|
value_location = location.child(member)
|
|
data_type = type(data)
|
|
reference_type = type(reference)
|
|
compatible_type = (
|
|
data_type is reference_type
|
|
or (reference_type is float and data_type is int)
|
|
)
|
|
# Assign nullability to the container and other invalid types to the existing value.
|
|
if not compatible_type:
|
|
nullability = data is None or reference is None
|
|
error_location = location if nullability else value_location
|
|
member_prefix = (
|
|
f"member {member_name} "
|
|
if nullability and member_name is not None
|
|
else ""
|
|
)
|
|
return ValidationResult(errors=(ValidationError(
|
|
location=error_location,
|
|
error=(
|
|
f"{member_prefix}expected "
|
|
f"{_TYPE_LABELS.get(reference_type, reference_type.__name__)} "
|
|
f"but found {_TYPE_LABELS.get(data_type, data_type.__name__)}."
|
|
),
|
|
),))
|
|
|
|
errors: list[ValidationError] = []
|
|
|
|
# Require every referenced object field and reject every unreferenced field.
|
|
if isinstance(reference, dict):
|
|
actual = cast("dict[str, object]", data)
|
|
missing = set(reference) - set(actual)
|
|
unexpected = set(actual) - set(reference)
|
|
# Assign absent member existence to the object that should contain it.
|
|
for name in sorted(missing, key=str):
|
|
errors.append(ValidationError(
|
|
location=value_location,
|
|
error=f"missing member {name}.",
|
|
))
|
|
# Assign unexpected member existence to the object that contains it.
|
|
for name in sorted(unexpected, key=str):
|
|
errors.append(ValidationError(
|
|
location=value_location,
|
|
error=f"unexpected member {name}.",
|
|
))
|
|
# Validate every available object member against its corresponding reference member.
|
|
for name, expected in reference.items():
|
|
# Missing members are already represented by the complete-membership error.
|
|
if name in actual:
|
|
result = _validate_reference(
|
|
actual[name],
|
|
expected,
|
|
location=value_location,
|
|
member=name,
|
|
)
|
|
errors.extend(result.errors)
|
|
|
|
# Treat the first reference item as the reusable shape of every array member.
|
|
if isinstance(reference, list):
|
|
# Validate members only when the reference declares a member shape.
|
|
if reference:
|
|
actual_items = cast("list[object]", data)
|
|
expected_item = reference[0]
|
|
# Validate every array member without comparing actual/reference cardinality.
|
|
for index, actual_item in enumerate(actual_items):
|
|
result = _validate_reference(
|
|
actual_item,
|
|
expected_item,
|
|
location=value_location,
|
|
item_index=index,
|
|
)
|
|
errors.extend(result.errors)
|
|
|
|
return ValidationResult(errors=tuple(errors))
|
|
|
|
|
|
def validate_shape(
|
|
data: object,
|
|
references: list[object],
|
|
*,
|
|
location: ValidationLocation,
|
|
conjunctive: bool = False,
|
|
) -> ValidationResult:
|
|
"""Admit data when any complete reference shape is compatible.
|
|
|
|
Disjunctive validation admits any complete reference. Conjunctive validation
|
|
recursively overlays object references in order; the right reference replaces
|
|
primitive, list, and cross-kind collisions, while a null/non-null collision
|
|
retains the non-null side. Unmatched and null/null members remain declared.
|
|
Complex types are compared recursively by member presence and member shape. Native
|
|
primitives follow reference-directed marshalling compatibility rather than
|
|
literal equality. An empty reference list leaves list members unconstrained;
|
|
an empty ``references`` argument admits no value.
|
|
"""
|
|
|
|
# Reject an absent union contract because no reference can admit the value.
|
|
if not references:
|
|
return ValidationResult(errors=(ValidationError(
|
|
location=location,
|
|
error="at least one reference shape is required.",
|
|
),))
|
|
|
|
effective = references
|
|
# Form one ordered reference before validating conjunctive input.
|
|
if conjunctive:
|
|
effective = [reduce(_merge, references)]
|
|
|
|
matched = False
|
|
errors: list[ValidationError] = []
|
|
# Evaluate every allowed reference so invalid unions retain all correction evidence.
|
|
for reference in effective:
|
|
result = _validate_reference(
|
|
data,
|
|
reference,
|
|
location=location,
|
|
)
|
|
# Remember any complete alternative without retaining other alternatives' errors.
|
|
if result.valid:
|
|
matched = True
|
|
else:
|
|
errors.extend(result.errors)
|
|
|
|
# Admit the value when any one reference matched completely.
|
|
if matched:
|
|
return _VALID
|
|
return ValidationResult(errors=tuple(errors))
|