Skip to content

fix(validators): name the failing inner item in deep_iterable/deep_mapping errors - #1654

Open
kaia-cod wants to merge 1 commit into
python-attrs:mainfrom
kaia-cod:fix-1245-inner-validator-error
Open

kaia-cod wants to merge 1 commit into
python-attrs:mainfrom
kaia-cod:fix-1245-inner-validator-error

Conversation

@kaia-cod

@kaia-cod kaia-cod commented Nov 6, 2026 •

Copy link
Copy Markdown

Problem

Closes #1245.

When an inner validator of deep_iterable() or deep_mapping() fails, the error message names the whole attribute instead of the offending member, so on a nested structure you learn that something is wrong but not where:

@attrs.define
class C:
    x = attrs.field(validator=attrs.validators.deep_iterable(
        member_validator=attrs.validators.min_len(1),
        iterable_validator=None,
    ))

C(x=[[], [1]])
# ValueError: Length of 'x' must be >= 1: 0
#                        ^^^ no position

The same happens for deep_mapping() with both the key and the value validator:

C(m={"a": 1, "b": "nope"})
# TypeError: ('m' must be <class 'int'> (got 'nope') ...)
#             ^^^ no key

Root cause

_DeepIterable.__call__ and _DeepMapping.__call__ invoke the member validator directly with the outer Attribute object. An Attribute carries its name in .name, and every built-in validator formats its message from attr.name — so the inner failure can only ever say x / m.

Fix

src/attr/validators.py (+33/−4):

  • add a tiny _MemberAttribute stand-in that forwards everything (__getattr__) to the parent attribute and overrides only name (→ "x[1]", "m[2]", "m['a']") and __repr__;
  • _DeepIterable passes it as attr to member_validator;
  • _DeepMapping builds it once per key from repr(key) and passes it to both key_validator and value_validator.

No public API change: the shim is private (_MemberAttribute), __slots__ is kept, and the object quacks like the attribute it wraps, so third-party validators that read attr.name, attr.type, attr.metadata, … keep working — they just get a more precise name.

After the change:

ValueError: Length of 'x[1]' must be >= 1: 0
TypeError: 'm[2]' must be <class 'str'> (got 2 that is a <class 'int'>) ...

Tests

tests/test_validators.py (+60/−1) — new TestInnerValidatorErrorMessages, 4 cases, one per call site:

  • test_deep_iterable_names_the_failing_member
  • test_deep_iterable_names_the_wrongly_typed_member
  • test_deep_mapping_names_the_failing_value
  • test_deep_mapping_names_the_failing_key

Each test asserts the exact message produced by the real validators (no mocks) and is expected to fail on main.

Red before green

Branch base: 9d9f94a043de7fc4012459853a4ed597a5577855 (main).

On the base commit with only the new tests applied:

$ pytest tests/test_validators.py -k TestInnerValidatorErrorMessages -q
FAILED ...::test_deep_iterable_names_the_failing_member
FAILED ...::test_deep_iterable_names_the_wrongly_typed_member
FAILED ...::test_deep_mapping_names_the_failing_value
FAILED ...::test_deep_mapping_names_the_failing_key
4 failed, 168 deselected          # exit 1

On the branch (single commit 2529f830da93e42fd7f6a5d09b4204e95a4ffcd0, tree 0634e0d744e7c0f51288023675e210417a8e0c72):

$ pytest tests/test_validators.py -k TestInnerValidatorErrorMessages -q
4 passed, 168 deselected          # exit 0

Quality gates (on the branch tree)

gate command result
full suite pytest -q 1404 passed, 11 skipped, 1 xfailed — exit 0
lint ruff check --fix --exit-non-zero-on-fix . All checks passed! — exit 0
format ruff format --exit-non-zero-on-format . left unchanged — exit 0

Diff: exactly 2 files, +93/−5, 1 commit on top of main. The base and the branch head share the same parent, so the diff is clean with no unrelated churn.

One behavioural change worth reviewing

not_() validates exc_types through deep_iterable, so TestNot_::test_bad_exception_args now reports 'exc_types[0]' instead of 'exc_types'. That assertion is the single line changed in an existing test (+1/−1 in tests/), and it is arguably the improvement this issue asks for — but if you'd rather keep the old wording there, I can gate the shim on an option and update this PR.

Notes

  • No changelog fragment yet — tell me the format you want (changelog.d/1245.change.md?) and I'll push it.
  • I'm happy to squash/rebase, drop the _MemberAttribute shim in favour of attrs.evolve-style naming, or split the deep_mapping part into its own PR if you prefer a smaller diff.

Reviewer

@hynek — requesting your review on this PR: it's a 2-file, single-commit fix for #1245 with a red-before-green test for every call site (deep_iterable member, deep_mapping key and value). I'd value your call on the two points above (the exc_types[0] wording and whether you want a changelog fragment), and I'll adjust within the day.

@kaia-cod
kaia-cod force-pushed the fix-1245-inner-validator-error branch from 0365095 to 2529f83 Compare November 6, 2026 16:46
@kaia-cod

kaia-cod commented Nov 6, 2026

Copy link
Copy Markdown
Author

Reviewer request

@hynek — requesting your review on this PR. It is a 2-file, +93/−5 change on top of 9d9f94a (main), single commit 2529f83.

What it does — deep_iterable() / deep_mapping() now hand the inner validator a private _MemberAttribute shim, so an inner failure names the offending member (x[1], m[2], m['a']) instead of the whole attribute (x, m). No public API change: the shim forwards every attribute access (type, metadata, …) to the real Attribute and overrides only name / __repr__.

Red before green — base commit 9d9f94a with only tests/test_validators.py from this branch applied:

$ pytest tests/test_validators.py -k TestInnerValidatorErrorMessages -q
4 failed, 168 deselected        # exit 1

On the branch: 4 passed, 168 deselected (exit 0).

Gates on the branch tree (0634e0d)

gate command result
full suite pytest -q 1404 passed, 11 skipped, 1 xfailed — exit 0
lint ruff check --fix --exit-non-zero-on-fix . All checks passed! — exit 0
format ruff format --exit-non-zero-on-format . 97 files left unchanged — exit 0

One behavioural change worth your decision — not_() routes exc_types through deep_iterable, so TestNot_::test_bad_exception_args now reports 'exc_types[0]' instead of 'exc_types'. That single assertion is the only pre-existing line this PR touches; it is arguably the improvement #1245 asks for, but say the word and I will gate the shim behind an opt-in so the old wording stays there.

No changelog fragment yet — tell me the changelog.d/ naming you want and I will add it. Happy to squash/rebase or split the deep_mapping half into its own PR if a smaller diff is easier to review.

@kaia-cod
kaia-cod force-pushed the fix-1245-inner-validator-error branch 5 times, most recently from 2529f83 to 05e2253 Compare November 6, 2026 21:57

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wrong error message for inner validators

1 participant