Coverage for src/keel/jsonschema_min.py: 100%
86 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-16 18:07 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-16 18:07 +0000
1"""A tiny, dependency-free JSON-Schema validator (draft-07 subset).
3keel validates ``project.yaml`` against ``projects/schema/project.schema.json``.
4Rather than take a runtime dependency on ``jsonschema``, we implement exactly the
5subset of keywords the schema uses. The function is pure and deterministic:
6identical inputs always yield the same ordered list of error strings.
8Supported keywords
9------------------
10``type`` (incl. list-of-types), ``const``, ``enum``, ``required``,
11``properties``, ``additionalProperties`` (bool), ``items``, ``minItems``,
12``pattern``, ``minLength``.
14Anything else in a schema is ignored (forward-compatible), so the schema must not
15rely on unsupported keywords for enforcement.
16"""
18from __future__ import annotations
20import re
21from typing import Any
23# JSON-Schema type name -> Python type(s). ``int`` is excluded from "number"
24# only conceptually; JSON has no separate int, so we accept both for "number".
25_TYPES: dict[str, tuple[type, ...]] = {
26 "object": (dict,),
27 "array": (list,),
28 "string": (str,),
29 "integer": (int,),
30 "number": (int, float),
31 "boolean": (bool,),
32 "null": (type(None),),
33}
36def _type_matches(value: Any, type_name: str) -> bool:
37 py = _TYPES.get(type_name)
38 if py is None:
39 return True # unknown type name -> do not enforce
40 # bool is a subclass of int; keep them distinct for "integer"/"number".
41 if type_name in ("integer", "number") and isinstance(value, bool):
42 return False
43 if type_name == "boolean":
44 return isinstance(value, bool)
45 return isinstance(value, py)
48def validate(instance: Any, schema: dict, path: str = "$") -> list[str]:
49 """Return an ordered list of human-readable error strings (empty == valid)."""
50 errors: list[str] = []
51 _validate(instance, schema, path, errors)
52 return errors
55def _validate(instance: Any, schema: dict, path: str, errors: list[str]) -> None:
56 if not isinstance(schema, dict):
57 return
59 if "const" in schema and instance != schema["const"]:
60 errors.append(f"{path}: must equal {schema['const']!r} (got {instance!r})")
62 if "enum" in schema and instance not in schema["enum"]:
63 errors.append(f"{path}: must be one of {schema['enum']!r} (got {instance!r})")
65 if "type" in schema:
66 types = schema["type"]
67 types = [types] if isinstance(types, str) else types
68 if not any(_type_matches(instance, t) for t in types):
69 errors.append(f"{path}: expected type {'/'.join(types)} (got {_kind(instance)})")
70 # If the basic type is wrong, deeper checks would be noisy; stop here.
71 return
73 if isinstance(instance, str):
74 _validate_string(instance, schema, path, errors)
75 elif isinstance(instance, list):
76 _validate_array(instance, schema, path, errors)
77 elif isinstance(instance, dict):
78 _validate_object(instance, schema, path, errors)
81def _validate_string(instance: str, schema: dict, path: str, errors: list[str]) -> None:
82 pat = schema.get("pattern")
83 if pat is not None and re.search(pat, instance) is None:
84 errors.append(f"{path}: {instance!r} does not match pattern {pat!r}")
85 min_len = schema.get("minLength")
86 if min_len is not None and len(instance) < min_len:
87 errors.append(f"{path}: shorter than minLength {min_len}")
90def _validate_array(instance: list, schema: dict, path: str, errors: list[str]) -> None:
91 min_items = schema.get("minItems")
92 if min_items is not None and len(instance) < min_items:
93 errors.append(f"{path}: fewer than minItems {min_items}")
94 item_schema = schema.get("items")
95 if isinstance(item_schema, dict):
96 for i, item in enumerate(instance):
97 _validate(item, item_schema, f"{path}[{i}]", errors)
100def _validate_object(instance: dict, schema: dict, path: str, errors: list[str]) -> None:
101 for key in schema.get("required", []):
102 if key not in instance:
103 errors.append(f"{path}: missing required property {key!r}")
105 props = schema.get("properties", {})
106 for key, subschema in props.items():
107 if key in instance:
108 child = f"{path}.{key}" if path != "$" else f"$.{key}"
109 _validate(instance[key], subschema, child, errors)
111 additional = schema.get("additionalProperties", True)
112 if additional is False:
113 for key in instance:
114 if key not in props:
115 errors.append(f"{path}: unknown property {key!r}")
116 elif isinstance(additional, dict):
117 for key, value in instance.items():
118 if key not in props:
119 child = f"{path}.{key}" if path != "$" else f"$.{key}"
120 _validate(value, additional, child, errors)
123def _kind(value: Any) -> str:
124 if isinstance(value, bool):
125 return "boolean"
126 if isinstance(value, dict):
127 return "object"
128 if isinstance(value, list):
129 return "array"
130 if isinstance(value, str):
131 return "string"
132 if isinstance(value, int):
133 return "integer"
134 if isinstance(value, float):
135 return "number"
136 if value is None:
137 return "null"
138 return type(value).__name__