magicciv/tools/autoplay-validate.py
Natalie 979cd0ca26 feat(@projects/@magic-civilization): add combat and city tracking stats
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-04-16 17:51:23 -07:00

163 lines
5.4 KiB
Python

#!/usr/bin/env python3
"""
Minimal JSON Schema validator for autoplay-result-schema.json.
Implements the subset of draft-07 used by the schema:
type, required, additionalProperties, properties, propertyNames.pattern,
minimum, enum, items, $ref (local only).
stdlib only — no pip installs.
Usage:
from autoplay_validate import load_schema, validate
schema = load_schema()
errors = validate(data, schema)
if errors: ...
CLI:
python3 tools/autoplay-validate.py path/to/result.json
exits 0 if valid, 1 with errors to stderr otherwise.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from typing import Any
SCHEMA_PATH = Path(__file__).parent / "autoplay-result-schema.json"
def load_schema(path: Path = SCHEMA_PATH) -> dict[str, Any]:
with path.open() as f:
return json.load(f)
_TYPE_CHECKS: dict[str, type | tuple[type, ...]] = {
"object": dict,
"array": list,
"string": str,
"integer": int,
"number": (int, float),
"boolean": bool,
"null": type(None),
}
def _resolve_ref(ref: str, root: dict[str, Any]) -> dict[str, Any]:
# Local refs only: "#/definitions/foo"
if not ref.startswith("#/"):
raise ValueError(f"only local refs supported, got {ref!r}")
node: Any = root
for part in ref[2:].split("/"):
if not isinstance(node, dict) or part not in node:
raise ValueError(f"ref {ref!r} does not resolve")
node = node[part]
return node
def _validate(
value: Any, schema: dict[str, Any], root: dict[str, Any], path: str
) -> list[str]:
errors: list[str] = []
if "$ref" in schema:
schema = _resolve_ref(schema["$ref"], root)
t = schema.get("type")
if t is not None:
expected = _TYPE_CHECKS.get(t)
if expected is None:
errors.append(f"{path}: unknown schema type {t!r}")
return errors
# JSON has no separate int — bool is a subclass of int in Python; reject booleans as numbers.
if t in ("integer", "number") and isinstance(value, bool):
errors.append(f"{path}: expected {t}, got boolean")
return errors
if t == "integer" and isinstance(value, float) and not value.is_integer():
errors.append(f"{path}: expected integer, got float {value}")
return errors
if not isinstance(value, expected):
errors.append(
f"{path}: expected {t}, got {type(value).__name__}"
)
return errors
if "enum" in schema:
if value not in schema["enum"]:
errors.append(f"{path}: {value!r} not in enum {schema['enum']}")
if "minimum" in schema and isinstance(value, (int, float)):
if value < schema["minimum"]:
errors.append(
f"{path}: {value} < minimum {schema['minimum']}"
)
if "pattern" in schema and isinstance(value, str):
if not re.match(schema["pattern"], value):
errors.append(f"{path}: {value!r} does not match pattern {schema['pattern']!r}")
if t == "object" and isinstance(value, dict):
props: dict[str, Any] = schema.get("properties", {})
required: list[str] = schema.get("required", [])
additional: bool | dict[str, Any] = schema.get("additionalProperties", True)
prop_names: dict[str, Any] | None = schema.get("propertyNames")
for req in required:
if req not in value:
errors.append(f"{path}: missing required property {req!r}")
for k, v in value.items():
kpath = f"{path}.{k}"
if prop_names is not None:
errors.extend(_validate(k, prop_names, root, f"{kpath}<key>"))
if k in props:
errors.extend(_validate(v, props[k], root, kpath))
elif additional is False:
errors.append(f"{path}: unexpected property {k!r}")
elif isinstance(additional, dict):
errors.extend(_validate(v, additional, root, kpath))
if t == "array" and isinstance(value, list):
item_schema = schema.get("items")
if item_schema is not None:
for i, item in enumerate(value):
errors.extend(_validate(item, item_schema, root, f"{path}[{i}]"))
return errors
def validate(data: Any, schema: dict[str, Any] | None = None) -> list[str]:
"""Validate data against the schema. Returns list of error strings (empty = valid)."""
s = schema if schema is not None else load_schema()
return _validate(data, s, s, "$")
def _main(argv: list[str]) -> int:
if len(argv) < 2:
print("usage: autoplay-validate.py <result.json> [<result.json> ...]", file=sys.stderr)
return 2
schema = load_schema()
total_errors = 0
for p in argv[1:]:
path = Path(p)
try:
data = json.loads(path.read_text())
except (OSError, json.JSONDecodeError) as e:
print(f"{path}: cannot load ({e})", file=sys.stderr)
total_errors += 1
continue
errs = validate(data, schema)
if errs:
total_errors += len(errs)
print(f"{path}: {len(errs)} error(s)", file=sys.stderr)
for e in errs:
print(f" {e}", file=sys.stderr)
else:
print(f"{path}: OK", file=sys.stderr)
return 1 if total_errors else 0
if __name__ == "__main__":
sys.exit(_main(sys.argv))