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

1"""A tiny, dependency-free JSON-Schema validator (draft-07 subset). 

2 

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. 

7 

8Supported keywords 

9------------------ 

10``type`` (incl. list-of-types), ``const``, ``enum``, ``required``, 

11``properties``, ``additionalProperties`` (bool), ``items``, ``minItems``, 

12``pattern``, ``minLength``. 

13 

14Anything else in a schema is ignored (forward-compatible), so the schema must not 

15rely on unsupported keywords for enforcement. 

16""" 

17 

18from __future__ import annotations 

19 

20import re 

21from typing import Any 

22 

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} 

34 

35 

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) 

46 

47 

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 

53 

54 

55def _validate(instance: Any, schema: dict, path: str, errors: list[str]) -> None: 

56 if not isinstance(schema, dict): 

57 return 

58 

59 if "const" in schema and instance != schema["const"]: 

60 errors.append(f"{path}: must equal {schema['const']!r} (got {instance!r})") 

61 

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

64 

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 

72 

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 

80 

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

88 

89 

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) 

98 

99 

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

104 

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) 

110 

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) 

121 

122 

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__