Skip to content

Commit ef5e60a

Browse files
committed
Test with no detailed validation
1 parent 9f0f393 commit ef5e60a

File tree

4 files changed

+114
-57
lines changed

4 files changed

+114
-57
lines changed

src/cattrs/v/__init__.py

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

12-
__all__ = ["customize", "format_exception", "transform_error", "V"]
13+
__all__ = [
14+
"customize",
15+
"format_exception",
16+
"transform_error",
17+
"V",
18+
"between",
19+
"greater_than",
20+
]
1321

1422

1523
def format_exception(exc: BaseException, type: Union[type, None]) -> str:

src/cattrs/v/_fluent.py

Lines changed: 11 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
Generic,
99
Iterable,
1010
Literal,
11-
Protocol,
1211
Sequence,
1312
Sized,
1413
TypeVar,
@@ -130,27 +129,6 @@ def assert_len_between(val: Sized, _min: int = min, _max: int = max) -> None:
130129
return assert_len_between
131130

132131

133-
class Comparable(Protocol):
134-
def __lt__(self: T, other: T) -> bool:
135-
...
136-
137-
def __le__(self: T, other: T) -> bool:
138-
...
139-
140-
141-
C = TypeVar("C", bound=Comparable)
142-
143-
144-
def between(min: C, max: C) -> Callable[[C], None]:
145-
"""Ensure the value of the attribute is between min (inclusive) and max (exclusive)."""
146-
147-
def assert_between(val: C, _min: C = min, _max: C = max) -> None:
148-
if not (_min <= val) and not (_max < val):
149-
raise ValueError(f"Value not between {_min} and {_max}")
150-
151-
return assert_between
152-
153-
154132
def ignoring_none(*validators: Callable[[T], None]) -> Callable[[T | None], None]:
155133
"""
156134
A validator for (f.e.) strings cannot be applied to `str | None`, but it can
@@ -198,7 +176,7 @@ def assert_all_elements(val: Iterable[T]) -> None:
198176

199177

200178
def _compose_validators(
201-
base_structure: StructureHook | str,
179+
base_structure: StructureHook,
202180
validators: Sequence[Callable[[Any], None | bool]],
203181
detailed_validation: bool,
204182
) -> Callable[[Any, Any], Any]:
@@ -208,13 +186,14 @@ def _compose_validators(
208186
209187
The new hook will raise an ExceptionGroup.
210188
"""
211-
if isinstance(base_structure, str):
212-
name = base_structure
189+
bs = base_structure
190+
191+
if detailed_validation:
213192

214193
def structure_hook(
215-
val: dict[str, Any], t: Any, _name: str = name, _hooks=validators
194+
val: dict[str, Any], t: Any, _hooks=validators, _bs=bs
216195
) -> Any:
217-
res = val[_name]
196+
res = _bs(val, t)
218197
errors: list[Exception] = []
219198
for hook in _hooks:
220199
try:
@@ -226,18 +205,13 @@ def structure_hook(
226205
return res
227206

228207
else:
229-
bs = base_structure
230208

231-
def structure_hook(val: dict[str, Any], t: Any, _hooks=validators) -> Any:
232-
res = bs(val, t)
233-
errors: list[Exception] = []
209+
def structure_hook(
210+
val: dict[str, Any], t: Any, _hooks=validators, _bs=bs
211+
) -> Any:
212+
res = _bs(val, t)
234213
for hook in _hooks:
235-
try:
236-
hook(val)
237-
except Exception as exc:
238-
errors.append(exc)
239-
if errors:
240-
raise ExceptionGroup("Validation errors structuring {}", errors)
214+
hook(val)
241215
return res
242216

243217
return structure_hook

src/cattrs/v/_validators.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from __future__ import annotations
2+
3+
from typing import Callable, Protocol, TypeVar
4+
5+
T = TypeVar("T")
6+
7+
8+
class Comparable(Protocol):
9+
def __lt__(self: T, other: T) -> bool:
10+
...
11+
12+
def __le__(self: T, other: T) -> bool:
13+
...
14+
15+
16+
C = TypeVar("C", bound=Comparable)
17+
18+
19+
def greater_than(min: C) -> Callable[[C], None]:
20+
def assert_gt(val: C, _min: C = min) -> None:
21+
if _min >= val:
22+
raise ValueError(f"{val} not greater than {_min}")
23+
24+
return assert_gt
25+
26+
27+
def between(min: C, max: C) -> Callable[[C], None]:
28+
"""Ensure the value of the attribute is between min (inclusive) and max (exclusive)."""
29+
30+
def assert_between(val: C, _min: C = min, _max: C = max) -> None:
31+
if not (_min <= val) and not (_max < val):
32+
raise ValueError(f"{val} not between {_min} and {_max}")
33+
34+
return assert_between

tests/v/test_fluent.py

Lines changed: 60 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,17 @@
55
from attrs import fields as f
66
from pytest import fixture, raises
77

8-
from cattrs import ClassValidationError, Converter
9-
from cattrs.v import V, customize, transform_error
8+
from cattrs import BaseConverter, ClassValidationError, Converter
9+
from cattrs.v import V, customize, greater_than, transform_error
1010

1111

1212
@fixture
13-
def c() -> Converter:
14-
"""We need only converters with detailed_validation=True."""
15-
res = Converter()
16-
17-
res.register_structure_hook(
13+
def c(converter: BaseConverter) -> BaseConverter:
14+
converter.register_structure_hook(
1815
Union[str, int], lambda v, _: v if isinstance(v, int) else str(v)
1916
)
2017

21-
return res
18+
return converter
2219

2320

2421
@define
@@ -91,12 +88,18 @@ def test_simple_string_validation(c: Converter) -> None:
9188

9289
unstructured = c.unstructure(instance)
9390

94-
with raises(ClassValidationError) as exc_info:
95-
c.structure(unstructured, Model)
91+
if c.detailed_validation:
92+
with raises(ClassValidationError) as exc_info:
93+
c.structure(unstructured, Model)
94+
95+
assert transform_error(exc_info.value) == [
96+
"invalid value ('A' not lowercase) @ $.b"
97+
]
98+
else:
99+
with raises(ValueError) as exc_info:
100+
c.structure(unstructured, Model)
96101

97-
assert transform_error(exc_info.value) == [
98-
"invalid value ('A' not lowercase) @ $.b"
99-
]
102+
assert repr(exc_info.value) == "ValueError(\"'A' not lowercase\")"
100103

101104
instance.b = "a"
102105
assert instance == c.structure(c.unstructure(instance), Model)
@@ -110,13 +113,51 @@ def test_multiple_string_validators(c: Converter) -> None:
110113

111114
unstructured = c.unstructure(instance)
112115

113-
with raises(ClassValidationError) as exc_info:
114-
c.structure(unstructured, Model)
116+
if c.detailed_validation:
117+
with raises(ClassValidationError) as exc_info:
118+
c.structure(unstructured, Model)
115119

116-
assert transform_error(exc_info.value) == [
117-
"invalid value ('A' not lowercase) @ $.b",
118-
"invalid value ('A' is not a valid email) @ $.b",
119-
]
120+
assert transform_error(exc_info.value) == [
121+
"invalid value ('A' not lowercase) @ $.b",
122+
"invalid value ('A' is not a valid email) @ $.b",
123+
]
124+
else:
125+
with raises(ValueError) as exc_info:
126+
c.structure(unstructured, Model)
127+
128+
assert repr(exc_info.value) == "ValueError(\"'A' not lowercase\")"
120129

121130
instance.b = "a@b"
122131
assert instance == c.structure(c.unstructure(instance), Model)
132+
133+
134+
def test_multiple_field_validators(c: Converter) -> None:
135+
"""Multiple fields are validated."""
136+
customize(
137+
c,
138+
Model,
139+
V((fs := f(Model)).a).ensure(greater_than(5)),
140+
V(fs.b).ensure(is_lowercase),
141+
)
142+
143+
instance = Model(5, "A", ["1"], [1], "", 0, 0, {"a": 1})
144+
145+
unstructured = c.unstructure(instance)
146+
147+
if c.detailed_validation:
148+
with raises(ClassValidationError) as exc_info:
149+
c.structure(unstructured, Model)
150+
151+
assert transform_error(exc_info.value) == [
152+
"invalid value (5 not greater than 5) @ $.a",
153+
"invalid value ('A' not lowercase) @ $.b",
154+
]
155+
else:
156+
with raises(ValueError) as exc_info:
157+
c.structure(unstructured, Model)
158+
159+
assert repr(exc_info.value) == "ValueError('5 not greater than 5')"
160+
161+
instance.a = 6
162+
instance.b = "a"
163+
assert instance == c.structure(c.unstructure(instance), Model)

0 commit comments

Comments
 (0)