Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.d/1621.change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
`attrs.validators.deep_iterable()` and `attrs.validators.deep_mapping()` now add exception notes identifying the failing member, key, or value by its position in iteration order on Python 3.11 and newer when the exception supports notes.
The original exception object and its arguments are preserved; Python 3.10 retains its existing behavior.
31 changes: 31 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,7 @@ All objects from ``attrs.validators`` are also available from ``attr.validators`
Traceback (most recent call last):
...
ValueError: ("reserved tag key", Attribute(name='tags', default=NOTHING, validator=<not_ validator wrapping <in_ validator with options {'id', 'time', 'source'}>, capturing (<class 'ValueError'>, <class 'TypeError'>)>, type=None, kw_only=False), <in_ validator with options {'id', 'time', 'source'}>, {'source_': 'universe'}, (<class 'ValueError'>, <class 'TypeError'>))
Validation failed for mapping key at entry 0 of attribute 'tags'.
>>> Measurement(tags={"source_": "universe"})
Measurement(tags={'source_': 'universe'})

Expand Down Expand Up @@ -606,6 +607,10 @@ All objects from ``attrs.validators`` are also available from ``attr.validators`

.. autofunction:: attrs.validators.deep_iterable

On Python 3.11 and newer, a member validation error includes a note identifying the member's position when the exception supports notes.
Python 3.10 omits the note.
Positions start at zero and follow iteration order, including for iterables that cannot be indexed.

For example:

.. doctest::
Expand All @@ -626,10 +631,34 @@ All objects from ``attrs.validators`` are also available from ``attr.validators`
Traceback (most recent call last):
...
TypeError: ("'x' must be <class 'int'> (got '3' that is a <class 'str'>).", Attribute(name='x', default=NOTHING, validator=<deep_iterable validator for <instance_of validator for type <class 'list'>> iterables of <instance_of validator for type <class 'int'>>>, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False), <class 'int'>, '3')
Validation failed for member at index 2 of attribute 'x'.

This context distinguishes a short member from a short container while preserving the original error message:

.. doctest::

>>> @define
... class C:
... x = field(validator=attrs.validators.deep_iterable(
... member_validator=[
... attrs.validators.instance_of(str),
... attrs.validators.min_len(1),
... ],
... iterable_validator=attrs.validators.min_len(1),
... ))
>>> C(x=["abc", ""])
Traceback (most recent call last):
...
ValueError: Length of 'x' must be >= 1: 0
Validation failed for member at index 1 of attribute 'x'.


.. autofunction:: attrs.validators.deep_mapping

On Python 3.11 and newer, a key or value validation error includes a note identifying the validator's role and the entry's position in iteration order when the exception supports notes.
Python 3.10 omits the note.
Entry positions start at zero; generating the note does not call ``repr()`` on mapping keys.

For example:

.. doctest::
Expand All @@ -651,10 +680,12 @@ All objects from ``attrs.validators`` are also available from ``attr.validators`
Traceback (most recent call last):
...
TypeError: ("'x' must be <class 'int'> (got 1.0 that is a <class 'float'>).", Attribute(name='x', default=NOTHING, validator=<deep_mapping validator for objects mapping <instance_of validator for type <class 'str'>> to <instance_of validator for type <class 'int'>>>, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False), <class 'int'>, 1.0)
Validation failed for mapping value at entry 0 of attribute 'x'.
>>> C(x={"a": 1, 7: 2})
Traceback (most recent call last):
...
TypeError: ("'x' must be <class 'str'> (got 7 that is a <class 'int'>).", Attribute(name='x', default=NOTHING, validator=<deep_mapping validator for objects mapping <instance_of validator for type <class 'str'>> to <instance_of validator for type <class 'int'>>>, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False), <class 'str'>, 7)
Validation failed for mapping key at entry 1 of attribute 'x'.

Validators can be both globally and locally disabled:

Expand Down
66 changes: 60 additions & 6 deletions src/attr/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
import operator
import re

from contextlib import contextmanager
from contextlib import contextmanager, suppress
from re import Pattern

from ._compat import PY_3_11_PLUS
from ._config import get_run_validators, set_run_validators
from ._make import _AndValidator, and_, attrib, attrs
from .converters import default_if_none
Expand Down Expand Up @@ -330,6 +331,18 @@ def is_callable():
return _IsCallableValidator()


def _add_validation_note(error, attribute, context):
"""
Add context when supported without masking errors that cannot accept notes.
"""
if PY_3_11_PLUS:
with suppress(Exception):
BaseException.add_note(
error,
f"Validation failed for {context} of attribute {attribute.name!r}.",
)


@attrs(repr=False, slots=True, unsafe_hash=True)
class _DeepIterable:
member_validator = attrib(validator=is_callable())
Expand All @@ -344,8 +357,13 @@ def __call__(self, inst, attr, value):
if self.iterable_validator is not None:
self.iterable_validator(inst, attr, value)

for member in value:
self.member_validator(inst, attr, member)
for index, member in enumerate(value):
# Keep iteration errors outside the handler.
try:
self.member_validator(inst, attr, member)
except Exception as error: # noqa: PERF203
_add_validation_note(error, attr, f"member at index {index}")
raise

def __repr__(self):
iterable_identifier = (
Expand All @@ -363,6 +381,13 @@ def deep_iterable(member_validator, iterable_validator=None):
"""
A validator that performs deep validation of an iterable.

On Python 3.11 and newer, exceptions raised by *member_validator* receive
a note, when supported, identifying the member's zero-based position in
iteration order.
Nested validators add notes from the innermost failure outwards.
The original exception object and its arguments are preserved.
On Python 3.10, exceptions are propagated without adding notes.

Args:
member_validator: Validator(s) to apply to iterable members.

Expand All @@ -377,6 +402,10 @@ def deep_iterable(member_validator, iterable_validator=None):
.. versionchanged:: 25.4.0
*member_validator* and *iterable_validator* can now be a list or tuple
of validators.

.. versionchanged:: 26.2.0
Member validation errors receive context notes, when supported, on
Python 3.11 and newer.
"""
if isinstance(member_validator, (list, tuple)):
member_validator = and_(*member_validator)
Expand All @@ -398,11 +427,24 @@ def __call__(self, inst, attr, value):
if self.mapping_validator is not None:
self.mapping_validator(inst, attr, value)

for key in value:
for index, key in enumerate(value):
if self.key_validator is not None:
self.key_validator(inst, attr, key)
try:
self.key_validator(inst, attr, key)
except Exception as error:
_add_validation_note(
error, attr, f"mapping key at entry {index}"
)
raise
if self.value_validator is not None:
self.value_validator(inst, attr, value[key])
member = value[key]
try:
self.value_validator(inst, attr, member)
except Exception as error:
_add_validation_note(
error, attr, f"mapping value at entry {index}"
)
raise

def __repr__(self):
return f"<deep_mapping validator for objects mapping {self.key_validator!r} to {self.value_validator!r}>"
Expand All @@ -417,6 +459,14 @@ def deep_mapping(
All validators are optional, but at least one of *key_validator* or
*value_validator* must be provided.

On Python 3.11 and newer, exceptions raised by *key_validator* or
*value_validator* receive a note, when supported, identifying the
validator's role and the entry's zero-based position in iteration order,
without representing keys.
Nested validators add notes from the innermost failure outwards.
The original exception object and its arguments are preserved.
On Python 3.10, exceptions are propagated without adding notes.

Args:
key_validator: Validator(s) to apply to dictionary keys.

Expand All @@ -435,6 +485,10 @@ def deep_mapping(
*key_validator*, *value_validator*, and *mapping_validator* can now be a
list or tuple of validators.

.. versionchanged:: 26.2.0
Key and value validation errors receive context notes, when supported,
on Python 3.11 and newer.

Raises:
TypeError: If any sub-validator fails on validation.

Expand Down
Loading