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.
@@ -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,3 @@
1
+ from validate_nested._utils.paths import dict_paths, path_getter, stringify_dict
2
+
3
+ __all__ = ["dict_paths", "path_getter", "stringify_dict"]
@@ -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)
@@ -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
@@ -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
+ [![CI](https://github.com/ant1kdream/validate-nested/actions/workflows/ci.yml/badge.svg)](https://github.com/ant1kdream/validate-nested/actions/workflows/ci.yml)
35
+ [![PyPI](https://img.shields.io/pypi/v/validate-nested.svg)](https://pypi.org/project/validate-nested/)
36
+ [![Python versions](https://img.shields.io/pypi/pyversions/validate-nested.svg)](https://pypi.org/project/validate-nested/)
37
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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