163 lines
5.4 KiB
Python
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))
|