super-easy-validator-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- super_easy_validator_python/__init__.py +55 -0
- super_easy_validator_python/checks.py +525 -0
- super_easy_validator_python/codes.py +143 -0
- super_easy_validator_python/formats.py +316 -0
- super_easy_validator_python/paths.py +162 -0
- super_easy_validator_python/py.typed +0 -0
- super_easy_validator_python/rules.py +205 -0
- super_easy_validator_python/types.py +136 -0
- super_easy_validator_python/validator.py +685 -0
- super_easy_validator_python-0.1.0.dist-info/METADATA +280 -0
- super_easy_validator_python-0.1.0.dist-info/RECORD +14 -0
- super_easy_validator_python-0.1.0.dist-info/WHEEL +5 -0
- super_easy_validator_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- super_easy_validator_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
"""The validation engine: traversal, operators, groups and strict mode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, Optional
|
|
6
|
+
|
|
7
|
+
from . import codes
|
|
8
|
+
from .checks import check_single
|
|
9
|
+
from .paths import (
|
|
10
|
+
has_index_syntax,
|
|
11
|
+
is_array,
|
|
12
|
+
is_object,
|
|
13
|
+
is_slice_key,
|
|
14
|
+
lookup,
|
|
15
|
+
resolve,
|
|
16
|
+
resolve_indexed_path,
|
|
17
|
+
)
|
|
18
|
+
from .rules import (
|
|
19
|
+
GROUP_ATLEAST,
|
|
20
|
+
GROUP_ATMOST,
|
|
21
|
+
assert_known_token,
|
|
22
|
+
assert_valid_tokens,
|
|
23
|
+
custom_error,
|
|
24
|
+
group_size,
|
|
25
|
+
has_token,
|
|
26
|
+
is_group_key,
|
|
27
|
+
is_operator_key,
|
|
28
|
+
tokenize,
|
|
29
|
+
)
|
|
30
|
+
from .types import (
|
|
31
|
+
BRANCH_CASE,
|
|
32
|
+
BRANCH_DEFAULT,
|
|
33
|
+
BRANCH_THEN,
|
|
34
|
+
OP_AND,
|
|
35
|
+
OP_OR,
|
|
36
|
+
OP_SWITCH,
|
|
37
|
+
Config,
|
|
38
|
+
Data,
|
|
39
|
+
Detail,
|
|
40
|
+
InvalidRuleError,
|
|
41
|
+
Result,
|
|
42
|
+
Rules,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_MISSING = object()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def validate(rules: Rules, data: Optional[Data], config: Optional[Config] = None) -> Result:
|
|
49
|
+
"""Check data against rules.
|
|
50
|
+
|
|
51
|
+
Returns a Result whose ``errors`` is None when everything passed. A
|
|
52
|
+
malformed rule raises InvalidRuleError: that is a mistake in the rules, not
|
|
53
|
+
a validation failure.
|
|
54
|
+
"""
|
|
55
|
+
cfg = config or Config()
|
|
56
|
+
|
|
57
|
+
_assert_valid_rules(rules, "")
|
|
58
|
+
|
|
59
|
+
details: list[Detail] = []
|
|
60
|
+
_walk(rules, data, cfg, "", details)
|
|
61
|
+
|
|
62
|
+
return _finish(details, cfg)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _finish(details: list[Detail], cfg: Config) -> Result:
|
|
66
|
+
"""Apply the quote style and collapse duplicate messages."""
|
|
67
|
+
quote = cfg.quote_char
|
|
68
|
+
seen: set[str] = set()
|
|
69
|
+
out: list[Detail] = []
|
|
70
|
+
|
|
71
|
+
for d in details:
|
|
72
|
+
message = d["message"].replace('"', quote)
|
|
73
|
+
if message in seen:
|
|
74
|
+
continue
|
|
75
|
+
seen.add(message)
|
|
76
|
+
out.append({"field": d["field"], "message": message, "code": d["code"]})
|
|
77
|
+
|
|
78
|
+
if not out:
|
|
79
|
+
return Result()
|
|
80
|
+
return Result([d["message"] for d in out], out)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --- rule validation, before any data is examined -------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _assert_valid_rules(rules: Rules, prefix: str) -> None:
|
|
87
|
+
if not isinstance(rules, dict):
|
|
88
|
+
raise InvalidRuleError(prefix or "rules", "must be a dict")
|
|
89
|
+
|
|
90
|
+
for key, value in rules.items():
|
|
91
|
+
field = f"{prefix}.{key}" if prefix else key
|
|
92
|
+
|
|
93
|
+
if is_group_key(key):
|
|
94
|
+
if not isinstance(value, (str, list)):
|
|
95
|
+
raise InvalidRuleError(field, "must be a rule string or a list of rule strings")
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
_assert_valid_rule_value(value, field)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _assert_valid_rule_value(value: Any, field: str) -> None:
|
|
102
|
+
if value is None:
|
|
103
|
+
raise InvalidRuleError(field, "has an invalid rule: it must not be None")
|
|
104
|
+
|
|
105
|
+
if callable(value):
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
if isinstance(value, str):
|
|
109
|
+
tokens = value.split("|")
|
|
110
|
+
assert_valid_tokens(tokens, field)
|
|
111
|
+
_assert_user_regexes(tokens, field)
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
if isinstance(value, list):
|
|
115
|
+
if value and all(isinstance(e, str) for e in value):
|
|
116
|
+
assert_valid_tokens(value, field)
|
|
117
|
+
_assert_user_regexes(value, field)
|
|
118
|
+
return
|
|
119
|
+
if len(value) != 1:
|
|
120
|
+
raise InvalidRuleError(field, "has an invalid rule: a list rule must have exactly one element")
|
|
121
|
+
_assert_valid_rule_value(value[0], field)
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
if isinstance(value, dict):
|
|
125
|
+
if _is_operator_node(value):
|
|
126
|
+
_assert_valid_operator(value, field)
|
|
127
|
+
return
|
|
128
|
+
_assert_valid_rules(value, field)
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
raise InvalidRuleError(field, f"has an invalid rule: unsupported rule type {type(value).__name__}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _assert_user_regexes(tokens: list[str], field: str) -> None:
|
|
135
|
+
from .formats import translate_user_regex
|
|
136
|
+
|
|
137
|
+
for t in tokens:
|
|
138
|
+
if t.startswith("regex:"):
|
|
139
|
+
try:
|
|
140
|
+
translate_user_regex(t[len("regex:") :])
|
|
141
|
+
except ValueError as exc:
|
|
142
|
+
raise InvalidRuleError(field, str(exc)) from None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _is_operator_node(value: dict) -> bool:
|
|
146
|
+
return any(is_operator_key(k) for k in value)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _operator_of(node: dict) -> tuple[str, list[Any]]:
|
|
150
|
+
for op in (OP_OR, OP_AND, OP_SWITCH):
|
|
151
|
+
if op in node:
|
|
152
|
+
branches = node[op]
|
|
153
|
+
return op, branches if isinstance(branches, list) else []
|
|
154
|
+
return "", []
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _assert_valid_operator(node: dict, field: str) -> None:
|
|
158
|
+
operators = [op for op in (OP_OR, OP_AND, OP_SWITCH) if op in node]
|
|
159
|
+
if len(operators) > 1:
|
|
160
|
+
raise InvalidRuleError(field, f"cannot combine {' and '.join(operators)} in one object")
|
|
161
|
+
|
|
162
|
+
operator = operators[0]
|
|
163
|
+
extras = sorted(k for k in node if k != operator)
|
|
164
|
+
if extras:
|
|
165
|
+
raise InvalidRuleError(
|
|
166
|
+
field,
|
|
167
|
+
f"cannot mix '{operator}' with other keys (found: {', '.join(extras)}); "
|
|
168
|
+
"wrap them in a branch instead",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
branches = node[operator]
|
|
172
|
+
if not isinstance(branches, list):
|
|
173
|
+
raise InvalidRuleError(field, f"has an invalid rule: '{operator}' must be a list of branches")
|
|
174
|
+
if not branches:
|
|
175
|
+
raise InvalidRuleError(field, f"has an invalid rule: '{operator}' needs at least one branch")
|
|
176
|
+
|
|
177
|
+
if operator == OP_SWITCH:
|
|
178
|
+
_assert_valid_switch(branches, field)
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
for branch in branches:
|
|
182
|
+
_assert_valid_rule_value(branch, field)
|
|
183
|
+
if operator == OP_OR and _only_modifiers(branch):
|
|
184
|
+
raise InvalidRuleError(
|
|
185
|
+
field,
|
|
186
|
+
"has an invalid rule: a '$or' branch cannot be only 'optional' or 'nullable', "
|
|
187
|
+
"because it would accept any value. Use '$and' to make the field optional.",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _assert_valid_switch(branches: list[Any], field: str) -> None:
|
|
192
|
+
defaults = 0
|
|
193
|
+
|
|
194
|
+
for branch in branches:
|
|
195
|
+
if not isinstance(branch, dict):
|
|
196
|
+
raise InvalidRuleError(
|
|
197
|
+
field,
|
|
198
|
+
"has an invalid rule: every '$switch' branch must be an object with 'case' and 'then'",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
extras = sorted(k for k in branch if k not in (BRANCH_CASE, BRANCH_THEN, BRANCH_DEFAULT))
|
|
202
|
+
if extras:
|
|
203
|
+
raise InvalidRuleError(
|
|
204
|
+
field,
|
|
205
|
+
"has an invalid rule: a '$switch' branch accepts only 'case', 'then' and 'default' "
|
|
206
|
+
f"(found: {', '.join(extras)})",
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if branch.get(BRANCH_CASE) is None:
|
|
210
|
+
raise InvalidRuleError(field, "has an invalid rule: a '$switch' branch needs a 'case'")
|
|
211
|
+
if branch.get(BRANCH_THEN) is None:
|
|
212
|
+
raise InvalidRuleError(field, "has an invalid rule: a '$switch' branch needs a 'then'")
|
|
213
|
+
|
|
214
|
+
if BRANCH_DEFAULT in branch:
|
|
215
|
+
flag = branch[BRANCH_DEFAULT]
|
|
216
|
+
if not isinstance(flag, bool):
|
|
217
|
+
raise InvalidRuleError(field, "has an invalid rule: '$switch' 'default' must be a boolean")
|
|
218
|
+
if flag:
|
|
219
|
+
defaults += 1
|
|
220
|
+
|
|
221
|
+
_assert_valid_rule_value(branch[BRANCH_CASE], field)
|
|
222
|
+
_assert_valid_rule_value(branch[BRANCH_THEN], field)
|
|
223
|
+
|
|
224
|
+
if defaults > 1:
|
|
225
|
+
raise InvalidRuleError(
|
|
226
|
+
field, "has an invalid rule: only one '$switch' branch may be marked 'default'"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _only_modifiers(branch: Any) -> bool:
|
|
231
|
+
tokens = tokenize(branch)
|
|
232
|
+
if tokens is None:
|
|
233
|
+
return False
|
|
234
|
+
return all(t in ("", "optional", "nullable") for t in tokens)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# --- traversal ------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _walk(rules: Rules, data: Any, cfg: Config, prefix: str, out: list[Detail]) -> None:
|
|
241
|
+
if data is None:
|
|
242
|
+
name = prefix or "data"
|
|
243
|
+
out.append({"field": name, "message": f'"{name}" is required', "code": codes.DATA_REQUIRED})
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
if not isinstance(data, (dict, list)):
|
|
247
|
+
name = prefix or "data"
|
|
248
|
+
out.append(
|
|
249
|
+
{
|
|
250
|
+
"field": name,
|
|
251
|
+
"message": f'"{name}" must be of type object/array',
|
|
252
|
+
"code": codes.DATA_NOT_OBJECT,
|
|
253
|
+
}
|
|
254
|
+
)
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
for key, value in rules.items():
|
|
258
|
+
if is_group_key(key):
|
|
259
|
+
_check_group(key, value, data, prefix, out)
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
field = f"{prefix}.{key}" if prefix else key
|
|
263
|
+
_apply_rule(key, field, value, data, cfg, prefix, out)
|
|
264
|
+
|
|
265
|
+
if cfg.strict:
|
|
266
|
+
_strict_check(rules, data, prefix, out)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _apply_rule(
|
|
270
|
+
key: str, field: str, value: Any, data: Data, cfg: Config, prefix: str, out: list[Detail]
|
|
271
|
+
) -> None:
|
|
272
|
+
if callable(value):
|
|
273
|
+
_run_custom(value, key, field, data, cfg, out)
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
if isinstance(value, dict):
|
|
277
|
+
if _is_operator_node(value):
|
|
278
|
+
_apply_operator(value, key, field, prefix, data, cfg, out)
|
|
279
|
+
else:
|
|
280
|
+
_apply_nested(value, key, field, data, cfg, out)
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
if isinstance(value, list) and not (value and all(isinstance(e, str) for e in value)):
|
|
284
|
+
_apply_tuple(value[0], key, field, data, cfg, out)
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
tokens = tokenize(value)
|
|
288
|
+
if tokens is not None:
|
|
289
|
+
_apply_tokens(tokens, key, field, data, cfg, prefix, out)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _apply_tokens(
|
|
293
|
+
tokens: list[str], key: str, field: str, data: Data, cfg: Config, prefix: str, out: list[Detail]
|
|
294
|
+
) -> None:
|
|
295
|
+
indexing = cfg.array_indexing_check and has_index_syntax(key)
|
|
296
|
+
|
|
297
|
+
if indexing and is_slice_key(key):
|
|
298
|
+
selected, labels = resolve_indexed_path(data, key)
|
|
299
|
+
if not is_array(selected):
|
|
300
|
+
out.append({"field": key, "message": f'"{key}" must be an array', "code": codes.NOT_ARRAY})
|
|
301
|
+
return
|
|
302
|
+
for i, element in enumerate(selected):
|
|
303
|
+
label = labels[i] if i < len(labels) else f"{key}[{i}]"
|
|
304
|
+
check_single(label, prefix, element, element is not None, tokens, out)
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
value = resolve(data, key, cfg.array_indexing_check)
|
|
308
|
+
present = _is_present(data, key, cfg)
|
|
309
|
+
check_single(key, prefix, value, present, tokens, out)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _is_present(data: Data, key: str, cfg: Config) -> bool:
|
|
313
|
+
"""Whether the key selects anything at all.
|
|
314
|
+
|
|
315
|
+
A resolved value of None is ambiguous — the key may be absent, or present
|
|
316
|
+
and holding None — so presence is decided separately.
|
|
317
|
+
"""
|
|
318
|
+
if cfg.array_indexing_check and has_index_syntax(key):
|
|
319
|
+
value, _ = resolve_indexed_path(data, key)
|
|
320
|
+
return value is not None
|
|
321
|
+
if "." not in key:
|
|
322
|
+
return isinstance(data, dict) and key in data
|
|
323
|
+
return resolve(data, key, cfg.array_indexing_check) is not None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _apply_nested(sub: Rules, key: str, field: str, data: Data, cfg: Config, out: list[Detail]) -> None:
|
|
327
|
+
value = data.get(key) if isinstance(data, dict) else None
|
|
328
|
+
if value is None:
|
|
329
|
+
out.append({"field": field, "message": f'"{field}" is required', "code": codes.REQUIRED})
|
|
330
|
+
return
|
|
331
|
+
if not is_object(value):
|
|
332
|
+
out.append(
|
|
333
|
+
{"field": field, "message": f'"{field}" must be of type object', "code": codes.NOT_OBJECT}
|
|
334
|
+
)
|
|
335
|
+
return
|
|
336
|
+
_walk(sub, value, cfg, field, out)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _apply_tuple(sub: Any, key: str, field: str, data: Data, cfg: Config, out: list[Detail]) -> None:
|
|
340
|
+
value = data.get(key) if isinstance(data, dict) else None
|
|
341
|
+
if value is None:
|
|
342
|
+
out.append({"field": field, "message": f'"{field}" is required', "code": codes.REQUIRED})
|
|
343
|
+
return
|
|
344
|
+
_apply_tuple_value(sub, field, value, cfg, out)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _apply_tuple_value(sub: Any, field: str, value: Any, cfg: Config, out: list[Detail]) -> None:
|
|
348
|
+
if not is_array(value):
|
|
349
|
+
out.append(
|
|
350
|
+
{"field": field, "message": f'"{field}" must be of type array', "code": codes.NOT_ARRAY}
|
|
351
|
+
)
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
nested = isinstance(sub, list) and len(sub) == 1
|
|
355
|
+
inner = sub[0] if nested else sub
|
|
356
|
+
|
|
357
|
+
for i, element in enumerate(value):
|
|
358
|
+
path = f"{field}[{i}]"
|
|
359
|
+
|
|
360
|
+
if nested:
|
|
361
|
+
_apply_tuple_value(inner, path, element, cfg, out)
|
|
362
|
+
continue
|
|
363
|
+
|
|
364
|
+
if element is None:
|
|
365
|
+
out.append({"field": path, "message": f'"{path}" is required', "code": codes.REQUIRED})
|
|
366
|
+
continue
|
|
367
|
+
if not is_object(element):
|
|
368
|
+
out.append(
|
|
369
|
+
{
|
|
370
|
+
"field": path,
|
|
371
|
+
"message": f'"{path}" must be of type object/array',
|
|
372
|
+
"code": codes.NOT_OBJECT,
|
|
373
|
+
}
|
|
374
|
+
)
|
|
375
|
+
continue
|
|
376
|
+
_walk(sub, element, cfg, path, out)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _run_custom(
|
|
380
|
+
fn: Callable[[Any, Any], Any], key: str, field: str, data: Data, cfg: Config, out: list[Detail]
|
|
381
|
+
) -> None:
|
|
382
|
+
value = resolve(data, key, cfg.array_indexing_check)
|
|
383
|
+
result = _call_custom(fn, value, data, field)
|
|
384
|
+
if result is None:
|
|
385
|
+
return
|
|
386
|
+
out.append({"field": field, "message": result["message"], "code": result["code"]})
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _call_custom(fn: Callable[[Any, Any], Any], value: Any, parent: Any, field: str) -> Optional[dict]:
|
|
390
|
+
"""Run a user function, converting a bad return or a crash into a rule error."""
|
|
391
|
+
try:
|
|
392
|
+
result = fn(value, parent)
|
|
393
|
+
except InvalidRuleError:
|
|
394
|
+
raise
|
|
395
|
+
except Exception as exc:
|
|
396
|
+
raise InvalidRuleError(field, f"custom rule raised an error: {exc}") from None
|
|
397
|
+
|
|
398
|
+
if result is None:
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
if not isinstance(result, dict):
|
|
402
|
+
raise InvalidRuleError(
|
|
403
|
+
field,
|
|
404
|
+
"has an invalid custom rule: the function must return None, or a dict with "
|
|
405
|
+
f"'message' and 'code' (received {result!r})",
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
message = result.get("message")
|
|
409
|
+
code = result.get("code", codes.CUSTOM_RULE_FAILED)
|
|
410
|
+
if not isinstance(message, str) or not message:
|
|
411
|
+
raise InvalidRuleError(
|
|
412
|
+
field, "has an invalid custom rule: the returned dict needs a non-empty 'message'"
|
|
413
|
+
)
|
|
414
|
+
if not isinstance(code, str) or not code:
|
|
415
|
+
code = codes.CUSTOM_RULE_FAILED
|
|
416
|
+
|
|
417
|
+
return {"message": message, "code": code}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# --- operators ------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _branch_errors(
|
|
424
|
+
branch: Any, key: str, field: str, value: Any, present: bool, parent: Data, cfg: Config
|
|
425
|
+
) -> list[Detail]:
|
|
426
|
+
short_key = field.rsplit(".", 1)[-1] if "." in field else field
|
|
427
|
+
parent_name = field.rsplit(".", 1)[0] if "." in field else ""
|
|
428
|
+
|
|
429
|
+
if callable(branch):
|
|
430
|
+
result = _call_custom(branch, value, parent, field)
|
|
431
|
+
if result is None:
|
|
432
|
+
return []
|
|
433
|
+
return [{"field": field, "message": result["message"], "code": result["code"]}]
|
|
434
|
+
|
|
435
|
+
wrapper: Rules = {short_key: branch}
|
|
436
|
+
inner: Data = {short_key: value} if present else {}
|
|
437
|
+
|
|
438
|
+
branch_cfg = Config(cfg.quotes, cfg.strict, cfg.array_indexing_check)
|
|
439
|
+
if has_index_syntax(short_key):
|
|
440
|
+
branch_cfg.array_indexing_check = False
|
|
441
|
+
|
|
442
|
+
details: list[Detail] = []
|
|
443
|
+
_walk(wrapper, inner, branch_cfg, parent_name, details)
|
|
444
|
+
return details
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _branch_has_modifier(branch: Any, modifier: str) -> bool:
|
|
448
|
+
tokens = tokenize(branch)
|
|
449
|
+
return tokens is not None and has_token(tokens, modifier)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _any_branch_has(branches: list[Any], modifier: str) -> bool:
|
|
453
|
+
return any(_branch_has_modifier(b, modifier) for b in branches)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _apply_operator(
|
|
457
|
+
node: dict, key: str, field: str, prefix: str, data: Data, cfg: Config, out: list[Detail]
|
|
458
|
+
) -> None:
|
|
459
|
+
value = resolve(data, key, cfg.array_indexing_check)
|
|
460
|
+
present = _is_present(data, key, cfg)
|
|
461
|
+
operator, branches = _operator_of(node)
|
|
462
|
+
|
|
463
|
+
if operator == OP_SWITCH:
|
|
464
|
+
_apply_switch(branches, key, field, value, present, data, cfg, out)
|
|
465
|
+
elif operator == OP_AND:
|
|
466
|
+
_apply_and(branches, key, field, value, present, data, cfg, out)
|
|
467
|
+
else:
|
|
468
|
+
_apply_or(branches, key, field, value, present, data, cfg, out)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _apply_and(
|
|
472
|
+
branches: list[Any],
|
|
473
|
+
key: str,
|
|
474
|
+
field: str,
|
|
475
|
+
value: Any,
|
|
476
|
+
present: bool,
|
|
477
|
+
data: Data,
|
|
478
|
+
cfg: Config,
|
|
479
|
+
out: list[Detail],
|
|
480
|
+
) -> None:
|
|
481
|
+
if not present and _any_branch_has(branches, "optional"):
|
|
482
|
+
return
|
|
483
|
+
if present and value is None and _any_branch_has(branches, "nullable"):
|
|
484
|
+
return
|
|
485
|
+
|
|
486
|
+
object_branches = [b for b in branches if isinstance(b, dict) and not _is_operator_node(b)]
|
|
487
|
+
merged: Optional[Rules] = None
|
|
488
|
+
if len(object_branches) > 1:
|
|
489
|
+
merged = {}
|
|
490
|
+
for b in object_branches:
|
|
491
|
+
merged.update(b)
|
|
492
|
+
|
|
493
|
+
merged_done = False
|
|
494
|
+
for branch in branches:
|
|
495
|
+
if _only_modifiers(branch):
|
|
496
|
+
continue
|
|
497
|
+
if merged is not None and isinstance(branch, dict) and branch in object_branches:
|
|
498
|
+
if merged_done:
|
|
499
|
+
continue
|
|
500
|
+
merged_done = True
|
|
501
|
+
out.extend(_branch_errors(merged, key, field, value, present, data, cfg))
|
|
502
|
+
continue
|
|
503
|
+
out.extend(_branch_errors(branch, key, field, value, present, data, cfg))
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _apply_switch(
|
|
507
|
+
branches: list[Any],
|
|
508
|
+
key: str,
|
|
509
|
+
field: str,
|
|
510
|
+
value: Any,
|
|
511
|
+
present: bool,
|
|
512
|
+
data: Data,
|
|
513
|
+
cfg: Config,
|
|
514
|
+
out: list[Detail],
|
|
515
|
+
) -> None:
|
|
516
|
+
fallback: Any = _MISSING
|
|
517
|
+
|
|
518
|
+
for branch in branches:
|
|
519
|
+
if branch.get(BRANCH_DEFAULT) is True:
|
|
520
|
+
fallback = branch[BRANCH_THEN]
|
|
521
|
+
case_errors = _branch_errors(branch[BRANCH_CASE], key, field, value, present, data, cfg)
|
|
522
|
+
if not case_errors:
|
|
523
|
+
out.extend(_branch_errors(branch[BRANCH_THEN], key, field, value, present, data, cfg))
|
|
524
|
+
return
|
|
525
|
+
|
|
526
|
+
if fallback is not _MISSING:
|
|
527
|
+
out.extend(_branch_errors(fallback, key, field, value, present, data, cfg))
|
|
528
|
+
return
|
|
529
|
+
|
|
530
|
+
out.append(
|
|
531
|
+
{
|
|
532
|
+
"field": field,
|
|
533
|
+
"message": f'"{field}" does not match any case',
|
|
534
|
+
"code": codes.NO_CASE_MATCHED,
|
|
535
|
+
}
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _apply_or(
|
|
540
|
+
branches: list[Any],
|
|
541
|
+
key: str,
|
|
542
|
+
field: str,
|
|
543
|
+
value: Any,
|
|
544
|
+
present: bool,
|
|
545
|
+
data: Data,
|
|
546
|
+
cfg: Config,
|
|
547
|
+
out: list[Detail],
|
|
548
|
+
) -> None:
|
|
549
|
+
if not present and _any_branch_has(branches, "optional"):
|
|
550
|
+
return
|
|
551
|
+
if present and value is None and _any_branch_has(branches, "nullable"):
|
|
552
|
+
return
|
|
553
|
+
|
|
554
|
+
attempts: list[tuple[list[Detail], bool]] = []
|
|
555
|
+
for branch in branches:
|
|
556
|
+
errors = _branch_errors(branch, key, field, value, present, data, cfg)
|
|
557
|
+
if not errors:
|
|
558
|
+
return
|
|
559
|
+
attempts.append((errors, _branch_type_matches(branch, value)))
|
|
560
|
+
|
|
561
|
+
if not attempts:
|
|
562
|
+
return
|
|
563
|
+
|
|
564
|
+
for errors, _ in attempts:
|
|
565
|
+
if errors and all(e["code"] == codes.UNEXPECTED_FIELD for e in errors):
|
|
566
|
+
out.extend(errors)
|
|
567
|
+
return
|
|
568
|
+
|
|
569
|
+
pool = [a for a in attempts if a[1]] or attempts
|
|
570
|
+
best = min(pool, key=lambda a: len(a[0]))
|
|
571
|
+
out.extend(e for e in best[0] if e["code"] != codes.UNEXPECTED_FIELD)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _branch_type_matches(branch: Any, value: Any) -> bool:
|
|
575
|
+
"""Whether a branch is shaped for this value's type."""
|
|
576
|
+
if callable(branch):
|
|
577
|
+
return True
|
|
578
|
+
|
|
579
|
+
if isinstance(branch, dict):
|
|
580
|
+
if _is_operator_node(branch):
|
|
581
|
+
return True
|
|
582
|
+
return is_object(value)
|
|
583
|
+
|
|
584
|
+
if isinstance(branch, list):
|
|
585
|
+
if branch and all(isinstance(e, str) for e in branch):
|
|
586
|
+
return _tokens_match_type(branch, value)
|
|
587
|
+
return is_array(value)
|
|
588
|
+
|
|
589
|
+
if isinstance(branch, str):
|
|
590
|
+
return _tokens_match_type(branch.split("|"), value)
|
|
591
|
+
|
|
592
|
+
return False
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _tokens_match_type(tokens: list[str], value: Any) -> bool:
|
|
596
|
+
from .checks import is_number
|
|
597
|
+
|
|
598
|
+
if any(t.startswith("arrayof:") for t in tokens):
|
|
599
|
+
return is_array(value)
|
|
600
|
+
if "object" in tokens:
|
|
601
|
+
return is_object(value)
|
|
602
|
+
if "array" in tokens:
|
|
603
|
+
return is_array(value)
|
|
604
|
+
if "string" in tokens:
|
|
605
|
+
return isinstance(value, str)
|
|
606
|
+
if "number" in tokens:
|
|
607
|
+
return is_number(value)
|
|
608
|
+
if "boolean" in tokens:
|
|
609
|
+
return isinstance(value, bool)
|
|
610
|
+
return not is_object(value) and not is_array(value)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
# --- group keys -----------------------------------------------------------
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _check_group(kind: str, value: Any, data: Data, prefix: str, out: list[Detail]) -> None:
|
|
617
|
+
if isinstance(value, str):
|
|
618
|
+
groups = [value.split("|")]
|
|
619
|
+
elif isinstance(value, list):
|
|
620
|
+
groups = [g.split("|") for g in value if isinstance(g, str)]
|
|
621
|
+
else:
|
|
622
|
+
return
|
|
623
|
+
|
|
624
|
+
for tokens in groups:
|
|
625
|
+
size = group_size(tokens)
|
|
626
|
+
names = [t for t in tokens if ":" not in t]
|
|
627
|
+
if not names:
|
|
628
|
+
continue
|
|
629
|
+
|
|
630
|
+
count = sum(1 for n in names if lookup(data, n) is not None)
|
|
631
|
+
|
|
632
|
+
if kind == GROUP_ATLEAST and count >= size:
|
|
633
|
+
continue
|
|
634
|
+
if kind == GROUP_ATMOST and count <= size:
|
|
635
|
+
continue
|
|
636
|
+
|
|
637
|
+
labelled = [f"{prefix}.{n}" if prefix else n for n in names]
|
|
638
|
+
message = _group_message(kind, labelled, size)
|
|
639
|
+
override = custom_error(tokens)
|
|
640
|
+
code = codes.ATLEAST_NOT_MET if kind == GROUP_ATLEAST else codes.ATMOST_EXCEEDED
|
|
641
|
+
out.append(
|
|
642
|
+
{
|
|
643
|
+
"field": labelled[0],
|
|
644
|
+
"message": override if override is not None else message,
|
|
645
|
+
"code": code,
|
|
646
|
+
}
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _group_message(kind: str, names: list[str], size: int) -> str:
|
|
651
|
+
size_word = "one" if size == 1 else str(size)
|
|
652
|
+
last = names[-1]
|
|
653
|
+
leading = ", ".join(f'"{n}"' for n in names[:-1])
|
|
654
|
+
and_word = "and" if len(names) > 1 else ""
|
|
655
|
+
|
|
656
|
+
if kind == GROUP_ATLEAST:
|
|
657
|
+
is_are = "is" if size == 1 else "are"
|
|
658
|
+
parts = [f"at least {size_word} of", leading, and_word, f'"{last}"', is_are, "required"]
|
|
659
|
+
else:
|
|
660
|
+
parts = [f"at most {size_word} of", leading, and_word, f'"{last}"', "can be given"]
|
|
661
|
+
|
|
662
|
+
return " ".join(p for p in parts if p)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
# --- strict mode ----------------------------------------------------------
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _strict_check(rules: Rules, data: Data, prefix: str, out: list[Detail]) -> None:
|
|
669
|
+
if not isinstance(data, dict):
|
|
670
|
+
return
|
|
671
|
+
|
|
672
|
+
allowed = set()
|
|
673
|
+
for key in rules:
|
|
674
|
+
if is_group_key(key) or "." in key:
|
|
675
|
+
continue
|
|
676
|
+
bracket = key.find("[")
|
|
677
|
+
allowed.add(key[:bracket] if bracket > 0 else key)
|
|
678
|
+
|
|
679
|
+
for key in data:
|
|
680
|
+
if "." in key or key in allowed:
|
|
681
|
+
continue
|
|
682
|
+
label = f"{prefix}.{key}" if prefix else key
|
|
683
|
+
out.append(
|
|
684
|
+
{"field": label, "message": f'"{label}" is not required', "code": codes.UNEXPECTED_FIELD}
|
|
685
|
+
)
|