Skip to content

ant1kdream/validate-nested

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

7 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

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.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages