generated from coulomb/repo-seed
109 lines
3.7 KiB
Python
109 lines
3.7 KiB
Python
"""Minimal JSON-schema-like validation for guide-board contracts.
|
|
|
|
The first core should work from a clean checkout without pulling dependencies.
|
|
This validator intentionally supports only the schema features used by the
|
|
project's own draft contracts.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from guide_board.errors import ValidationError
|
|
from guide_board.io import load_json
|
|
|
|
|
|
SCHEMA_DIR = Path(__file__).resolve().parents[2] / "docs" / "schemas"
|
|
|
|
|
|
def load_schema(schema_name: str) -> dict[str, Any]:
|
|
return load_json(SCHEMA_DIR / f"{schema_name}.schema.json")
|
|
|
|
|
|
def validate_document(document: Any, schema: dict[str, Any], path: str = "$") -> list[str]:
|
|
errors: list[str] = []
|
|
_validate(document, schema, path, errors)
|
|
return errors
|
|
|
|
|
|
def assert_valid(document: Any, schema_name: str) -> None:
|
|
schema = load_schema(schema_name)
|
|
errors = validate_document(document, schema)
|
|
if errors:
|
|
formatted = "\n".join(f"- {error}" for error in errors)
|
|
raise ValidationError(f"{schema_name} validation failed:\n{formatted}")
|
|
|
|
|
|
def _validate(value: Any, schema: dict[str, Any], path: str, errors: list[str]) -> None:
|
|
if "type" in schema and not _matches_type(value, schema["type"]):
|
|
errors.append(f"{path}: expected {schema['type']}, got {_type_name(value)}")
|
|
return
|
|
|
|
if "enum" in schema and value not in schema["enum"]:
|
|
allowed = ", ".join(repr(item) for item in schema["enum"])
|
|
errors.append(f"{path}: expected one of {allowed}, got {value!r}")
|
|
|
|
if isinstance(value, dict):
|
|
required = schema.get("required", [])
|
|
for key in required:
|
|
if key not in value:
|
|
errors.append(f"{path}: missing required property {key!r}")
|
|
|
|
properties = schema.get("properties", {})
|
|
additional_allowed = schema.get("additionalProperties", True)
|
|
for key, child in value.items():
|
|
child_path = f"{path}.{key}"
|
|
if key in properties:
|
|
_validate(child, properties[key], child_path, errors)
|
|
elif additional_allowed is False:
|
|
errors.append(f"{child_path}: unexpected property")
|
|
|
|
if isinstance(value, list):
|
|
min_items = schema.get("minItems")
|
|
if isinstance(min_items, int) and len(value) < min_items:
|
|
errors.append(f"{path}: expected at least {min_items} item(s)")
|
|
|
|
item_schema = schema.get("items")
|
|
if isinstance(item_schema, dict):
|
|
for index, child in enumerate(value):
|
|
_validate(child, item_schema, f"{path}[{index}]", errors)
|
|
|
|
|
|
def _matches_type(value: Any, expected: str | list[str]) -> bool:
|
|
if isinstance(expected, list):
|
|
return any(_matches_type(value, item) for item in expected)
|
|
if expected == "object":
|
|
return isinstance(value, dict)
|
|
if expected == "array":
|
|
return isinstance(value, list)
|
|
if expected == "string":
|
|
return isinstance(value, str)
|
|
if expected == "integer":
|
|
return isinstance(value, int) and not isinstance(value, bool)
|
|
if expected == "number":
|
|
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
if expected == "boolean":
|
|
return isinstance(value, bool)
|
|
if expected == "null":
|
|
return value is None
|
|
return True
|
|
|
|
|
|
def _type_name(value: Any) -> str:
|
|
if isinstance(value, bool):
|
|
return "boolean"
|
|
if isinstance(value, dict):
|
|
return "object"
|
|
if isinstance(value, list):
|
|
return "array"
|
|
if isinstance(value, str):
|
|
return "string"
|
|
if isinstance(value, int):
|
|
return "integer"
|
|
if isinstance(value, float):
|
|
return "number"
|
|
if value is None:
|
|
return "null"
|
|
return type(value).__name__
|