Zerf home

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))