validate-nested 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- validate_nested-0.1.0/PKG-INFO +470 -0
- validate_nested-0.1.0/README.md +439 -0
- validate_nested-0.1.0/pyproject.toml +49 -0
- validate_nested-0.1.0/setup.cfg +4 -0
- validate_nested-0.1.0/src/validate_nested/__init__.py +36 -0
- validate_nested-0.1.0/src/validate_nested/_utils/__init__.py +3 -0
- validate_nested-0.1.0/src/validate_nested/_utils/paths.py +66 -0
- validate_nested-0.1.0/src/validate_nested/engine.py +204 -0
- validate_nested-0.1.0/src/validate_nested/lambdas.py +216 -0
- validate_nested-0.1.0/src/validate_nested/result.py +67 -0
- validate_nested-0.1.0/src/validate_nested/rules.py +113 -0
- validate_nested-0.1.0/src/validate_nested/soft.py +52 -0
- validate_nested-0.1.0/src/validate_nested.egg-info/PKG-INFO +470 -0
- validate_nested-0.1.0/src/validate_nested.egg-info/SOURCES.txt +24 -0
- validate_nested-0.1.0/src/validate_nested.egg-info/dependency_links.txt +1 -0
- validate_nested-0.1.0/src/validate_nested.egg-info/requires.txt +3 -0
- validate_nested-0.1.0/src/validate_nested.egg-info/top_level.txt +1 -0
- validate_nested-0.1.0/tests/test_complex_rule.py +105 -0
- validate_nested-0.1.0/tests/test_conditions.py +109 -0
- validate_nested-0.1.0/tests/test_extending.py +133 -0
- validate_nested-0.1.0/tests/test_lists.py +154 -0
- validate_nested-0.1.0/tests/test_modes.py +51 -0
- validate_nested-0.1.0/tests/test_request_pattern.py +109 -0
- validate_nested-0.1.0/tests/test_skip.py +77 -0
- validate_nested-0.1.0/tests/test_types.py +79 -0
|
@@ -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,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.
|