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.
@@ -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
+ [![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.