Files
guide-board/src/guide_board/schema.py

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__