diff --git a/changelog.d/1621.change.md b/changelog.d/1621.change.md new file mode 100644 index 000000000..f03af96e6 --- /dev/null +++ b/changelog.d/1621.change.md @@ -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. diff --git a/docs/api.rst b/docs/api.rst index 42b87f7a7..a441e4e60 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -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=, capturing (, )>, type=None, kw_only=False), , {'source_': 'universe'}, (, )) + Validation failed for mapping key at entry 0 of attribute 'tags'. >>> Measurement(tags={"source_": "universe"}) Measurement(tags={'source_': 'universe'}) @@ -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:: @@ -626,10 +631,34 @@ All objects from ``attrs.validators`` are also available from ``attr.validators` Traceback (most recent call last): ... TypeError: ("'x' must be (got '3' that is a ).", Attribute(name='x', default=NOTHING, validator=> iterables of >>, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False), , '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:: @@ -651,10 +680,12 @@ All objects from ``attrs.validators`` are also available from ``attr.validators` Traceback (most recent call last): ... TypeError: ("'x' must be (got 1.0 that is a ).", Attribute(name='x', default=NOTHING, validator=> to >>, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False), , 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 (got 7 that is a ).", Attribute(name='x', default=NOTHING, validator=> to >>, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False), , 7) + Validation failed for mapping key at entry 1 of attribute 'x'. Validators can be both globally and locally disabled: diff --git a/src/attr/validators.py b/src/attr/validators.py index d6fb2a47d..6e31f2c6d 100644 --- a/src/attr/validators.py +++ b/src/attr/validators.py @@ -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 @@ -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()) @@ -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 = ( @@ -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. @@ -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) @@ -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"" @@ -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. @@ -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. diff --git a/tests/test_validators.py b/tests/test_validators.py index cbd087dd1..c50f114d8 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -13,6 +13,7 @@ from attr import _config, fields, has from attr import validators as validator_module +from attr._compat import PY_3_11_PLUS from attr.validators import ( _subclass_of, and_, @@ -809,6 +810,258 @@ def test_validators_iterables(self, conv): assert and_(*mapping_validator) == v.mapping_validator +class TestDeepValidationNotes: + """ + Deep validation adds context without changing validator contracts. + """ + + @pytest.fixture(params=["member", "key", "value"]) + def validator_case(self, request): + """ + Provide each kind of inner validator and its expected context. + """ + member = object() + if request.param == "member": + return ( + deep_iterable, + [member], + member, + "Validation failed for member at index 0 of attribute 'test'.", + ) + if request.param == "key": + return ( + lambda v: deep_mapping(key_validator=v), + {member: 1}, + member, + "Validation failed for mapping key at entry 0 of attribute 'test'.", + ) + return ( + lambda v: deep_mapping(value_validator=v), + {"key": member}, + member, + "Validation failed for mapping value at entry 0 of attribute 'test'.", + ) + + def test_original_exception(self, validator_case): + """ + Preserve validator arguments, exception identity, args, and prior notes. + """ + factory, value, member, note = validator_case + inst = object() + a = simple_attr("test") + error = ValueError("invalid member", a) + error.__notes__ = ["custom context"] + original_message = str(error) + + def fail(received_inst, received_attr, received_member): + assert inst is received_inst + assert a is received_attr + assert member is received_member + raise error + + with pytest.raises(ValueError) as caught: + factory(fail)(inst, a, value) + + assert error is caught.value + assert ("invalid member", a) == caught.value.args + assert a is caught.value.args[1] + assert original_message == str(caught.value) + assert ["custom context"] + ([note] if PY_3_11_PLUS else []) == ( + caught.value.__notes__ + ) + + def test_invalid_member_length(self): + """ + A short member is identified without changing the original message. + """ + + @attr.define + class C: + x = attr.field( + validator=deep_iterable( + [instance_of(str), min_len(1)], + [instance_of(list), min_len(1)], + ) + ) + + with pytest.raises(ValueError) as caught: + C(["abc", ""]) + + assert ("Length of 'x' must be >= 1: 0",) == caught.value.args + expected = [ + "Validation failed for member at index 1 of attribute 'x'." + ] + assert (expected if PY_3_11_PLUS else []) == getattr( + caught.value, "__notes__", [] + ) + + def test_nested_context(self): + """ + Nested validators add context from the innermost failure outwards. + """ + a = simple_attr("test") + validator = deep_iterable( + deep_mapping(value_validator=deep_iterable(instance_of(int))) + ) + + with pytest.raises(TypeError) as caught: + validator(None, a, [{"first": [1], "second": [1, "bad"]}]) + + assert ( + "'test' must be (got 'bad' that is a ).", + a, + int, + "bad", + ) == caught.value.args + assert a is caught.value.args[1] + expected = [ + "Validation failed for member at index 1 of attribute 'test'.", + "Validation failed for mapping value at entry 1 of attribute 'test'.", + "Validation failed for member at index 0 of attribute 'test'.", + ] + assert (expected if PY_3_11_PLUS else []) == getattr( + caught.value, "__notes__", [] + ) + + def test_base_exception_unmodified(self, validator_case): + """ + Interrupts are not annotated as validation failures. + """ + factory, value, _, _ = validator_case + error = KeyboardInterrupt() + + def fail(inst, attribute, member): + raise error + + with pytest.raises(KeyboardInterrupt) as caught: + factory(fail)(None, simple_attr("test"), value) + + assert error is caught.value + assert not hasattr(caught.value, "__notes__") + + def test_note_failure_preserves_exception(self, validator_case): + """ + An exception with unusable notes is still propagated unchanged. + """ + factory, value, _, _ = validator_case + error = ValueError("invalid member") + error.__notes__ = None + + def fail(inst, attribute, member): + raise error + + with pytest.raises(ValueError) as caught: + factory(fail)(None, simple_attr("test"), value) + + assert error is caught.value + assert ("invalid member",) == caught.value.args + assert caught.value.__notes__ is None + + @pytest.mark.parametrize( + "factory", + [ + lambda v: deep_iterable(always_pass, v), + lambda v: deep_mapping(always_pass, always_pass, v), + ], + ids=["iterable", "mapping"], + ) + def test_container_error_unmodified(self, factory): + """ + A container validator failure does not get member context. + """ + error = ValueError("invalid container") + + def fail(inst, attribute, value): + raise error + + with pytest.raises(ValueError) as caught: + factory(fail)(None, simple_attr("test"), None) + + assert error is caught.value + assert not hasattr(caught.value, "__notes__") + + @pytest.mark.parametrize( + "validator", + [deep_iterable(always_pass), deep_mapping(key_validator=always_pass)], + ids=["iterable", "mapping"], + ) + def test_iteration_error_unmodified(self, validator): + """ + Failure to obtain the next item is not a validation failure. + """ + error = ValueError("iteration failed") + + def values(): + yield 1 + raise error + + with pytest.raises(ValueError) as caught: + validator(None, simple_attr("test"), values()) + + assert error is caught.value + assert not hasattr(caught.value, "__notes__") + + def test_lookup_error_unmodified(self): + """ + Failure to look up a mapping value is not a validation failure. + """ + error = KeyError("missing") + + class Mapping: + def __iter__(self): + return iter(["key"]) + + def __getitem__(self, key): + raise error + + with pytest.raises(KeyError) as caught: + deep_mapping(value_validator=always_pass)( + None, simple_attr("test"), Mapping() + ) + + assert error is caught.value + assert not hasattr(caught.value, "__notes__") + + def test_generator_stops_at_failure(self): + """ + Adding context does not consume items after an invalid member. + """ + values = iter(["valid", "", "remaining"]) + + with pytest.raises(ValueError): + deep_iterable(min_len(1))(None, simple_attr("test"), values) + + assert "remaining" == next(values) + + @pytest.mark.parametrize("role", ["key", "value"]) + def test_key_repr_not_called(self, role): + """ + Mapping context does not evaluate a user-defined key repr. + """ + + class Key: + def __repr__(self): + pytest.fail("key repr must not be called") + + error = ValueError("invalid member") + + def fail(inst, attribute, member): + raise error + + validator = deep_mapping(**{f"{role}_validator": fail}) + + with pytest.raises(ValueError) as caught: + validator(None, simple_attr("test"), {Key(): 1}) + + assert error is caught.value + expected = [ + f"Validation failed for mapping {role} at entry 0 of attribute 'test'." + ] + assert (expected if PY_3_11_PLUS else []) == getattr( + caught.value, "__notes__", [] + ) + + class TestIsCallable: """ Tests for `is_callable`.