validate-nested 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.
- validate_nested/__init__.py +36 -0
- validate_nested/_utils/__init__.py +3 -0
- validate_nested/_utils/paths.py +66 -0
- validate_nested/engine.py +204 -0
- validate_nested/lambdas.py +216 -0
- validate_nested/result.py +67 -0
- validate_nested/rules.py +113 -0
- validate_nested/soft.py +52 -0
- validate_nested-0.1.0.dist-info/METADATA +470 -0
- validate_nested-0.1.0.dist-info/RECORD +13 -0
- validate_nested-0.1.0.dist-info/WHEEL +5 -0
- validate_nested-0.1.0.dist-info/licenses/LICENSE +21 -0
- validate_nested-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""validate-nested — a tiny, framework-agnostic DSL for validating the shape of
|
|
2
|
+
nested dicts / JSON responses.
|
|
3
|
+
|
|
4
|
+
from validate_nested import validate
|
|
5
|
+
from validate_nested.lambdas import equal, length, empty
|
|
6
|
+
|
|
7
|
+
model = {
|
|
8
|
+
"ids": (list, length(3)),
|
|
9
|
+
"ids[*]": dict,
|
|
10
|
+
"state": (str, equal("ok")),
|
|
11
|
+
"message": empty(str),
|
|
12
|
+
"error_code": (int, equal(0)),
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
r = validate(response, model) # -> Result(ok, failures, skipped)
|
|
16
|
+
assert r.ok, r.report() # idiomatic immediate check
|
|
17
|
+
"""
|
|
18
|
+
from validate_nested.engine import validate
|
|
19
|
+
from validate_nested.result import Failure, Result, format_failure, render_failures
|
|
20
|
+
from validate_nested.rules import ComplexRule
|
|
21
|
+
from validate_nested.soft import SoftValidator
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"validate",
|
|
27
|
+
"SoftValidator",
|
|
28
|
+
"Result",
|
|
29
|
+
"Failure",
|
|
30
|
+
"format_failure",
|
|
31
|
+
"render_failures",
|
|
32
|
+
"ComplexRule",
|
|
33
|
+
"lambdas",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
from validate_nested import lambdas # noqa: E402 (re-exported for convenience)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Small dict/path helpers copied (and trimmed) from the original framework so the
|
|
2
|
+
library carries no external dependencies."""
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def dict_paths(d):
|
|
7
|
+
"""Yield every leaf/branch path in a nested dict/list as a dotted string,
|
|
8
|
+
e.g. {"a": [{"b": 1}]} -> "a", "a[0]", "a[0].b"."""
|
|
9
|
+
q = [(d, [])]
|
|
10
|
+
while q:
|
|
11
|
+
n, p = q.pop(0)
|
|
12
|
+
if p:
|
|
13
|
+
yield ".".join(map(str, p))
|
|
14
|
+
if isinstance(n, dict):
|
|
15
|
+
for k, v in n.items():
|
|
16
|
+
q.append((v, p + [k]))
|
|
17
|
+
elif isinstance(n, list):
|
|
18
|
+
for i, v in enumerate(n):
|
|
19
|
+
q.append((v, p + ["[" + str(i) + "]"]))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def path_getter(record, path, default=None):
|
|
23
|
+
"""Resolve a dotted/indexed path inside a nested dict/list, returning `default`
|
|
24
|
+
if any segment is missing. Supports list indices: "a.b[0].c"."""
|
|
25
|
+
if default is None:
|
|
26
|
+
default = {}
|
|
27
|
+
path_parts = [part for part in re.split(r"[\.\[\]]+", path) if part]
|
|
28
|
+
value = record
|
|
29
|
+
for part in path_parts:
|
|
30
|
+
if isinstance(value, list) and part.isdigit():
|
|
31
|
+
idx = int(part)
|
|
32
|
+
if -len(value) <= idx < len(value):
|
|
33
|
+
value = value[idx]
|
|
34
|
+
else:
|
|
35
|
+
return default
|
|
36
|
+
elif isinstance(value, dict):
|
|
37
|
+
value = value.get(part, default)
|
|
38
|
+
else:
|
|
39
|
+
return default
|
|
40
|
+
if value == default:
|
|
41
|
+
return default
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def stringify_dict(d):
|
|
46
|
+
"""Recursively stringify every leaf of a dict/list (lambdas are shortened to their
|
|
47
|
+
`<lambda>` head). Used to render rule/validation details safely."""
|
|
48
|
+
if isinstance(d, dict):
|
|
49
|
+
for k, v in d.items():
|
|
50
|
+
if isinstance(v, (dict, list)):
|
|
51
|
+
d[k] = stringify_dict(v)
|
|
52
|
+
else:
|
|
53
|
+
if "<lambda>" in str(v):
|
|
54
|
+
d[k] = str(v).split("<lambda>")[0] + "<lambda>"
|
|
55
|
+
else:
|
|
56
|
+
d[k] = str(v)
|
|
57
|
+
elif isinstance(d, list):
|
|
58
|
+
for i, v in enumerate(d):
|
|
59
|
+
if isinstance(v, (dict, list)):
|
|
60
|
+
d[i] = stringify_dict(v)
|
|
61
|
+
else:
|
|
62
|
+
if "<lambda>" in str(v):
|
|
63
|
+
d[i] = str(v).split("<lambda>")[0] + "<lambda>"
|
|
64
|
+
else:
|
|
65
|
+
d[i] = str(v)
|
|
66
|
+
return d
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""The validation engine.
|
|
2
|
+
|
|
3
|
+
``validate(record, model)`` walks the model, runs every rule against the record and
|
|
4
|
+
returns a :class:`Result` — it never raises or asserts. Callers decide what to do::
|
|
5
|
+
|
|
6
|
+
r = validate(record, model)
|
|
7
|
+
assert r.ok, r.report()
|
|
8
|
+
"""
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from validate_nested._utils.paths import path_getter
|
|
12
|
+
from validate_nested.result import Failure, Result
|
|
13
|
+
from validate_nested.rules import (
|
|
14
|
+
ComplexRule,
|
|
15
|
+
NotExists,
|
|
16
|
+
convert_paths_to_template,
|
|
17
|
+
process_validation_rules,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("validate_nested")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _type_name(t):
|
|
24
|
+
"""Readable type name(s): dict -> 'dict', (int, str) -> 'int or str'."""
|
|
25
|
+
if isinstance(t, tuple):
|
|
26
|
+
return " or ".join(getattr(x, "__name__", str(x)) for x in t)
|
|
27
|
+
return getattr(t, "__name__", str(t))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SkipSignal(Exception):
|
|
31
|
+
"""Raised internally when a ``skip()``-marked rule fails. ``validate`` catches it
|
|
32
|
+
and turns it into ``Result.skipped`` — the engine itself never skips anything."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _Checker:
|
|
36
|
+
"""Validates a single (type_hint, path) pair against one record. Collects failures
|
|
37
|
+
into ``self.failures`` instead of asserting."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, value, record, **kwargs):
|
|
40
|
+
self.record = record
|
|
41
|
+
self.failures = []
|
|
42
|
+
|
|
43
|
+
# boolean rules from the model body
|
|
44
|
+
self._opt = kwargs.pop("opt", None)
|
|
45
|
+
self._required = kwargs.pop("required", None)
|
|
46
|
+
self._not_exist = kwargs.pop("not_exist", None)
|
|
47
|
+
self._undefined = kwargs.pop("undefined", None)
|
|
48
|
+
self._empty = kwargs.pop("empty", None)
|
|
49
|
+
self._not_empty = kwargs.pop("not_empty", None)
|
|
50
|
+
self._skip = kwargs.pop("skip", None)
|
|
51
|
+
self.validators = kwargs.pop("validators", [])
|
|
52
|
+
|
|
53
|
+
# options
|
|
54
|
+
self._additional_assert_msg = kwargs.pop("add_msg", None)
|
|
55
|
+
self._replace_assert_msg = kwargs.pop("assert_msg", "")
|
|
56
|
+
self._to_int = kwargs.pop("to_int", None)
|
|
57
|
+
self._to_float = kwargs.pop("to_float", None)
|
|
58
|
+
# any leftover kwargs are ignored (host-specific context)
|
|
59
|
+
|
|
60
|
+
self.type_hint, self.original_path = value
|
|
61
|
+
self.exist = True
|
|
62
|
+
self.value = path_getter(self.record, self.original_path, default=NotExists())
|
|
63
|
+
|
|
64
|
+
def _message(self, default):
|
|
65
|
+
if self._replace_assert_msg:
|
|
66
|
+
return self._replace_assert_msg
|
|
67
|
+
if self._additional_assert_msg:
|
|
68
|
+
return f"{self._additional_assert_msg}, {default}"
|
|
69
|
+
return default
|
|
70
|
+
|
|
71
|
+
def _check(self, default, condition):
|
|
72
|
+
if condition:
|
|
73
|
+
return
|
|
74
|
+
msg = self._message(default)
|
|
75
|
+
if self._skip:
|
|
76
|
+
raise SkipSignal(msg)
|
|
77
|
+
self.failures.append(Failure(path=self.original_path, message=msg))
|
|
78
|
+
|
|
79
|
+
def _has_failures(self):
|
|
80
|
+
return bool(self.failures)
|
|
81
|
+
|
|
82
|
+
def process(self):
|
|
83
|
+
value = self.value
|
|
84
|
+
|
|
85
|
+
if isinstance(value, NotExists):
|
|
86
|
+
self.exist = False
|
|
87
|
+
|
|
88
|
+
# rule: not_exist() — the path must be absent
|
|
89
|
+
if self._not_exist:
|
|
90
|
+
self._check(
|
|
91
|
+
default=f"must be absent, got {_type_name(type(value))}",
|
|
92
|
+
condition=isinstance(value, NotExists),
|
|
93
|
+
)
|
|
94
|
+
return not self._has_failures()
|
|
95
|
+
|
|
96
|
+
# rule: opt() — absent value is acceptable (unless required is also set)
|
|
97
|
+
if self._opt and isinstance(value, NotExists):
|
|
98
|
+
return not self._required
|
|
99
|
+
|
|
100
|
+
# type check
|
|
101
|
+
if isinstance(value, NotExists):
|
|
102
|
+
assert_type_msg = "is missing"
|
|
103
|
+
else:
|
|
104
|
+
assert_type_msg = f"expected {_type_name(self.type_hint)}, got {_type_name(type(value))}"
|
|
105
|
+
|
|
106
|
+
self._check(default=assert_type_msg, condition=isinstance(value, self.type_hint))
|
|
107
|
+
|
|
108
|
+
# if a double type was given, narrow the type hint to the actual type for len checks
|
|
109
|
+
if isinstance(self.type_hint, tuple):
|
|
110
|
+
self.type_hint = type(value)
|
|
111
|
+
|
|
112
|
+
# length checks (only for sized types that actually exist)
|
|
113
|
+
if (
|
|
114
|
+
self.exist
|
|
115
|
+
and self.type_hint in (list, dict, str)
|
|
116
|
+
and isinstance(value, self.type_hint)
|
|
117
|
+
):
|
|
118
|
+
if self._empty:
|
|
119
|
+
self._check(
|
|
120
|
+
default=f"expected empty, got length {len(value)}",
|
|
121
|
+
condition=(len(value) == 0),
|
|
122
|
+
)
|
|
123
|
+
if self._not_empty:
|
|
124
|
+
self._check(
|
|
125
|
+
default="expected non-empty, got empty",
|
|
126
|
+
condition=(len(value) > 0),
|
|
127
|
+
)
|
|
128
|
+
if self._undefined and len(value) == 0:
|
|
129
|
+
self.exist = False
|
|
130
|
+
|
|
131
|
+
# validators ('lambdas') — only when the value exists
|
|
132
|
+
if self.exist and self.validators:
|
|
133
|
+
for lambda_info in self.validators:
|
|
134
|
+
current_length = len(lambda_info)
|
|
135
|
+
lambda_info.extend([""] * (3 - current_length))
|
|
136
|
+
_lambda, lambda_assert_msg, _lambda_details = lambda_info
|
|
137
|
+
|
|
138
|
+
template_value = value
|
|
139
|
+
try:
|
|
140
|
+
if self._to_int:
|
|
141
|
+
template_value = int(value)
|
|
142
|
+
if self._to_float:
|
|
143
|
+
template_value = float(value)
|
|
144
|
+
result = _lambda(template_value)
|
|
145
|
+
except Exception as error:
|
|
146
|
+
error_msg = f"{lambda_assert_msg} — error on {template_value!r}: {error}"
|
|
147
|
+
self._check(default=error_msg, condition=(not error))
|
|
148
|
+
else:
|
|
149
|
+
if _lambda.__qualname__.startswith("length.<locals>.<lambda>"):
|
|
150
|
+
lambda_msg = f"{lambda_assert_msg}, got length {len(template_value)}"
|
|
151
|
+
else:
|
|
152
|
+
lambda_msg = f"{lambda_assert_msg}, got {template_value!r}"
|
|
153
|
+
self._check(default=lambda_msg, condition=result)
|
|
154
|
+
|
|
155
|
+
return self.exist and not self._has_failures()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def validate(record, model, **options):
|
|
159
|
+
"""Validate ``record`` against ``model`` and return a :class:`Result`.
|
|
160
|
+
|
|
161
|
+
Never raises (except ``ValueError`` on an empty model). All failed checks are
|
|
162
|
+
collected into ``result.failures``; ``result.ok`` is True iff nothing failed.
|
|
163
|
+
A fired ``skip()`` rule short-circuits and sets ``result.skipped``.
|
|
164
|
+
"""
|
|
165
|
+
if not model:
|
|
166
|
+
raise ValueError("model cannot be empty")
|
|
167
|
+
|
|
168
|
+
log.debug("validating against model: %r", model)
|
|
169
|
+
|
|
170
|
+
t_dict = convert_paths_to_template(record)
|
|
171
|
+
failures = []
|
|
172
|
+
|
|
173
|
+
for template_path, rules in model.items():
|
|
174
|
+
path_options = dict(options)
|
|
175
|
+
if isinstance(rules, ComplexRule):
|
|
176
|
+
path_options.update(rules.options)
|
|
177
|
+
rules = rules.value
|
|
178
|
+
|
|
179
|
+
type_hint, validators, rule_options = process_validation_rules(rules)
|
|
180
|
+
rule_options.update(path_options)
|
|
181
|
+
|
|
182
|
+
# expand a wildcard template (ids[*]) to every concrete path, else use as-is
|
|
183
|
+
concrete_paths = t_dict.get(template_path) or [template_path]
|
|
184
|
+
|
|
185
|
+
path_ok = True
|
|
186
|
+
for concrete_path in concrete_paths:
|
|
187
|
+
checker = _Checker(
|
|
188
|
+
value=(type_hint, concrete_path),
|
|
189
|
+
record=record,
|
|
190
|
+
validators=validators,
|
|
191
|
+
**rule_options,
|
|
192
|
+
)
|
|
193
|
+
try:
|
|
194
|
+
path_result = checker.process()
|
|
195
|
+
except SkipSignal as signal:
|
|
196
|
+
return Result(ok=not failures, failures=failures, skipped=str(signal))
|
|
197
|
+
failures.extend(checker.failures)
|
|
198
|
+
path_ok = path_ok and path_result
|
|
199
|
+
|
|
200
|
+
# required + failed: stop and don't run the remaining model keys
|
|
201
|
+
if rule_options.get("required") and not path_ok:
|
|
202
|
+
break
|
|
203
|
+
|
|
204
|
+
return Result(ok=not failures, failures=failures)
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""The validator DSL.
|
|
2
|
+
|
|
3
|
+
Two kinds of helpers:
|
|
4
|
+
|
|
5
|
+
* string-marker rules (``empty``, ``opt``, ``required``, ``skip`` ...) — return a
|
|
6
|
+
tuple whose first item is a marker string the engine recognises;
|
|
7
|
+
* value validators (``length``, ``equal``, ``approx`` ...) — return a ``LambdaInfo``
|
|
8
|
+
carrying the check plus a short reason used when it fails. The engine appends the
|
|
9
|
+
actual value, e.g. ``should be equal to 'ok', got 'err'``.
|
|
10
|
+
|
|
11
|
+
Used inside a model:: ``{"ids": (list, length(3)), "state": (str, equal("ok"))}``.
|
|
12
|
+
"""
|
|
13
|
+
from collections import namedtuple
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LambdaInfo(
|
|
17
|
+
namedtuple("LambdaInfo", ("func_lambda", "lambda_assert_msg", "lambda_details"))
|
|
18
|
+
):
|
|
19
|
+
def __repr__(self):
|
|
20
|
+
return f"{self.lambda_details}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def predicate(func, message, name=None):
|
|
24
|
+
"""Build a one-off custom validator from any callable ``value -> bool``.
|
|
25
|
+
|
|
26
|
+
The engine appends ``, got <value>`` to ``message`` on failure::
|
|
27
|
+
|
|
28
|
+
is_even = predicate(lambda v: v % 2 == 0, "should be even")
|
|
29
|
+
model = {"count": (int, is_even)} # fails as: should be even, got 3
|
|
30
|
+
|
|
31
|
+
``func`` may be a lambda or a plain function — it's wrapped internally so the engine
|
|
32
|
+
always recognises it as a validator. For a reusable/parametrised validator, write a
|
|
33
|
+
function that returns a ``LambdaInfo`` directly (that's how the built-ins are made).
|
|
34
|
+
"""
|
|
35
|
+
return LambdaInfo(
|
|
36
|
+
func_lambda=lambda v: func(v),
|
|
37
|
+
lambda_assert_msg=message,
|
|
38
|
+
lambda_details=name or getattr(func, "__name__", "predicate"),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── string-marker (boolean) rules ──────────────────────────────────────────────
|
|
43
|
+
def to_int(*args):
|
|
44
|
+
return "to_int", *args
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def to_float(*args):
|
|
48
|
+
return "to_float", *args
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def undefined(*args):
|
|
52
|
+
return "undefined", *args
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def empty(*args):
|
|
56
|
+
return "empty", *args
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def not_empty(*args):
|
|
60
|
+
return "not_empty", *args
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def opt(*args):
|
|
64
|
+
return "opt", *args
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def skip(*args):
|
|
68
|
+
return "skip", *args
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def not_exist(*args):
|
|
72
|
+
return "not_exist", *args
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def required(*args):
|
|
76
|
+
return "required", *args
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── value validators (parametrised) ────────────────────────────────────────────
|
|
80
|
+
def length(size: int):
|
|
81
|
+
return LambdaInfo(
|
|
82
|
+
func_lambda=lambda v: len(v) == size,
|
|
83
|
+
lambda_assert_msg=f"should have length {size}",
|
|
84
|
+
lambda_details=f"{length.__name__}({size}): lambda v: len(v) == {size}",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def equal(value):
|
|
89
|
+
return LambdaInfo(
|
|
90
|
+
func_lambda=lambda v: v == value,
|
|
91
|
+
lambda_assert_msg=f"should be equal to {value!r}",
|
|
92
|
+
lambda_details=f"{equal.__name__}({value}): lambda v: v == {value}",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def lower_match(value):
|
|
97
|
+
return LambdaInfo(
|
|
98
|
+
func_lambda=lambda v: v.lower() == value.lower(),
|
|
99
|
+
lambda_assert_msg=f"should be equal to {value!r} (case-insensitive)",
|
|
100
|
+
lambda_details=f"{lower_match.__name__}({value.lower()}): lambda v: v.lower() == {value.lower()}",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def not_equal(value):
|
|
105
|
+
return LambdaInfo(
|
|
106
|
+
func_lambda=lambda v: v != value,
|
|
107
|
+
lambda_assert_msg=f"should not be equal to {value!r}",
|
|
108
|
+
lambda_details=f"{not_equal.__name__}({value}): lambda v: v != {value}",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def approx(value, delta=0.01):
|
|
113
|
+
return LambdaInfo(
|
|
114
|
+
# |v - value| <= delta (same semantics as the original pytest.approx(abs=delta),
|
|
115
|
+
# but with no test-framework dependency)
|
|
116
|
+
func_lambda=lambda v: abs(v - value) <= delta,
|
|
117
|
+
lambda_assert_msg=f"should be approximately {value} (±{delta})",
|
|
118
|
+
lambda_details=f"{approx.__name__}({value}): lambda v: abs(v - {value}) <= {delta}",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def count(value, amount):
|
|
123
|
+
return LambdaInfo(
|
|
124
|
+
func_lambda=lambda v: v.count(value) == amount,
|
|
125
|
+
lambda_assert_msg=f"should contain {value!r} exactly {amount} time(s)",
|
|
126
|
+
lambda_details=f"{count.__name__}({value}): lambda v: v.count({value}) == {amount}",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def exists_in(value):
|
|
131
|
+
return LambdaInfo(
|
|
132
|
+
func_lambda=lambda v: v in value,
|
|
133
|
+
lambda_assert_msg=f"should be one of {list(value)}",
|
|
134
|
+
lambda_details=f"{exists_in.__name__}({value}): lambda v: v in {value}",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def contains(value):
|
|
139
|
+
if isinstance(value, str):
|
|
140
|
+
return LambdaInfo(
|
|
141
|
+
func_lambda=lambda v: value in v,
|
|
142
|
+
lambda_assert_msg=f"should contain {value!r}",
|
|
143
|
+
lambda_details=f"{contains.__name__}({value}): lambda v: {value} in v",
|
|
144
|
+
)
|
|
145
|
+
elif isinstance(value, list):
|
|
146
|
+
return LambdaInfo(
|
|
147
|
+
func_lambda=lambda v: all(elem in v for elem in value),
|
|
148
|
+
lambda_assert_msg=f"should contain all of {value}",
|
|
149
|
+
lambda_details=f"{contains.__name__}({value}): lambda v: all(elem in v for elem in {value})",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def ends(value):
|
|
154
|
+
return LambdaInfo(
|
|
155
|
+
func_lambda=lambda v: v.endswith(value),
|
|
156
|
+
lambda_assert_msg=f"should end with {value!r}",
|
|
157
|
+
lambda_details=f"{ends.__name__}({value}): lambda v: v.endswith(value)",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def in_range(start, stop):
|
|
162
|
+
return LambdaInfo(
|
|
163
|
+
func_lambda=lambda v: start < v < stop,
|
|
164
|
+
lambda_assert_msg=f"should be in range ({start}, {stop})",
|
|
165
|
+
lambda_details=f"{in_range.__name__}({start}, {stop}): lambda v: {start} < v < {stop}",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def less(value):
|
|
170
|
+
return LambdaInfo(
|
|
171
|
+
func_lambda=lambda v: v < value,
|
|
172
|
+
lambda_assert_msg=f"should be less than {value}",
|
|
173
|
+
lambda_details=f"{less.__name__}({value}): lambda v: v < {value}",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def more(value):
|
|
178
|
+
return LambdaInfo(
|
|
179
|
+
func_lambda=lambda v: v > value,
|
|
180
|
+
lambda_assert_msg=f"should be greater than {value}",
|
|
181
|
+
lambda_details=f"{more.__name__}({value}): lambda v: v > {value}",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def split_length(size, sep=","):
|
|
186
|
+
return LambdaInfo(
|
|
187
|
+
func_lambda=lambda v: len(v.split(sep)) == size,
|
|
188
|
+
lambda_assert_msg=f"should split by {sep!r} into {size} parts",
|
|
189
|
+
lambda_details=f"{split_length.__name__}({size}, {sep}): lambda v: len(v.split({sep})) == {size}",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ── parameter-less predefined validators ───────────────────────────────────────
|
|
194
|
+
valid_score = LambdaInfo(
|
|
195
|
+
func_lambda=lambda v: 0 < v <= 1,
|
|
196
|
+
lambda_assert_msg="should be a valid score (0 < v <= 1)",
|
|
197
|
+
lambda_details="valid_score: lambda v: 0 < v <= 1",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
positive_number = LambdaInfo(
|
|
201
|
+
func_lambda=lambda v: v >= 0,
|
|
202
|
+
lambda_assert_msg="should be >= 0",
|
|
203
|
+
lambda_details="positive_number: lambda v: v >= 0",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
non_zero = LambdaInfo(
|
|
207
|
+
func_lambda=lambda v: v > 0,
|
|
208
|
+
lambda_assert_msg="should be > 0",
|
|
209
|
+
lambda_details="non_zero: lambda v: v > 0",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
split_positive_numbers = LambdaInfo(
|
|
213
|
+
func_lambda=lambda v: all([float(elem) >= 0 for elem in v.split(",")]),
|
|
214
|
+
lambda_assert_msg="all comma-separated parts should be >= 0",
|
|
215
|
+
lambda_details='split_positive_numbers: lambda v: all([float(elem) >= 0 for elem in v.split(",")])',
|
|
216
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Neutral, framework-agnostic result types.
|
|
2
|
+
|
|
3
|
+
The engine never raises or asserts on its own — ``validate`` returns a :class:`Result`.
|
|
4
|
+
You decide what to do with it; the idiomatic immediate check is::
|
|
5
|
+
|
|
6
|
+
r = validate(record, model)
|
|
7
|
+
assert r.ok, r.report()
|
|
8
|
+
"""
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Callable, List, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Failure:
|
|
15
|
+
"""A single failed check.
|
|
16
|
+
|
|
17
|
+
``path`` is where in the record it failed; ``message`` is the default
|
|
18
|
+
human-readable reason (expected vs actual).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
path: str
|
|
22
|
+
message: str
|
|
23
|
+
|
|
24
|
+
def __str__(self):
|
|
25
|
+
return f"[{self.path}] {self.message}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def format_failure(failure: "Failure") -> str:
|
|
29
|
+
"""Default single-failure renderer (override by passing ``formatter=`` to report)."""
|
|
30
|
+
return f" - [{failure.path}] {failure.message}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def render_failures(failures: List["Failure"], formatter: Callable[["Failure"], str] = None) -> str:
|
|
34
|
+
"""Join failures into one readable multi-line message."""
|
|
35
|
+
fmt = formatter or format_failure
|
|
36
|
+
header = f"{len(failures)} validation failure(s):"
|
|
37
|
+
return "\n".join([header, *(fmt(f) for f in failures)])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Result:
|
|
42
|
+
"""Outcome of a :func:`validate_nested.validate` call.
|
|
43
|
+
|
|
44
|
+
* ``ok`` — True when no check failed
|
|
45
|
+
* ``failures`` — list of :class:`Failure`
|
|
46
|
+
* ``skipped`` — reason string if a ``skip()`` rule fired, else None
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
ok: bool
|
|
50
|
+
failures: List[Failure] = field(default_factory=list)
|
|
51
|
+
skipped: Optional[str] = None
|
|
52
|
+
|
|
53
|
+
def __bool__(self):
|
|
54
|
+
return self.ok
|
|
55
|
+
|
|
56
|
+
def report(self, formatter: Callable[[Failure], str] = None) -> str:
|
|
57
|
+
"""Render this result as a readable message — meant for an assert message::
|
|
58
|
+
|
|
59
|
+
assert r.ok, r.report()
|
|
60
|
+
|
|
61
|
+
Pass ``formatter`` (a callable ``Failure -> str``) to customise each line.
|
|
62
|
+
"""
|
|
63
|
+
if self.skipped is not None:
|
|
64
|
+
return f"skipped: {self.skipped}"
|
|
65
|
+
if not self.failures:
|
|
66
|
+
return "ok"
|
|
67
|
+
return render_failures(self.failures, formatter)
|
validate_nested/rules.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Model parsing: turns a model value (a type, a marker, a validator, or a tuple of
|
|
2
|
+
those) into ``(type_hint, validators, boolean_rules)`` the engine can act on."""
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from collections import namedtuple
|
|
6
|
+
|
|
7
|
+
from validate_nested._utils.paths import dict_paths
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger("validate_nested")
|
|
10
|
+
|
|
11
|
+
# A model entry may be wrapped to attach per-path options, e.g. ComplexRule(value, options)
|
|
12
|
+
ComplexRule = namedtuple("ComplexRule", ["value", "options"])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CustomType(type):
|
|
16
|
+
def __repr__(cls):
|
|
17
|
+
return f"<class {cls.__name__}>"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NotExists(metaclass=CustomType):
|
|
21
|
+
"""Sentinel returned by path lookups when a key is missing in the record."""
|
|
22
|
+
|
|
23
|
+
def __str__(self):
|
|
24
|
+
return "not exists in dict"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def convert_paths_to_template(record):
|
|
28
|
+
"""Index the record's concrete paths under their ``[*]`` wildcard template, so a
|
|
29
|
+
model key like ``ids[*]`` can be expanded to ``ids[0]``, ``ids[1]`` ..."""
|
|
30
|
+
t_dict = {}
|
|
31
|
+
for path in dict_paths(record):
|
|
32
|
+
path = path.replace(".[", "[")
|
|
33
|
+
template_path = re.sub(r"\[.*?\]", "[*]", path)
|
|
34
|
+
if template_path not in t_dict:
|
|
35
|
+
t_dict[template_path] = []
|
|
36
|
+
if path not in t_dict[template_path]:
|
|
37
|
+
t_dict[template_path].append(path)
|
|
38
|
+
return t_dict
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def unpack_rules(items, validators):
|
|
42
|
+
"""Flatten a (possibly nested) rule tuple, peeling LambdaInfo validators off into
|
|
43
|
+
``validators`` and returning the remaining markers/types."""
|
|
44
|
+
unpacked = []
|
|
45
|
+
|
|
46
|
+
if isinstance(items, tuple):
|
|
47
|
+
for item in items:
|
|
48
|
+
if isinstance(item, tuple) and any(
|
|
49
|
+
callable(sub_item) and sub_item.__name__ == "<lambda>"
|
|
50
|
+
for sub_item in item
|
|
51
|
+
):
|
|
52
|
+
validators.append(list(item))
|
|
53
|
+
else:
|
|
54
|
+
unpacked.extend(unpack_rules(item, validators))
|
|
55
|
+
else:
|
|
56
|
+
unpacked.append(items)
|
|
57
|
+
|
|
58
|
+
return unpacked
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def process_validation_rules(validation_rules):
|
|
62
|
+
"""Split a model value into a type hint, a list of validators and a flat dict of
|
|
63
|
+
boolean rule flags.
|
|
64
|
+
|
|
65
|
+
Rule semantics:
|
|
66
|
+
* ``required`` — stop further checks and fail if this rule failed
|
|
67
|
+
* ``skip`` — signal a skip if this rule failed (host decides what to do)
|
|
68
|
+
* ``opt`` — value may be absent; if absent, pass (unless combined with required)
|
|
69
|
+
* ``not_exist``— assert the path is absent
|
|
70
|
+
* ``undefined``— don't assume empty-vs-filled (skips the len check)
|
|
71
|
+
* ``empty`` / ``not_empty`` — assert len == 0 / len > 0 (not_empty is the default)
|
|
72
|
+
* ``to_int`` / ``to_float`` — coerce the value before running validators
|
|
73
|
+
"""
|
|
74
|
+
type_hint, validators = None, []
|
|
75
|
+
boolean_rules = dict(
|
|
76
|
+
{
|
|
77
|
+
"not_exist": False,
|
|
78
|
+
"required": False,
|
|
79
|
+
"opt": False,
|
|
80
|
+
"undefined": False,
|
|
81
|
+
"empty": False,
|
|
82
|
+
"not_empty": True,
|
|
83
|
+
"skip": False,
|
|
84
|
+
"to_int": False,
|
|
85
|
+
"to_float": False,
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# unpack the model body into markers/types (validators are peeled off in-place)
|
|
90
|
+
unpacked_rules = unpack_rules(validation_rules, validators)
|
|
91
|
+
|
|
92
|
+
# extract type hint(s)
|
|
93
|
+
type_hint = tuple(item for item in unpacked_rules if isinstance(item, type))
|
|
94
|
+
if len(type_hint) == 1:
|
|
95
|
+
type_hint = type_hint[0]
|
|
96
|
+
|
|
97
|
+
# extract boolean rules (marker strings)
|
|
98
|
+
boolean_rules.update({item: True for item in unpacked_rules if isinstance(item, str)})
|
|
99
|
+
|
|
100
|
+
# exclusive options
|
|
101
|
+
if boolean_rules.get("empty"):
|
|
102
|
+
boolean_rules["not_empty"] = False
|
|
103
|
+
|
|
104
|
+
if boolean_rules.get("undefined"):
|
|
105
|
+
boolean_rules["empty"] = False
|
|
106
|
+
boolean_rules["not_empty"] = False
|
|
107
|
+
|
|
108
|
+
log.debug(
|
|
109
|
+
"processed rules: %r -> type_hint=%r validators=%r boolean_rules=%r",
|
|
110
|
+
validation_rules, type_hint, validators, boolean_rules,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return type_hint, validators, boolean_rules
|
validate_nested/soft.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Soft validation across several calls.
|
|
2
|
+
|
|
3
|
+
A single ``validate`` call already checks the *whole* model and reports every failure
|
|
4
|
+
at once. ``SoftValidator`` extends that across multiple calls in one block, raising a
|
|
5
|
+
single aggregated ``AssertionError`` at the end (handy in one test asserting several
|
|
6
|
+
records).
|
|
7
|
+
|
|
8
|
+
with SoftValidator() as soft:
|
|
9
|
+
soft.validate(resp_a, model_a)
|
|
10
|
+
soft.validate(resp_b, model_b)
|
|
11
|
+
# raises here if either failed, listing all failures
|
|
12
|
+
"""
|
|
13
|
+
from validate_nested.engine import validate
|
|
14
|
+
from validate_nested.result import render_failures
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SoftValidator:
|
|
18
|
+
def __init__(self, formatter=None):
|
|
19
|
+
self.formatter = formatter
|
|
20
|
+
self.failures = []
|
|
21
|
+
self.skipped = []
|
|
22
|
+
|
|
23
|
+
def validate(self, record, model, **options):
|
|
24
|
+
"""Validate and accumulate failures; returns the individual Result."""
|
|
25
|
+
result = validate(record, model, **options)
|
|
26
|
+
self.failures.extend(result.failures)
|
|
27
|
+
if result.skipped is not None:
|
|
28
|
+
self.skipped.append(result.skipped)
|
|
29
|
+
return result
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def ok(self):
|
|
33
|
+
return not self.failures
|
|
34
|
+
|
|
35
|
+
def report(self):
|
|
36
|
+
"""Readable message for everything collected so far."""
|
|
37
|
+
return render_failures(self.failures, self.formatter)
|
|
38
|
+
|
|
39
|
+
def assert_ok(self):
|
|
40
|
+
"""Raise now if anything has failed so far."""
|
|
41
|
+
if self.failures:
|
|
42
|
+
raise AssertionError(self.report())
|
|
43
|
+
|
|
44
|
+
def __enter__(self):
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
48
|
+
# don't mask an exception already propagating out of the block
|
|
49
|
+
if exc_type is not None:
|
|
50
|
+
return False
|
|
51
|
+
self.assert_ok()
|
|
52
|
+
return False
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: validate-nested
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A tiny, framework-agnostic DSL for validating the shape of nested dicts / JSON responses.
|
|
5
|
+
Author: Sergei Murashov
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ant1kdream/validate-nested
|
|
8
|
+
Project-URL: Repository, https://github.com/ant1kdream/validate-nested
|
|
9
|
+
Project-URL: Issues, https://github.com/ant1kdream/validate-nested/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/ant1kdream/validate-nested/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: validation,json,schema,dict,testing,api
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Operating System :: OS Independent
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
24
|
+
Classifier: Topic :: Software Development :: Testing
|
|
25
|
+
Requires-Python: >=3.8
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=6.0; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# validate-nested
|
|
33
|
+
|
|
34
|
+
[](https://github.com/ant1kdream/validate-nested/actions/workflows/ci.yml)
|
|
35
|
+
[](https://pypi.org/project/validate-nested/)
|
|
36
|
+
[](https://pypi.org/project/validate-nested/)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+
|
|
39
|
+
**A tiny, dependency-free DSL for validating the *shape* of nested dicts / JSON responses
|
|
40
|
+
of any depth.**
|
|
41
|
+
|
|
42
|
+
Describe what a response should look like with a compact `model` dict and let the engine
|
|
43
|
+
check types, lengths, values, presence and per-item rules in one pass — then plug the
|
|
44
|
+
result into *any* test framework, or none.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from validate_nested import validate
|
|
48
|
+
from validate_nested.lambdas import equal, length, more
|
|
49
|
+
|
|
50
|
+
# a nested response — dotted paths and [*] reach into it
|
|
51
|
+
response = {
|
|
52
|
+
"status": "ok",
|
|
53
|
+
"page": {"size": 3, "index": 0},
|
|
54
|
+
"results": [
|
|
55
|
+
{"id": "a1", "score": 0.91},
|
|
56
|
+
{"id": "b2", "score": 0.40}, # <- too low
|
|
57
|
+
{"id": "c3", "score": 0.95},
|
|
58
|
+
],
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
model = {
|
|
62
|
+
"status": (str, equal("ok")), # top-level field
|
|
63
|
+
"page.size": (int, equal(3)), # dotted path into a nested dict
|
|
64
|
+
"results": (list, length(3)), # the list itself
|
|
65
|
+
"results[*].id": str, # a field of every list item
|
|
66
|
+
"results[*].score": (float, more(0.5)), # per-item value check
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
r = validate(response, model) # -> Result(ok, failures, skipped); never raises
|
|
70
|
+
assert r.ok, r.report()
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The failing item is reported by its exact path:
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
1 validation failure(s):
|
|
77
|
+
- [results[1].score] should be greater than 0.5, got 0.4
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
No classes to declare, no schema files — the model *is* the spec, inline where you use it.
|
|
81
|
+
|
|
82
|
+
### Nesting of any depth
|
|
83
|
+
|
|
84
|
+
Paths reach as deep as the data goes, and `[*]` wildcards stack — one flat model describes
|
|
85
|
+
a whole tree of orders → items → tags:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from validate_nested import validate
|
|
89
|
+
from validate_nested.lambdas import equal, length, more, contains, not_empty
|
|
90
|
+
|
|
91
|
+
response = {
|
|
92
|
+
"status": "ok",
|
|
93
|
+
"meta": {
|
|
94
|
+
"page": {"index": 0, "size": 2},
|
|
95
|
+
"total": 2,
|
|
96
|
+
},
|
|
97
|
+
"orders": [
|
|
98
|
+
{
|
|
99
|
+
"id": "ORD-1",
|
|
100
|
+
"customer": {"id": 42, "email": "ada@example.io"},
|
|
101
|
+
"items": [
|
|
102
|
+
{"sku": "A-1", "price": 9.99, "tags": ["new"]},
|
|
103
|
+
{"sku": "B-2", "price": 19.50, "tags": ["sale", "hot"]},
|
|
104
|
+
],
|
|
105
|
+
"shipping": {"country": "DE", "zip": "10115"},
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
model = {
|
|
111
|
+
"status": (str, equal("ok")),
|
|
112
|
+
"meta.page.index": int, # dotted path, 3 levels down
|
|
113
|
+
"meta.total": (int, more(0)),
|
|
114
|
+
"orders": (list, not_empty()),
|
|
115
|
+
"orders[*].id": (str, not_empty()),
|
|
116
|
+
"orders[*].customer.email": (str, contains("@")), # wildcard then a dotted path
|
|
117
|
+
"orders[*].items": (list, not_empty()),
|
|
118
|
+
"orders[*].items[*].sku": str, # wildcard inside a wildcard
|
|
119
|
+
"orders[*].items[*].price": (float, more(0)),
|
|
120
|
+
"orders[*].items[*].tags[*]": str, # three wildcards deep
|
|
121
|
+
"orders[*].shipping.country": (str, length(2)),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
assert validate(response, model).ok
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
If, say, the second item of the first order had a negative price, that one element is
|
|
128
|
+
pinpointed — every other item still validates:
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
1 validation failure(s):
|
|
132
|
+
- [orders[0].items[1].price] should be greater than 0, got -1.0
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Why
|
|
138
|
+
|
|
139
|
+
- **Terse.** One dict describes a whole response. No model class per shape.
|
|
140
|
+
- **Structural + value checks together.** `(int, equal(0))`, `(list, length(3))`, `ids[*]`.
|
|
141
|
+
- **Framework-agnostic.** The engine returns data; *you* decide how to report (plain
|
|
142
|
+
code, immediate `assert`, soft-aggregate, or pytest).
|
|
143
|
+
- **Zero dependencies.** Pure Python 3.8+. `pytest` is only needed to run the tests.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Install
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
pip install validate-nested # core, no dependencies
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## The model
|
|
156
|
+
|
|
157
|
+
A model is `{path: rule}`. A **rule** is a type, a marker, a validator, or a tuple of those.
|
|
158
|
+
|
|
159
|
+
### Types
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
{"age": int, "name": str, "tags": list, "meta": dict, "score": float}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
A tuple of types is a union (`(int, str)` = "either"). See [tests/test_types.py](tests/test_types.py).
|
|
166
|
+
|
|
167
|
+
### Type + value validators
|
|
168
|
+
|
|
169
|
+
Combine a type with one or more validators in a tuple:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
{"score": (float, valid_score), "ids": (list, length(3)), "state": (str, equal("ok"))}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Each validator has its own file:
|
|
176
|
+
[`valid_score`](tests/lambdas/test_valid_score.py),
|
|
177
|
+
[`length`](tests/lambdas/test_length.py),
|
|
178
|
+
[`equal`](tests/lambdas/test_equal.py) — and the full list is in the validators table below.
|
|
179
|
+
|
|
180
|
+
### Paths & wildcards
|
|
181
|
+
|
|
182
|
+
Dotted paths, the `[*]` wildcard (every item of a list), and explicit indices:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
{
|
|
186
|
+
"data.user.id": int, # nested
|
|
187
|
+
"items[*]": dict, # every element of items
|
|
188
|
+
"items[*].price": float, # price of every element
|
|
189
|
+
"items[0].sku": str, # a specific element by index
|
|
190
|
+
"orders[*].items[*].price": float, # nested wildcards
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
A failure carries the concrete index (`items[1].price`), an out-of-range index is
|
|
195
|
+
reported as missing, and the two styles can be mixed. See
|
|
196
|
+
[tests/test_lists.py](tests/test_lists.py).
|
|
197
|
+
|
|
198
|
+
### Presence & coercion markers
|
|
199
|
+
|
|
200
|
+
**Built-in only** (you can't define custom markers). They tune presence, emptiness and
|
|
201
|
+
coercion:
|
|
202
|
+
|
|
203
|
+
| Marker | Meaning |
|
|
204
|
+
|---|---|
|
|
205
|
+
| [`not_empty()`](tests/rules/test_not_empty.py) | `len > 0` (the **default** for sized types) |
|
|
206
|
+
| [`empty()`](tests/rules/test_empty.py) | `len == 0` |
|
|
207
|
+
| [`opt()`](tests/rules/test_opt.py) | value may be absent → passes if missing |
|
|
208
|
+
| [`required()`](tests/rules/test_required.py) | if this rule fails, stop and don't check the rest |
|
|
209
|
+
| [`not_exist()`](tests/rules/test_not_exist.py) | the path must be **absent** |
|
|
210
|
+
| [`undefined()`](tests/rules/test_undefined.py) | don't assume empty-vs-filled (skip the len check) |
|
|
211
|
+
| [`to_int()`](tests/rules/test_to_int.py) / [`to_float()`](tests/rules/test_to_float.py) | coerce before running validators, e.g. `(str, to_int(equal(5)))` |
|
|
212
|
+
| [`skip()`](tests/test_skip.py) | if this rule fails, signal a **skip** instead of a failure |
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
{
|
|
216
|
+
"id": required(str), # must be present, a string
|
|
217
|
+
"tags": not_empty(list), # a non-empty list
|
|
218
|
+
"notes": empty(str), # an empty string
|
|
219
|
+
"nickname": opt(str), # may be absent
|
|
220
|
+
"legacy": not_exist(), # must be absent
|
|
221
|
+
"count": (str, to_int(equal(5))), # coerce "5" -> 5 before checking
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Markers compose. The key idiom is `required(opt(...))` — an **optional gate**: the field
|
|
226
|
+
may be absent (then it and its children pass), but **if present** its shape is checked
|
|
227
|
+
first, and if that fails the children are skipped:
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
model = {
|
|
231
|
+
"profile": required(opt(dict)), # may be absent; if present, must be a dict
|
|
232
|
+
"profile.name": (str, equal("Ada")), # only reached when profile is a valid dict
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
validate({"other": 1}, model).ok # True — profile absent, children skipped
|
|
236
|
+
validate({"profile": {"name": "Ada"}}, model).ok # True — present and valid
|
|
237
|
+
validate({"profile": "oops"}, model).ok # False — [profile] expected dict, got str
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
(`required(not_exist())` composes the same way.) See
|
|
241
|
+
[tests/rules/test_required.py](tests/rules/test_required.py) and
|
|
242
|
+
[tests/rules/test_opt.py](tests/rules/test_opt.py).
|
|
243
|
+
|
|
244
|
+
### Validators — built-in (`from validate_nested.lambdas import ...`)
|
|
245
|
+
|
|
246
|
+
| Validator | Passes when |
|
|
247
|
+
|---|---|
|
|
248
|
+
| [`equal(x)`](tests/lambdas/test_equal.py) / [`not_equal(x)`](tests/lambdas/test_not_equal.py) | value `==` / `!=` x |
|
|
249
|
+
| [`length(n)`](tests/lambdas/test_length.py) | `len(value) == n` |
|
|
250
|
+
| [`approx(x, delta=0.01)`](tests/lambdas/test_approx.py) | `abs(value - x) <= delta` |
|
|
251
|
+
| [`contains(x)`](tests/lambdas/test_contains.py) | substring / all items in value |
|
|
252
|
+
| [`exists_in((a, b, ...))`](tests/lambdas/test_exists_in.py) | value is one of |
|
|
253
|
+
| [`in_range(a, b)`](tests/lambdas/test_in_range.py) | `a < value < b` |
|
|
254
|
+
| [`less(x)`](tests/lambdas/test_less.py) / [`more(x)`](tests/lambdas/test_more.py) | `value < x` / `value > x` |
|
|
255
|
+
| [`ends(x)`](tests/lambdas/test_ends.py) | `value.endswith(x)` |
|
|
256
|
+
| [`count(value, amount)`](tests/lambdas/test_count.py) | value appears `amount` times |
|
|
257
|
+
| [`split_length(n, sep=",")`](tests/lambdas/test_split_length.py) | `len(value.split(sep)) == n` |
|
|
258
|
+
| [`lower_match(x)`](tests/lambdas/test_lower_match.py) | case-insensitive equality |
|
|
259
|
+
| [`valid_score`](tests/lambdas/test_valid_score.py) / [`positive_number`](tests/lambdas/test_positive_number.py) / [`non_zero`](tests/lambdas/test_non_zero.py) | `0 < v <= 1` / `v >= 0` / `v > 0` |
|
|
260
|
+
| [`split_positive_numbers`](tests/lambdas/test_split_positive_numbers.py) | all comma-split parts `>= 0` |
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
{
|
|
264
|
+
"title": (str, length(8)), # exactly 8 chars
|
|
265
|
+
"status": (str, exists_in(("open", "closed"))), # one of
|
|
266
|
+
"score": (float, in_range(0, 1)), # 0 < score < 1
|
|
267
|
+
"tags": (list, contains("urgent")), # list contains "urgent"
|
|
268
|
+
"ref": (str, ends(".pdf")), # ends with ".pdf"
|
|
269
|
+
"retries": (int, less(5)), # < 5
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Extending — custom validators
|
|
274
|
+
|
|
275
|
+
Need a check the built-ins don't cover? Two ways, both drop straight into a model
|
|
276
|
+
(including over `[*]` list items):
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
from validate_nested.lambdas import predicate, LambdaInfo
|
|
280
|
+
|
|
281
|
+
# 1) inline, the short way — predicate(callable, message)
|
|
282
|
+
is_even = predicate(lambda v: v % 2 == 0, "should be even")
|
|
283
|
+
model = {"count": (int, is_even)} # fails as: should be even, got 3
|
|
284
|
+
|
|
285
|
+
# 2) reusable / parametrised — a function returning LambdaInfo
|
|
286
|
+
# (this is exactly how the built-ins like equal() and length() are written)
|
|
287
|
+
def divisible_by(n):
|
|
288
|
+
return LambdaInfo(
|
|
289
|
+
func_lambda=lambda v: v % n == 0,
|
|
290
|
+
lambda_assert_msg=f"should be divisible by {n}",
|
|
291
|
+
lambda_details=f"divisible_by({n})",
|
|
292
|
+
)
|
|
293
|
+
model = {"size": (int, divisible_by(3))}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
> ⚠️ **A bare `lambda` is silently ignored.** `(int, lambda v: v > 0)` won't run — the
|
|
297
|
+
> engine only recognises a validator once it's wrapped (`predicate(...)` or
|
|
298
|
+
> `LambdaInfo(...)`). Always wrap; never drop a raw `lambda` into a model.
|
|
299
|
+
|
|
300
|
+
Runnable examples (and custom `report(formatter=...)`):
|
|
301
|
+
[tests/test_extending.py](tests/test_extending.py).
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## Consumption modes
|
|
306
|
+
|
|
307
|
+
### 1. Pure — inspect the result
|
|
308
|
+
|
|
309
|
+
```python
|
|
310
|
+
result = validate(record, model)
|
|
311
|
+
if not result.ok:
|
|
312
|
+
for f in result.failures:
|
|
313
|
+
print(f.path, f.message)
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
`result.ok` is True only when **every** path passed (a later passing field never masks an
|
|
317
|
+
earlier failure), and `bool(result) == result.ok` — so `validate` reads cleanly as a gate,
|
|
318
|
+
guarding work that should run only on a well-formed record:
|
|
319
|
+
|
|
320
|
+
```python
|
|
321
|
+
if validate(response, model): # proceed only when the shape is right
|
|
322
|
+
enqueue(response["orders"])
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
See [tests/test_conditions.py](tests/test_conditions.py).
|
|
326
|
+
|
|
327
|
+
### 2. Immediate — assert on the result
|
|
328
|
+
|
|
329
|
+
`validate` is the only entry point; you decide when to assert. `Result.report()`
|
|
330
|
+
renders a readable message for the assert line:
|
|
331
|
+
|
|
332
|
+
```python
|
|
333
|
+
r = validate(record, model)
|
|
334
|
+
assert r.ok, r.report() # AssertionError lists every failure
|
|
335
|
+
assert r.ok, r.report(formatter=my_fmt) # custom message per failure
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### 3. Soft — aggregate across several checks
|
|
339
|
+
|
|
340
|
+
```python
|
|
341
|
+
from validate_nested import SoftValidator
|
|
342
|
+
|
|
343
|
+
with SoftValidator() as soft:
|
|
344
|
+
soft.validate(resp_a, model_a)
|
|
345
|
+
soft.validate(resp_b, model_b)
|
|
346
|
+
# raises once at block end, listing every failure from both
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
See [tests/test_modes.py](tests/test_modes.py).
|
|
350
|
+
|
|
351
|
+
### 4. pytest (optional)
|
|
352
|
+
|
|
353
|
+
There is **no** shipped pytest helper — `validate` is all you need, and you wire the
|
|
354
|
+
`Result` however you like (this also keeps the namespace clear of `pytest-check` & co.).
|
|
355
|
+
A typical wiring is three lines; define your own once and reuse it:
|
|
356
|
+
|
|
357
|
+
```python
|
|
358
|
+
def validate_or_skip(record, model): # your helper — keep it wherever you like
|
|
359
|
+
r = validate(record, model)
|
|
360
|
+
if r.skipped:
|
|
361
|
+
pytest.skip(r.skipped) # a fired skip() rule -> skip the test
|
|
362
|
+
assert r.ok, r.report() # any other failure -> fail with the report
|
|
363
|
+
return r
|
|
364
|
+
|
|
365
|
+
def test_search():
|
|
366
|
+
validate_or_skip(response.json(), {"state": (str, equal("ok")), "hits[*]": dict})
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Not using pytest? Route the result anywhere — `unittest`'s `skipTest`, a logger, a custom
|
|
370
|
+
exception. See [tests/test_skip.py](tests/test_skip.py) for skip wired both ways.
|
|
371
|
+
|
|
372
|
+
### 5. Compose your own — e.g. a request helper
|
|
373
|
+
|
|
374
|
+
`validate` is a building block — wrap it in whatever helper fits your domain. A common
|
|
375
|
+
one validates an HTTP response's **status code as a gate**, then its body, and *only* its
|
|
376
|
+
body if the code was right. Mark `status` `required` so a wrong code fails once and
|
|
377
|
+
short-circuits — the `body.*` rules behind it are never checked (no cascade of
|
|
378
|
+
"missing body field" noise behind an error response):
|
|
379
|
+
|
|
380
|
+
```python
|
|
381
|
+
from validate_nested import validate
|
|
382
|
+
from validate_nested.lambdas import required, equal
|
|
383
|
+
|
|
384
|
+
def validate_request(response, expected_code, model):
|
|
385
|
+
record = {"status": response.status_code, "body": response.json()}
|
|
386
|
+
gate = {"status": required((int, equal(expected_code)))}
|
|
387
|
+
r = validate(record, {**gate, **model})
|
|
388
|
+
assert r.ok, r.report()
|
|
389
|
+
return r
|
|
390
|
+
|
|
391
|
+
# body rules are written against body.* paths:
|
|
392
|
+
validate_request(response, 200, {"body.id": int, "body.state": (str, equal("ok"))})
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
A wrong code reports only `[status] ...` (the body is never inspected); a right code with
|
|
396
|
+
a bad body reports `[body.state] ...`. See
|
|
397
|
+
[tests/test_request_pattern.py](tests/test_request_pattern.py).
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
401
|
+
## skip semantics
|
|
402
|
+
|
|
403
|
+
`skip()` is a test-control concern, so the **core never skips anything** — when a
|
|
404
|
+
`skip()`-marked rule fails, `validate` returns `Result(skipped="<reason>")`. You decide:
|
|
405
|
+
|
|
406
|
+
```python
|
|
407
|
+
r = validate(record, {"feature": skip(dict)})
|
|
408
|
+
if r.skipped:
|
|
409
|
+
pytest.skip(r.skipped) # or unittest's skipTest, a log call, your own — your choice
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Override the default skip reason per field with `ComplexRule(value=skip(...),
|
|
413
|
+
options={"assert_msg": "..."})`. See [tests/test_skip.py](tests/test_skip.py).
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
417
|
+
## Custom messages
|
|
418
|
+
|
|
419
|
+
`Failure(path, message)` is neutral. Render it your way with a `formatter` (a callable
|
|
420
|
+
`Failure -> str`):
|
|
421
|
+
|
|
422
|
+
```python
|
|
423
|
+
r = validate(record, model)
|
|
424
|
+
assert r.ok, r.report(formatter=lambda f: f"{f.path} is wrong: {f.message}")
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
See [tests/test_modes.py](tests/test_modes.py) (`test_custom_formatter`).
|
|
428
|
+
|
|
429
|
+
---
|
|
430
|
+
|
|
431
|
+
## Advanced — per-field message (`ComplexRule`)
|
|
432
|
+
|
|
433
|
+
`report(formatter=...)` reshapes *every* failure at once. To override just **one field's**
|
|
434
|
+
message, wrap its rule in `ComplexRule(value=<rule>, options={...})` — `assert_msg`
|
|
435
|
+
replaces the message, `add_msg` prepends context.
|
|
436
|
+
|
|
437
|
+
Its most useful case is giving a `skip()` a readable reason: by default a fired skip
|
|
438
|
+
carries the raw mismatch text (`expected dict, got str`), which says nothing about *why*
|
|
439
|
+
you skipped. `assert_msg` fixes that:
|
|
440
|
+
|
|
441
|
+
```python
|
|
442
|
+
from validate_nested import ComplexRule, validate
|
|
443
|
+
from validate_nested.lambdas import skip
|
|
444
|
+
|
|
445
|
+
model = {"beta_feature": ComplexRule(skip(dict), {"assert_msg": "beta disabled in this env"})}
|
|
446
|
+
r = validate(record, model)
|
|
447
|
+
# r.skipped == "beta disabled in this env" (not "expected dict, got str")
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
See [tests/test_complex_rule.py](tests/test_complex_rule.py) (messages) and
|
|
451
|
+
[tests/test_skip.py](tests/test_skip.py) (custom skip reason).
|
|
452
|
+
|
|
453
|
+
---
|
|
454
|
+
|
|
455
|
+
## Result API
|
|
456
|
+
|
|
457
|
+
```python
|
|
458
|
+
Result(
|
|
459
|
+
ok: bool, # True iff nothing failed
|
|
460
|
+
failures: list[Failure], # each has .path and .message
|
|
461
|
+
skipped: str | None, # reason if a skip() rule fired
|
|
462
|
+
)
|
|
463
|
+
bool(result) # == result.ok
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
---
|
|
467
|
+
|
|
468
|
+
## License
|
|
469
|
+
|
|
470
|
+
MIT.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
validate_nested/__init__.py,sha256=bVym5tjmZmsNlzT0ts4RnZ9PYZD4nE80Z1sxiPbpwJk,1038
|
|
2
|
+
validate_nested/engine.py,sha256=tj55YfJm9tQaMoiEdoJa_8ywp9OXVZL94rhSlTAo-qY,7628
|
|
3
|
+
validate_nested/lambdas.py,sha256=2J4jXvMepUSIVnBgu7wElyhNqeElc8rQ2wEzntfsGNI,7046
|
|
4
|
+
validate_nested/result.py,sha256=osDqk6ap0C5r5m3vBTpshN5wnsV6UTArBZc60sFCcNk,2062
|
|
5
|
+
validate_nested/rules.py,sha256=0wQ7Fg7m_m0OMywQj0OfrcF0hyj1T_tYlql7AHfUYAQ,3911
|
|
6
|
+
validate_nested/soft.py,sha256=HB4DREmQcwWDLmDBbMmIGnbK3Nt1Rl3hk4ZJgllm6Q0,1714
|
|
7
|
+
validate_nested/_utils/__init__.py,sha256=-s1eYRQt2I4MGqYvaRro_HFXaB7BkMy1AHKYh5czDbI,140
|
|
8
|
+
validate_nested/_utils/paths.py,sha256=1ZUv_FnesgB8ScBJmhhmxkbaVrI1D6ZQm9apIcX_Yek,2269
|
|
9
|
+
validate_nested-0.1.0.dist-info/licenses/LICENSE,sha256=2AeXfTPpJHhcn4olaonIiTEeIs_Hw8Y8fcZ4lNqxyGc,1072
|
|
10
|
+
validate_nested-0.1.0.dist-info/METADATA,sha256=E4RtmDEmNLYxIFDJtYvtfY4na56qbImRs4ew2TAWrNU,17560
|
|
11
|
+
validate_nested-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
validate_nested-0.1.0.dist-info/top_level.txt,sha256=bV9F7Q4Si-fVjcVR2HLwu1Q-ZWT3inEVNwUj15nush8,16
|
|
13
|
+
validate_nested-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sergei Murashov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
validate_nested
|