Coverage for src/keel/jsonschema_min.py: 100%
95 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +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``, ``minimum``, ``maximum``.
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)
79 elif isinstance(instance, (int, float)) and not isinstance(instance, bool):
80 _validate_number(instance, schema, path, errors)
83def _validate_string(instance: str, schema: dict, path: str, errors: list[str]) -> None:
84 pat = schema.get("pattern")
85 if pat is not None and re.search(pat, instance) is None:
86 errors.append(f"{path}: {instance!r} does not match pattern {pat!r}")
87 min_len = schema.get("minLength")
88 if min_len is not None and len(instance) < min_len:
89 errors.append(f"{path}: shorter than minLength {min_len}")
92def _validate_number(instance: int | float, schema: dict, path: str, errors: list[str]) -> None:
93 minimum = schema.get("minimum")
94 if minimum is not None and instance < minimum:
95 errors.append(f"{path}: {instance!r} is less than minimum {minimum}")
96 maximum = schema.get("maximum")
97 if maximum is not None and instance > maximum:
98 errors.append(f"{path}: {instance!r} is greater than maximum {maximum}")
101def _validate_array(instance: list, schema: dict, path: str, errors: list[str]) -> None:
102 min_items = schema.get("minItems")
103 if min_items is not None and len(instance) < min_items:
104 errors.append(f"{path}: fewer than minItems {min_items}")
105 item_schema = schema.get("items")
106 if isinstance(item_schema, dict):
107 for i, item in enumerate(instance):
108 _validate(item, item_schema, f"{path}[{i}]", errors)
111def _validate_object(instance: dict, schema: dict, path: str, errors: list[str]) -> None:
112 for key in schema.get("required", []):
113 if key not in instance:
114 errors.append(f"{path}: missing required property {key!r}")
116 props = schema.get("properties", {})
117 for key, subschema in props.items():
118 if key in instance:
119 child = f"{path}.{key}" if path != "$" else f"$.{key}"
120 _validate(instance[key], subschema, child, errors)
122 additional = schema.get("additionalProperties", True)
123 if additional is False:
124 for key in instance:
125 if key not in props:
126 errors.append(f"{path}: unknown property {key!r}")
127 elif isinstance(additional, dict):
128 for key, value in instance.items():
129 if key not in props:
130 child = f"{path}.{key}" if path != "$" else f"$.{key}"
131 _validate(value, additional, child, errors)
134def _kind(value: Any) -> str:
135 if isinstance(value, bool):
136 return "boolean"
137 if isinstance(value, dict):
138 return "object"
139 if isinstance(value, list):
140 return "array"
141 if isinstance(value, str):
142 return "string"
143 if isinstance(value, int):
144 return "integer"
145 if isinstance(value, float):
146 return "number"
147 if value is None:
148 return "null"
149 return type(value).__name__