Mirror-Structure Rules

Added in version 0.4.0.

Mirror-structure rules let you write validation rules whose shape matches the shape of your data. Instead of wrapping every nested dict in explicit {'type': 'dict', 'fields': {...}} boilerplate, you can write a rule dict that looks like the data dict.


The problem with explicit nested rules

The canonical way to validate a nested dict requires repeating structural keywords at every level:

# data
data = {
    'app': {
        'name': 'QuickScript',
        'version': '1.0.0',
    },
    'database': {
        'host': '127.0.0.1',
        'port': 5432,
    },
}

# canonical rule — verbose
rule = {'keys': {
    'app': {
        'type': 'dict',
        'fields': {
            'name':    {'type': 'str',    'range': (3, 'any')},
            'version': {'type': 'semver'},
        }
    },
    'database': {
        'type': 'dict',
        'fields': {
            'host': {'type': 'ip'},
            'port': {'type': 'int', 'range': (1, 65535)},
        }
    },
}}

Every nested dict adds two layers (type and fields) that carry no information beyond “this is a dict with these fields” — which the data already shows.


Mirror-structure shorthand

With mirror-structure rules, the rule mirrors the data exactly. Any dict that has no type, fields, or items key is treated as a field map and expanded automatically:

# data
data = {
    'app': {
        'name': 'QuickScript',
        'version': '1.0.0',
    },
    'database': {
        'host': '127.0.0.1',
        'port': 5432,
    },
}

# mirror rule — matches the shape of the data
rule = {
    'app': {
        'name':    'str|min:3',
        'version': 'semver',
    },
    'database': {
        'host': 'ip',
        'port': 'int|between:1,65535',
    },
}

result = validate_data(data=data, rule=rule)
result.ok  # True

The rule is structurally identical to the data. Field names appear once and each leaf value is the rule for that field.


Error paths

Errors are reported with the full dotted path to the failing field, the same as canonical nested rules:

result = validate_data(
    data={'app': {'name': 'ab', 'version': '1.0.0'}},
    rule={'app': {'name': 'str|min:3', 'version': 'semver'}},
)

result.ok      # False
result.errors  # ['app.name: invalid string length']

Multi-level nesting

The shorthand recurses to any depth. Each level of the rule just mirrors the corresponding level of the data:

data = {
    'company': {
        'address': {
            'postcode': 'AB1 2CD',
        }
    }
}

rule = {
    'company': {
        'address': {
            'postcode': 'str|min:6',
        }
    }
}

result = validate_data(data=data, rule=rule)
result.ok  # True

If a field at a deeply nested path fails, the full path appears in the error:

result = validate_data(
    data={'company': {'address': {'postcode': '123'}}},
    rule={'company': {'address': {'postcode': 'str|min:6'}}},
)

result.errors  # ['company.address.postcode: invalid string length']

Mixing flat and nested rules

Top-level fields can freely mix flat shorthand rules and mirror-structure nested dicts:

data = {
    'owner': 'alice',
    'company': {
        'address': {
            'postcode': 'AB1 2CD',
        }
    }
}

rule = {
    'owner':   'str|min:3',           # flat rule for a scalar field
    'company': {                       # mirror structure for a nested dict
        'address': {
            'postcode': 'str|min:6',
        }
    }
}

validate_data(data=data, rule=rule).ok  # True

Using the keys wrapper

The bare field map and the keys wrapper both support mirror-structure rules and behave identically:

# bare field map
rule = {
    'app': {'name': 'str|min:3', 'version': 'semver'},
}

# keys wrapper — equivalent
rule = {'keys': {
    'app': {'name': 'str|min:3', 'version': 'semver'},
}}

Transforms on nested fields

Pipe-syntax transforms work inside mirror rules at any depth. When mutate=True is passed, transformed values are reflected in the reconstructed output:

result = validate_data(
    data={'user': {'profile': {'name': '  alice  '}}},
    rule={'user': {'profile': {'name': 'str|strip|min:3'}}},
    mutate=True,
)

result.ok               # True
result.data[0]          # {'profile': {'name': 'alice'}}  — whitespace stripped

Mutate and data reconstruction

When mutate=True is passed, result.data contains the validated (and transformed) values. For mirror-structure rules the structure is preserved — result.data is a list of dicts, not a flat list of leaf values:

result = validate_data(
    data={
        'app':      {'name': 'QuickScript', 'version': '1.0.0'},
        'database': {'host': '127.0.0.1',   'port': 5432},
    },
    rule={
        'app':      {'name': 'str|min:3', 'version': 'semver'},
        'database': {'host': 'ip', 'port': 'int|between:1,65535'},
    },
    mutate=True,
)

result.ok    # True
result.data  # [{'name': 'QuickScript', 'version': '1.0.0'}, {'host': '127.0.0.1', 'port': 5432}]

Depth limit

Mirror-structure rules can nest up to 100 levels deep. Exceeding this limit raises a ValueError with the path of the offending node:

# 101 levels — raises ValueError
# ValueError: Maximum nesting depth of 100 exceeded at 'x.x.x. ... .x'

This limit exists to prevent runaway recursion from untrusted or machine-generated rule dicts. In practice, real-world data rarely exceeds five or six levels.


Mixing with explicit dict rules

You can use explicit {'type': 'dict', 'fields': {...}} rules alongside mirror-structure shorthand at any level — they are fully compatible:

rule = {'keys': {
    'user': {
        # explicit form — use when you need dict-level options (e.g. nullable)
        'type': 'dict',
        'nullable': True,
        'fields': {
            'name': 'str|min:3',
            'role': 'str|in:admin,user,guest',
        }
    },
    'config': {
        # mirror shorthand — no boilerplate
        'theme': 'str|in:light,dark',
        'locale': 'str|length:2',
    }
}}

Use the explicit form when you need dict-level options such as nullable or a custom message. Use the mirror shorthand when the dict structure itself needs no configuration.


Reference: how expansion works

The mirror shorthand is purely a syntactic convenience. Before validation runs, the shorthand is expanded into the canonical {'type': 'dict', 'fields': {...}} form by _expand_shorthand_rule. The expansion happens recursively and is transparent — errors, error paths, and result.data behave identically to manually written canonical rules.

A bare dict like:

{'app': {'name': 'str|min:3', 'version': 'semver'}}

is expanded to:

{
    'fields': {
        'app': {
            'fields': {
                'name':    {'type': 'str', 'range': (3, 'any')},
                'version': {'type': 'semver', 'message': ''},
            }
        }
    }
}

before being passed to the validator.