From 6ae0a5293d8ee3ccd18ae71970ebaccc9c903004 Mon Sep 17 00:00:00 2001 From: uwezkhan06 Date: Mon, 18 May 2026 20:53:19 +0530 Subject: [PATCH 1/2] Add input type validation to max_len and min_len validators to prevent crashes on non-sized objects --- src/attr/_compat.py | 3 ++- src/attr/validators.py | 18 ++++++++++++++++- tests/test_validators.py | 42 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/attr/_compat.py b/src/attr/_compat.py index bc68ed9ea..2448d4784 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 @@ -97,3 +97,4 @@ def get_generic_base(cl): if cl.__class__ is _GenericAlias: return cl.__origin__ return None + diff --git a/src/attr/validators.py b/src/attr/validators.py index 0b1a29443..924a4feed 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) @@ -747,4 +763,4 @@ def or_(*validators): for v in validators: vals.extend(v.validators if isinstance(v, _OrValidator) else [v]) - return _OrValidator(tuple(vals)) + return _OrValidator(tuple(vals)) \ No newline at end of file diff --git a/tests/test_validators.py b/tests/test_validators.py index 8caa64272..82ec27c3f 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -807,6 +807,20 @@ 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 +1037,19 @@ 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 +1121,19 @@ 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: """ @@ -1396,4 +1436,4 @@ def test_repr(self): assert ( ">, >)>" - ) == repr(v) + ) == repr(v) \ No newline at end of file From d36594d29ce78a410993027392e8452ff6db41a4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 15:24:21 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/attr/_compat.py | 1 - src/attr/validators.py | 2 +- tests/test_validators.py | 12 ++++++++---- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/attr/_compat.py b/src/attr/_compat.py index 2448d4784..f371bf2a7 100644 --- a/src/attr/_compat.py +++ b/src/attr/_compat.py @@ -97,4 +97,3 @@ def get_generic_base(cl): if cl.__class__ is _GenericAlias: return cl.__origin__ return None - diff --git a/src/attr/validators.py b/src/attr/validators.py index 924a4feed..4b7acf480 100644 --- a/src/attr/validators.py +++ b/src/attr/validators.py @@ -763,4 +763,4 @@ def or_(*validators): for v in validators: vals.extend(v.validators if isinstance(v, _OrValidator) else [v]) - return _OrValidator(tuple(vals)) \ No newline at end of file + return _OrValidator(tuple(vals)) diff --git a/tests/test_validators.py b/tests/test_validators.py index 82ec27c3f..20b99ab0a 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -818,7 +818,9 @@ def test_fail_non_mapping(self): 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})." + msg = ( + f"'{a.name}' must be a mapping (got [1, 2, 3] that is a {list!r})." + ) assert msg in str(e.value) @@ -1041,12 +1043,13 @@ 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))) + Tester(x for x in range(2)) assert "must be a sized object" in str(e.value) @@ -1125,12 +1128,13 @@ 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))) + Tester(x for x in range(2)) assert "must be a sized object" in str(e.value) @@ -1436,4 +1440,4 @@ def test_repr(self): assert ( ">, >)>" - ) == repr(v) \ No newline at end of file + ) == repr(v)