Skip to main content

A tiny, framework-agnostic DSL for validating the shape of nested dicts / JSON responses.

Project description

validate-nested

CI PyPI Python versions License: MIT

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.

Nesting of any depth

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).ok

If, 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

Why

  • 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+. pytest is only needed to run the tests.

Install

pip install validate-nested    # core, no dependencies

The model

A model is {path: rule}. A rule is a type, a marker, a validator, or a tuple of those.

Types

{"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.

Type + value validators

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.

Paths & wildcards

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.

Presence & coercion markers

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.

Validators — built-in (from validate_nested.lambdas import ...)

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
}

Extending — custom validators

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 bare lambda is silently ignored. (int, lambda v: v > 0) won't run — the engine only recognises a validator once it's wrapped (predicate(...) or LambdaInfo(...)). Always wrap; never drop a raw lambda into a model.

Runnable examples (and custom report(formatter=...)): tests/test_extending.py.


Consumption modes

1. Pure — inspect the result

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"])

See tests/test_conditions.py.

2. Immediate — assert on the result

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 failure

3. Soft — aggregate across several checks

from 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 both

See tests/test_modes.py.

4. pytest (optional)

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.

5. Compose your own — e.g. a request helper

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 semantics

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 choice

Override the default skip reason per field with ComplexRule(value=skip(...), options={"assert_msg": "..."}). See tests/test_skip.py.


Custom messages

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).


Advanced — per-field message (ComplexRule)

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 API

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.ok

License

MIT.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

validate_nested-0.1.0.tar.gz (27.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

validate_nested-0.1.0-py3-none-any.whl (18.1 kB view details)

Uploaded Python 3

File details

Details for the file validate_nested-0.1.0.tar.gz.

File metadata

  • Download URL: validate_nested-0.1.0.tar.gz
  • Upload date:
  • Size: 27.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for validate_nested-0.1.0.tar.gz
Algorithm Hash digest
SHA256 4bf8227beba0b23478c0ea1d8e44eeae50c5b6de0ee7f61a699dd5d26265078e
MD5 5a6ae3f5d553193f783f762b6c11c353
BLAKE2b-256 065c9927eab4df42dd839f451baa910663988349d39feb5cf2014faa2a813894

See more details on using hashes here.

Provenance

The following attestation bundles were made for validate_nested-0.1.0.tar.gz:

Publisher: release.yml on ant1kdream/validate-nested

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file validate_nested-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: validate_nested-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 18.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for validate_nested-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d9fd74499cffc418e55c7f3b2f79e4a6d74cff3c0465f886ac1dd44ff84c37bf
MD5 37dad6993ea4572d701a9d5bb8046074
BLAKE2b-256 5e865078dde02df437d5c616240c1ccf57c64579ae3992ad38705c2065150851

See more details on using hashes here.

Provenance

The following attestation bundles were made for validate_nested-0.1.0-py3-none-any.whl:

Publisher: release.yml on ant1kdream/validate-nested

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page