A tiny, dependency-free DSL for validating the shape of nested dicts / JSON responses of any depth.
Describe what a response should look like with a compact model dict and let the engine
check types, lengths, values, presence and per-item rules in one pass β then plug the
result into any test framework, or none.
from validate_nested import validate
from validate_nested.lambdas import equal, length, more
# a nested response β dotted paths and [*] reach into it
response = {
"status": "ok",
"page": {"size": 3, "index": 0},
"results": [
{"id": "a1", "score": 0.91},
{"id": "b2", "score": 0.40}, # <- too low
{"id": "c3", "score": 0.95},
],
}
model = {
"status": (str, equal("ok")), # top-level field
"page.size": (int, equal(3)), # dotted path into a nested dict
"results": (list, length(3)), # the list itself
"results[*].id": str, # a field of every list item
"results[*].score": (float, more(0.5)), # per-item value check
}
r = validate(response, model) # -> Result(ok, failures, skipped); never raises
assert r.ok, r.report()The failing item is reported by its exact path:
1 validation failure(s):
- [results[1].score] should be greater than 0.5, got 0.4
No classes to declare, no schema files β the model is the spec, inline where you use it.
Paths reach as deep as the data goes, and [*] wildcards stack β one flat model describes
a whole tree of orders β items β tags:
from validate_nested import validate
from validate_nested.lambdas import equal, length, more, contains, not_empty
response = {
"status": "ok",
"meta": {
"page": {"index": 0, "size": 2},
"total": 2,
},
"orders": [
{
"id": "ORD-1",
"customer": {"id": 42, "email": "ada@example.io"},
"items": [
{"sku": "A-1", "price": 9.99, "tags": ["new"]},
{"sku": "B-2", "price": 19.50, "tags": ["sale", "hot"]},
],
"shipping": {"country": "DE", "zip": "10115"},
},
],
}
model = {
"status": (str, equal("ok")),
"meta.page.index": int, # dotted path, 3 levels down
"meta.total": (int, more(0)),
"orders": (list, not_empty()),
"orders[*].id": (str, not_empty()),
"orders[*].customer.email": (str, contains("@")), # wildcard then a dotted path
"orders[*].items": (list, not_empty()),
"orders[*].items[*].sku": str, # wildcard inside a wildcard
"orders[*].items[*].price": (float, more(0)),
"orders[*].items[*].tags[*]": str, # three wildcards deep
"orders[*].shipping.country": (str, length(2)),
}
assert validate(response, model).okIf, say, the second item of the first order had a negative price, that one element is pinpointed β every other item still validates:
1 validation failure(s):
- [orders[0].items[1].price] should be greater than 0, got -1.0
- Terse. One dict describes a whole response. No model class per shape.
- Structural + value checks together.
(int, equal(0)),(list, length(3)),ids[*]. - Framework-agnostic. The engine returns data; you decide how to report (plain
code, immediate
assert, soft-aggregate, or pytest). - Zero dependencies. Pure Python 3.8+.
pytestis only needed to run the tests.
pip install validate-nested # core, no dependenciesA model is {path: rule}. A rule is a type, a marker, a validator, or a tuple of those.
{"age": int, "name": str, "tags": list, "meta": dict, "score": float}A tuple of types is a union ((int, str) = "either"). See tests/test_types.py.
Combine a type with one or more validators in a tuple:
{"score": (float, valid_score), "ids": (list, length(3)), "state": (str, equal("ok"))}Each validator has its own file:
valid_score,
length,
equal β and the full list is in the validators table below.
Dotted paths, the [*] wildcard (every item of a list), and explicit indices:
{
"data.user.id": int, # nested
"items[*]": dict, # every element of items
"items[*].price": float, # price of every element
"items[0].sku": str, # a specific element by index
"orders[*].items[*].price": float, # nested wildcards
}A failure carries the concrete index (items[1].price), an out-of-range index is
reported as missing, and the two styles can be mixed. See
tests/test_lists.py.
Built-in only (you can't define custom markers). They tune presence, emptiness and coercion:
| Marker | Meaning |
|---|---|
not_empty() |
len > 0 (the default for sized types) |
empty() |
len == 0 |
opt() |
value may be absent β passes if missing |
required() |
if this rule fails, stop and don't check the rest |
not_exist() |
the path must be absent |
undefined() |
don't assume empty-vs-filled (skip the len check) |
to_int() / to_float() |
coerce before running validators, e.g. (str, to_int(equal(5))) |
skip() |
if this rule fails, signal a skip instead of a failure |
{
"id": required(str), # must be present, a string
"tags": not_empty(list), # a non-empty list
"notes": empty(str), # an empty string
"nickname": opt(str), # may be absent
"legacy": not_exist(), # must be absent
"count": (str, to_int(equal(5))), # coerce "5" -> 5 before checking
}Markers compose. The key idiom is required(opt(...)) β an optional gate: the field
may be absent (then it and its children pass), but if present its shape is checked
first, and if that fails the children are skipped:
model = {
"profile": required(opt(dict)), # may be absent; if present, must be a dict
"profile.name": (str, equal("Ada")), # only reached when profile is a valid dict
}
validate({"other": 1}, model).ok # True β profile absent, children skipped
validate({"profile": {"name": "Ada"}}, model).ok # True β present and valid
validate({"profile": "oops"}, model).ok # False β [profile] expected dict, got str(required(not_exist()) composes the same way.) See
tests/rules/test_required.py and
tests/rules/test_opt.py.
| Validator | Passes when |
|---|---|
equal(x) / not_equal(x) |
value == / != x |
length(n) |
len(value) == n |
approx(x, delta=0.01) |
abs(value - x) <= delta |
contains(x) |
substring / all items in value |
exists_in((a, b, ...)) |
value is one of |
in_range(a, b) |
a < value < b |
less(x) / more(x) |
value < x / value > x |
ends(x) |
value.endswith(x) |
count(value, amount) |
value appears amount times |
split_length(n, sep=",") |
len(value.split(sep)) == n |
lower_match(x) |
case-insensitive equality |
valid_score / positive_number / non_zero |
0 < v <= 1 / v >= 0 / v > 0 |
split_positive_numbers |
all comma-split parts >= 0 |
{
"title": (str, length(8)), # exactly 8 chars
"status": (str, exists_in(("open", "closed"))), # one of
"score": (float, in_range(0, 1)), # 0 < score < 1
"tags": (list, contains("urgent")), # list contains "urgent"
"ref": (str, ends(".pdf")), # ends with ".pdf"
"retries": (int, less(5)), # < 5
}Need a check the built-ins don't cover? Two ways, both drop straight into a model
(including over [*] list items):
from validate_nested.lambdas import predicate, LambdaInfo
# 1) inline, the short way β predicate(callable, message)
is_even = predicate(lambda v: v % 2 == 0, "should be even")
model = {"count": (int, is_even)} # fails as: should be even, got 3
# 2) reusable / parametrised β a function returning LambdaInfo
# (this is exactly how the built-ins like equal() and length() are written)
def divisible_by(n):
return LambdaInfo(
func_lambda=lambda v: v % n == 0,
lambda_assert_msg=f"should be divisible by {n}",
lambda_details=f"divisible_by({n})",
)
model = {"size": (int, divisible_by(3))}
β οΈ A barelambdais silently ignored.(int, lambda v: v > 0)won't run β the engine only recognises a validator once it's wrapped (predicate(...)orLambdaInfo(...)). Always wrap; never drop a rawlambdainto a model.
Runnable examples (and custom report(formatter=...)):
tests/test_extending.py.
result = validate(record, model)
if not result.ok:
for f in result.failures:
print(f.path, f.message)result.ok is True only when every path passed (a later passing field never masks an
earlier failure), and bool(result) == result.ok β so validate reads cleanly as a gate,
guarding work that should run only on a well-formed record:
if validate(response, model): # proceed only when the shape is right
enqueue(response["orders"])validate is the only entry point; you decide when to assert. Result.report()
renders a readable message for the assert line:
r = validate(record, model)
assert r.ok, r.report() # AssertionError lists every failure
assert r.ok, r.report(formatter=my_fmt) # custom message per failurefrom validate_nested import SoftValidator
with SoftValidator() as soft:
soft.validate(resp_a, model_a)
soft.validate(resp_b, model_b)
# raises once at block end, listing every failure from bothSee tests/test_modes.py.
There is no shipped pytest helper β validate is all you need, and you wire the
Result however you like (this also keeps the namespace clear of pytest-check & co.).
A typical wiring is three lines; define your own once and reuse it:
def validate_or_skip(record, model): # your helper β keep it wherever you like
r = validate(record, model)
if r.skipped:
pytest.skip(r.skipped) # a fired skip() rule -> skip the test
assert r.ok, r.report() # any other failure -> fail with the report
return r
def test_search():
validate_or_skip(response.json(), {"state": (str, equal("ok")), "hits[*]": dict})Not using pytest? Route the result anywhere β unittest's skipTest, a logger, a custom
exception. See tests/test_skip.py for skip wired both ways.
validate is a building block β wrap it in whatever helper fits your domain. A common
one validates an HTTP response's status code as a gate, then its body, and only its
body if the code was right. Mark status required so a wrong code fails once and
short-circuits β the body.* rules behind it are never checked (no cascade of
"missing body field" noise behind an error response):
from validate_nested import validate
from validate_nested.lambdas import required, equal
def validate_request(response, expected_code, model):
record = {"status": response.status_code, "body": response.json()}
gate = {"status": required((int, equal(expected_code)))}
r = validate(record, {**gate, **model})
assert r.ok, r.report()
return r
# body rules are written against body.* paths:
validate_request(response, 200, {"body.id": int, "body.state": (str, equal("ok"))})A wrong code reports only [status] ... (the body is never inspected); a right code with
a bad body reports [body.state] .... See
tests/test_request_pattern.py.
skip() is a test-control concern, so the core never skips anything β when a
skip()-marked rule fails, validate returns Result(skipped="<reason>"). You decide:
r = validate(record, {"feature": skip(dict)})
if r.skipped:
pytest.skip(r.skipped) # or unittest's skipTest, a log call, your own β your choiceOverride the default skip reason per field with ComplexRule(value=skip(...), options={"assert_msg": "..."}). See tests/test_skip.py.
Failure(path, message) is neutral. Render it your way with a formatter (a callable
Failure -> str):
r = validate(record, model)
assert r.ok, r.report(formatter=lambda f: f"{f.path} is wrong: {f.message}")See tests/test_modes.py (test_custom_formatter).
report(formatter=...) reshapes every failure at once. To override just one field's
message, wrap its rule in ComplexRule(value=<rule>, options={...}) β assert_msg
replaces the message, add_msg prepends context.
Its most useful case is giving a skip() a readable reason: by default a fired skip
carries the raw mismatch text (expected dict, got str), which says nothing about why
you skipped. assert_msg fixes that:
from validate_nested import ComplexRule, validate
from validate_nested.lambdas import skip
model = {"beta_feature": ComplexRule(skip(dict), {"assert_msg": "beta disabled in this env"})}
r = validate(record, model)
# r.skipped == "beta disabled in this env" (not "expected dict, got str")See tests/test_complex_rule.py (messages) and tests/test_skip.py (custom skip reason).
Result(
ok: bool, # True iff nothing failed
failures: list[Failure], # each has .path and .message
skipped: str | None, # reason if a skip() rule fired
)
bool(result) # == result.okMIT.