Skip to content

Commit fd7538f

Browse files
authored
validators.in_ now transforms certain unhashable options to tuples (#1320)
* validators.in_ now transforms certain unhashable options to tuples Fixes #1295 * Add news fragment * type
1 parent 09161fc commit fd7538f

3 files changed

Lines changed: 38 additions & 6 deletions

File tree

changelog.d/1320.change.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
`attrs.validators.in_()` now transforms certain unhashable options to tuples to keep the field hashable.
2+
3+
This allows fields that use this validator to be used with, for example, `attrs.filters.include()`.

src/attr/validators.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ def optional(validator):
234234
@attrs(repr=False, slots=True, hash=True)
235235
class _InValidator:
236236
options = attrib()
237+
_original_options = attrib(hash=False)
237238

238239
def __call__(self, inst, attr, value):
239240
try:
@@ -242,23 +243,28 @@ def __call__(self, inst, attr, value):
242243
in_options = False
243244

244245
if not in_options:
245-
msg = f"'{attr.name}' must be in {self.options!r} (got {value!r})"
246+
msg = f"'{attr.name}' must be in {self._original_options!r} (got {value!r})"
246247
raise ValueError(
247248
msg,
248249
attr,
249-
self.options,
250+
self._original_options,
250251
value,
251252
)
252253

253254
def __repr__(self):
254-
return f"<in_ validator with options {self.options!r}>"
255+
return f"<in_ validator with options {self._original_options!r}>"
255256

256257

257258
def in_(options):
258259
"""
259260
A validator that raises a `ValueError` if the initializer is called with a
260-
value that does not belong in the options provided. The check is performed
261-
using ``value in options``, so *options* has to support that operation.
261+
value that does not belong in the *options* provided.
262+
263+
The check is performed using ``value in options``, so *options* has to
264+
support that operation.
265+
266+
To keep the validator hashable, dicts, lists, and sets are transparently
267+
transformed into a `tuple`.
262268
263269
Args:
264270
options: Allowed options.
@@ -273,8 +279,15 @@ def in_(options):
273279
The ValueError was incomplete until now and only contained the human
274280
readable error message. Now it contains all the information that has
275281
been promised since 17.1.0.
282+
.. versionchanged:: 24.1.0
283+
*options* that are a list, dict, or a set are now transformed into a
284+
tuple to keep the validator hashable.
276285
"""
277-
return _InValidator(options)
286+
repr_options = options
287+
if isinstance(options, (list, dict, set)):
288+
options = tuple(options)
289+
290+
return _InValidator(options, repr_options)
278291

279292

280293
@attrs(repr=False, slots=False, hash=True)

tests/test_validators.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ def test_success_with_value(self):
391391
"""
392392
v = in_([1, 2, 3])
393393
a = simple_attr("test")
394+
394395
v(1, a, 3)
395396

396397
def test_fail(self):
@@ -433,6 +434,21 @@ def test_repr(self):
433434
v = in_([3, 4, 5])
434435
assert ("<in_ validator with options [3, 4, 5]>") == repr(v)
435436

437+
def test_is_hashable(self):
438+
"""
439+
`in_` is hashable, so fields using it can be used with the include and
440+
exclude filters.
441+
"""
442+
443+
@attr.s
444+
class C:
445+
x: int = attr.ib(validator=attr.validators.in_({1, 2}))
446+
447+
i = C(2)
448+
449+
attr.asdict(i, filter=attr.filters.include(lambda val: True))
450+
attr.asdict(i, filter=attr.filters.exclude(lambda val: True))
451+
436452

437453
@pytest.fixture(
438454
name="member_validator",

0 commit comments

Comments
 (0)