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.
@@ -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
+ )