validate.py
scripts/validate.py
#!/usr/bin/env python3
"""Check a JSON file against a small subset of JSON Schema.
Usage: python3 scripts/validate.py <schema.json> <file.json>
Supported keywords: type, required, properties, additionalProperties (false), items,
minimum, maximum, enum, minItems, maxItems, and format date or date-time.
Annotation keywords ($schema, title, description) are ignored. Any other keyword
stops the check, so a rule is never skipped without a message.
Exit codes: 0 valid, 1 not valid, 2 usage error or unreadable file.
"""
import json
import re
import sys
from datetime import date, datetime
IGNORED = {"$schema", "title", "description"}
SUPPORTED = {"type", "required", "properties", "additionalProperties", "items",
"minimum", "maximum", "enum", "minItems", "maxItems", "format"}
def is_type(value, name: str) -> bool:
if name == "boolean":
return isinstance(value, bool)
if name == "integer":
return isinstance(value, int) and not isinstance(value, bool)
if name == "number":
return isinstance(value, (int, float)) and not isinstance(value, bool)
return {
"object": isinstance(value, dict),
"array": isinstance(value, list),
"string": isinstance(value, str),
"null": value is None,
}[name]
def valid_format(value: str, name: str) -> bool:
try:
if name == "date":
return bool(re.fullmatch(r"\d{4}-\d{2}-\d{2}", value)) and bool(date.fromisoformat(value))
if name == "date-time":
datetime.fromisoformat(value.replace("Z", "+00:00"))
return True
except ValueError:
return False
return True
def check(value, schema: dict, path: str, errors: list[str]) -> None:
unsupported = set(schema) - SUPPORTED - IGNORED
if unsupported:
raise ValueError(f"{path}: schema keyword not supported: {', '.join(sorted(unsupported))}")
if "type" in schema:
names = schema["type"] if isinstance(schema["type"], list) else [schema["type"]]
if not any(is_type(value, n) for n in names):
errors.append(f"{path}: expected {' or '.join(names)}, got {type(value).__name__}")
return
if "enum" in schema and value not in schema["enum"]:
errors.append(f"{path}: {value!r} is not one of {schema['enum']}")
if "format" in schema and isinstance(value, str) and not valid_format(value, schema["format"]):
errors.append(f"{path}: {value!r} is not a valid {schema['format']}")
if is_type(value, "number"):
if "minimum" in schema and value < schema["minimum"]:
errors.append(f"{path}: {value} is below the minimum {schema['minimum']}")
if "maximum" in schema and value > schema["maximum"]:
errors.append(f"{path}: {value} is above the maximum {schema['maximum']}")
if isinstance(value, dict):
for key in schema.get("required", []):
if key not in value:
errors.append(f"{path}: missing required field '{key}'")
props = schema.get("properties", {})
if schema.get("additionalProperties") is False:
for key in value:
if key not in props:
errors.append(f"{path}: field '{key}' is not allowed")
for key, sub in props.items():
if key in value:
check(value[key], sub, f"{path}.{key}", errors)
if isinstance(value, list):
if "minItems" in schema and len(value) < schema["minItems"]:
errors.append(f"{path}: {len(value)} items, at least {schema['minItems']} needed")
if "maxItems" in schema and len(value) > schema["maxItems"]:
errors.append(f"{path}: {len(value)} items, at most {schema['maxItems']} allowed")
if "items" in schema:
for i, item in enumerate(value):
check(item, schema["items"], f"{path}[{i}]", errors)
def main(argv: list[str]) -> int:
if len(argv) != 3:
print("Usage: python3 scripts/validate.py <schema.json> <file.json>", file=sys.stderr)
return 2
try:
with open(argv[1], encoding="utf-8") as f:
schema = json.load(f)
with open(argv[2], encoding="utf-8") as f:
value = json.load(f)
except (OSError, json.JSONDecodeError) as err:
print(f"Could not read the files: {err}", file=sys.stderr)
return 2
errors: list[str] = []
try:
check(value, schema, "$", errors)
except ValueError as err:
print(f"Stopped: {err}", file=sys.stderr)
return 2
if errors:
print(f"NOT VALID: {argv[2]} does not match {argv[1]}", file=sys.stderr)
for e in errors:
print(f" {e}", file=sys.stderr)
return 1
print(f"Valid: {argv[2]} matches {argv[1]}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))