Skip to content

Commit b3e622f

Browse files
committed
is_unique validator
1 parent 5836a41 commit b3e622f

File tree

5 files changed

+76
-13
lines changed

5 files changed

+76
-13
lines changed

src/cattrs/v/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
IterableValidationError,
99
)
1010
from ._fluent import V, customize
11-
from ._validators import between, greater_than, len_between
11+
from ._validators import between, greater_than, is_unique, len_between
1212

1313
__all__ = [
1414
"customize",
@@ -18,6 +18,7 @@
1818
"between",
1919
"greater_than",
2020
"len_between",
21+
"is_unique",
2122
]
2223

2324

src/cattrs/v/_fluent.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -108,15 +108,6 @@ def replace_with(self, value: T) -> VOmitted:
108108
return VOmitted(self.attr)
109109

110110

111-
def is_unique(val: Collection[Any]) -> None:
112-
"""Ensure all elements in a collection are unique.
113-
114-
Takes a value that implements Collection.
115-
"""
116-
if len(val) != len(set(val)):
117-
raise ValueError(f"Value ({val}) not unique")
118-
119-
120111
def ignoring_none(*validators: Callable[[T], None]) -> Callable[[T | None], None]:
121112
"""
122113
A validator for (f.e.) strings cannot be applied to `str | None`, but it can

src/cattrs/v/_validators.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

3-
from typing import Callable, Protocol, Sized, TypeVar
3+
from collections.abc import Hashable
4+
from typing import Callable, Collection, Protocol, Sized, TypeVar
45

56
T = TypeVar("T")
67

@@ -43,3 +44,11 @@ def assert_len_between(val: Sized, _min: int = min, _max: int = max) -> None:
4344
raise ValueError(f"length ({length}) not between {_min} and {_max}")
4445

4546
return assert_len_between
47+
48+
49+
def is_unique(val: Collection[Hashable]) -> None:
50+
"""Ensure all elements in a collection are unique."""
51+
if (length := len(val)) != (unique_length := len(set(val))):
52+
raise ValueError(
53+
f"Collection ({length} elem(s)) not unique, only {unique_length} unique elem(s)"
54+
)

tests/v/test_validators.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,15 @@
66

77
from cattrs import BaseConverter
88
from cattrs.errors import ClassValidationError
9-
from cattrs.v import V, between, customize, greater_than, len_between, transform_error
9+
from cattrs.v import (
10+
V,
11+
between,
12+
customize,
13+
greater_than,
14+
is_unique,
15+
len_between,
16+
transform_error,
17+
)
1018

1119

1220
@define
@@ -104,3 +112,31 @@ def test_len_between(converter: BaseConverter):
104112
converter.structure({"a": [1, 2]}, WithList)
105113

106114
assert repr(exc_info.value) == "ValueError('length (2) not between 1 and 2')"
115+
116+
117+
def test_unique(converter: BaseConverter):
118+
"""The `is_unique` validator works."""
119+
120+
@define
121+
class A:
122+
a: list[int]
123+
124+
customize(converter, A, V(f(A).a).ensure(is_unique))
125+
126+
assert converter.structure({"a": [1]}, A) == A([1])
127+
128+
if converter.detailed_validation:
129+
with raises(ClassValidationError) as exc_info:
130+
converter.structure({"a": [1, 1]}, A)
131+
132+
assert transform_error(exc_info.value) == [
133+
"invalid value (Collection (2 elem(s)) not unique, only 1 unique elem(s)) @ $.a"
134+
]
135+
else:
136+
with raises(ValueError) as exc_info:
137+
converter.structure({"a": [1, 1]}, A)
138+
139+
assert (
140+
repr(exc_info.value)
141+
== "ValueError('Collection (2 elem(s)) not unique, only 1 unique elem(s)')"
142+
)

tests/v/test_validators_mypy.yml

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,30 @@
3535
3636
c = Converter()
3737
38-
v.customize(c, A, v.V(fields(A).a).ensure(v.len_between(5, 10)))
38+
v.customize(c, A, v.V(fields(A).a).ensure(v.len_between(5, 10)))
39+
40+
- case: unique
41+
main: |
42+
from attrs import define, fields
43+
from cattrs import v, Converter
44+
45+
@define
46+
class A:
47+
a: list[int]
48+
49+
c = Converter()
50+
51+
v.customize(c, A, v.V(fields(A).a).ensure(v.is_unique))
52+
53+
- case: unique_error_not_hashable
54+
main: |
55+
from attrs import define, fields
56+
from cattrs import v, Converter
57+
58+
@define
59+
class A:
60+
a: list[dict]
61+
62+
c = Converter()
63+
64+
v.customize(c, A, v.V(fields(A).a).ensure(v.is_unique)) # E: Argument 1 to "ensure" of "V" has incompatible type "Callable[[Collection[Hashable]], None]"; expected "Callable[[list[dict[Any, Any]]], bool | None]" [arg-type]

0 commit comments

Comments
 (0)