diff --git a/src/attr/_compat.py b/src/attr/_compat.py index bc68ed9ea..f371bf2a7 100644 --- a/src/attr/_compat.py +++ b/src/attr/_compat.py @@ -5,7 +5,7 @@ import sys import threading -from collections.abc import Mapping, Sequence # noqa: F401 +from collections.abc import Mapping, Sequence, Sized # noqa: F401 from typing import _GenericAlias diff --git a/src/attr/validators.py b/src/attr/validators.py index 0b1a29443..4b7acf480 100644 --- a/src/attr/validators.py +++ b/src/attr/validators.py @@ -10,6 +10,7 @@ from contextlib import contextmanager from re import Pattern +from ._compat import Mapping, Sized from ._config import get_run_validators, set_run_validators from ._make import _AndValidator, and_, attrib, attrs from .converters import default_if_none @@ -397,6 +398,15 @@ def __call__(self, inst, attr, value): if self.mapping_validator is not None: self.mapping_validator(inst, attr, value) + if not isinstance(value, Mapping): + msg = f"'{attr.name}' must be a mapping (got {value!r} that is a {value.__class__!r})." + raise TypeError( + msg, + attr, + Mapping, + value, + ) + for key in value: if self.key_validator is not None: self.key_validator(inst, attr, key) @@ -543,6 +553,9 @@ def __call__(self, inst, attr, value): """ We use a callable class to be able to change the ``__repr__``. """ + if not isinstance(value, Sized): + msg = f"'{attr.name}' must be a sized object (got {value!r} that is a {value.__class__!r})." + raise TypeError(msg) if len(value) > self.max_length: msg = f"Length of '{attr.name}' must be <= {self.max_length}: {len(value)}" raise ValueError(msg) @@ -572,6 +585,9 @@ def __call__(self, inst, attr, value): """ We use a callable class to be able to change the ``__repr__``. """ + if not isinstance(value, Sized): + msg = f"'{attr.name}' must be a sized object (got {value!r} that is a {value.__class__!r})." + raise TypeError(msg) if len(value) < self.min_length: msg = f"Length of '{attr.name}' must be >= {self.min_length}: {len(value)}" raise ValueError(msg) diff --git a/tests/test_validators.py b/tests/test_validators.py index 8caa64272..20b99ab0a 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -807,6 +807,22 @@ def test_validators_iterables(self, conv): assert and_(*value_validator) == v.value_validator assert and_(*mapping_validator) == v.mapping_validator + def test_fail_non_mapping(self): + """ + Raise TypeError if value is not a mapping. + """ + key_validator = instance_of(str) + value_validator = instance_of(int) + v = deep_mapping(key_validator, value_validator) + a = simple_attr("test") + with pytest.raises(TypeError) as e: + v(None, a, [1, 2, 3]) + + msg = ( + f"'{a.name}' must be a mapping (got [1, 2, 3] that is a {list!r})." + ) + assert msg in str(e.value) + class TestIsCallable: """ @@ -1023,6 +1039,20 @@ def test_repr(self): """ assert repr(max_len(23)) == "" + def test_fail_non_sized(self): + """ + Raise TypeError if value is not a sized object (e.g., generator). + """ + + @attr.s + class Tester: + value = attr.ib(validator=max_len(self.MAX_LENGTH)) + + with pytest.raises(TypeError) as e: + Tester(x for x in range(2)) + + assert "must be a sized object" in str(e.value) + class TestMinLen: """ @@ -1094,6 +1124,20 @@ def test_repr(self): """ assert repr(min_len(23)) == "" + def test_fail_non_sized(self): + """ + Raise TypeError if value is not a sized object (e.g., generator). + """ + + @attr.s + class Tester: + value = attr.ib(validator=min_len(self.MIN_LENGTH)) + + with pytest.raises(TypeError) as e: + Tester(x for x in range(2)) + + assert "must be a sized object" in str(e.value) + class TestSubclassOf: """