Skip to content

Commit 006dc72

Browse files
committed
General purpose ensure
1 parent fd22d45 commit 006dc72

File tree

8 files changed

+228
-6
lines changed

8 files changed

+228
-6
lines changed

src/cattrs/__init__.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,21 @@
1111
StructureHandlerNotFoundError,
1212
)
1313
from .gen import override
14-
from .v import transform_error
14+
from .v import ensure, transform_error
1515

1616
__all__ = [
17-
"structure",
18-
"unstructure",
17+
"ensure",
1918
"get_structure_hook",
2019
"get_unstructure_hook",
20+
"global_converter",
2121
"register_structure_hook_func",
2222
"register_structure_hook",
2323
"register_unstructure_hook_func",
2424
"register_unstructure_hook",
2525
"structure_attrs_fromdict",
2626
"structure_attrs_fromtuple",
27-
"global_converter",
27+
"structure",
28+
"unstructure",
2829
"BaseConverter",
2930
"Converter",
3031
"AttributeValidationNote",

src/cattrs/v/__init__.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Cattrs validation."""
2-
from typing import Any, Callable, List, Union
2+
from typing import Annotated, Any, Callable, List, TypeVar, Union, overload
33

4-
from attrs import frozen
4+
from attrs import NOTHING, frozen
55

66
from .._compat import ExceptionGroup
77
from ..errors import (
@@ -145,3 +145,28 @@ def transform_error(
145145
else:
146146
errors.append(f"{format_exception(exc, None)} @ {path}")
147147
return errors
148+
149+
150+
T = TypeVar("T")
151+
E = TypeVar("E")
152+
153+
154+
@overload
155+
def ensure(
156+
type: type[list[T]], *validators: Callable[[list[T]], Any], elems: type[E]
157+
) -> type[list[E]]:
158+
...
159+
160+
161+
@overload
162+
def ensure(type: type[T], *validators: Callable[[T], Any]) -> type[T]:
163+
...
164+
165+
166+
def ensure(type: Any, *validators: Any, elems: Any = NOTHING) -> Any:
167+
if elems is not NOTHING:
168+
# These are lists.
169+
if not validators:
170+
return type[elems]
171+
return Annotated[type, VAnnotation(validators)]
172+
return Annotated[type, VAnnotation(validators)]

src/cattrs/v/_hooks.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Hooks and hook factories for validation."""
2+
from __future__ import annotations
3+
4+
from typing import TYPE_CHECKING, Any
5+
6+
from .._compat import Annotated, ExceptionGroup, is_annotated
7+
from ..dispatch import StructureHook
8+
from . import VAnnotation
9+
10+
if TYPE_CHECKING:
11+
from ..converters import BaseConverter
12+
13+
14+
def get_validator_annotation(type: Any) -> tuple[VAnnotation, Any] | None:
15+
if is_annotated(type):
16+
args = type.__metadata__
17+
for arg in args:
18+
if isinstance(arg, VAnnotation):
19+
new_args = tuple(a for a in args[1:] if a is not arg)
20+
if new_args:
21+
return Annotated(type.__origin__, *new_args) # type: ignore
22+
return arg, type.__origin__
23+
return None
24+
25+
26+
def is_validated(type: Any) -> bool:
27+
"""The predicate for validated annotations."""
28+
return get_validator_annotation(type) is not None
29+
30+
31+
def validator_factory(type: Any, converter: BaseConverter) -> StructureHook:
32+
res = get_validator_annotation(type)
33+
assert res is not None
34+
val_annotation, type = res
35+
36+
base_hook = converter.get_structure_hook(type)
37+
38+
if converter.detailed_validation:
39+
40+
def validating_hook(val: Any, _: Any) -> Any:
41+
res = base_hook(val, type)
42+
errors = []
43+
for validator in val_annotation.validators:
44+
try:
45+
if validator(res) is False:
46+
raise ValueError(f"Validation failed for {res}")
47+
except Exception as exc:
48+
errors.append(exc)
49+
if errors:
50+
raise ExceptionGroup("Value validation failed", errors)
51+
return res
52+
53+
else:
54+
55+
def validating_hook(val: Any, _: Any) -> Any:
56+
res = base_hook(val, type)
57+
for validator in val_annotation.validators:
58+
if validator(res) is False:
59+
raise ValueError(f"Validation failed for {res}")
60+
return res
61+
62+
return validating_hook

tests/test_converter_typing.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
- case: sequence_structuring
2+
main: |
3+
from typing import Sequence
4+
from cattrs import Converter
5+
6+
c = Converter()
7+
8+
# Maybe one day!
9+
c.structure([], Sequence[int]) # E: Only concrete class can be given where "type[Sequence[int]]" is expected [type-abstract]

tests/v/test_ensure.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Tests for `cattrs.v.ensure`."""
2+
import sys
3+
from typing import List, MutableSequence, Sequence
4+
5+
from pytest import fixture, mark, raises
6+
7+
from cattrs import BaseConverter
8+
from cattrs._compat import ExceptionGroup
9+
from cattrs.v import ensure
10+
from cattrs.v._hooks import is_validated, validator_factory
11+
12+
13+
@fixture
14+
def valconv(converter) -> BaseConverter:
15+
converter.register_structure_hook_factory(is_validated)(validator_factory)
16+
return converter
17+
18+
19+
def test_ensured_ints(valconv: BaseConverter):
20+
"""Validation for primitives works."""
21+
assert valconv.structure("5", ensure(int, lambda i: i > 0))
22+
23+
with raises(Exception) as exc:
24+
valconv.structure("-5", ensure(int, lambda i: i > 0))
25+
26+
if valconv.detailed_validation:
27+
assert isinstance(exc.value, ExceptionGroup)
28+
assert isinstance(exc.value.exceptions[0], ValueError)
29+
else:
30+
assert isinstance(exc.value, ValueError)
31+
32+
33+
def test_ensured_lists(valconv: BaseConverter):
34+
"""Validation for lists works."""
35+
assert valconv.structure([1, 2], ensure(list[int], lambda lst: len(lst) > 0))
36+
37+
with raises(Exception) as exc:
38+
valconv.structure([], ensure(list[int], lambda lst: len(lst) > 0))
39+
40+
if valconv.detailed_validation:
41+
assert isinstance(exc.value, ExceptionGroup)
42+
assert isinstance(exc.value.exceptions[0], ValueError)
43+
else:
44+
assert isinstance(exc.value, ValueError)
45+
46+
47+
@mark.parametrize("type", [List, Sequence, MutableSequence])
48+
def test_ensured_list_elements(valconv: BaseConverter, type):
49+
"""Validation for list elements works."""
50+
assert valconv.structure([1, 2], ensure(type, elems=ensure(int, lambda i: i > 0)))
51+
52+
with raises(Exception) as exc:
53+
valconv.structure([1, -2], ensure(type, elems=ensure(int, lambda i: i > 0)))
54+
55+
if valconv.detailed_validation:
56+
assert isinstance(exc.value, ExceptionGroup)
57+
assert isinstance(exc.value.exceptions[0], ExceptionGroup)
58+
assert isinstance(exc.value.exceptions[0].exceptions[0], ValueError)
59+
else:
60+
assert isinstance(exc.value, ValueError)
61+
62+
63+
@mark.skipif(sys.version_info[:2] < (3, 10), reason="Not supported on older Pythons")
64+
def test_ensured_list(valconv: BaseConverter):
65+
"""Ensure works for builtin lists."""
66+
assert valconv.structure([1, 2], ensure(list, elems=ensure(int, lambda i: i > 0)))
67+
68+
with raises(Exception) as exc:
69+
valconv.structure([1, -2], ensure(list, elems=ensure(int, lambda i: i > 0)))
70+
71+
if valconv.detailed_validation:
72+
assert isinstance(exc.value, ExceptionGroup)
73+
assert isinstance(exc.value.exceptions[0], ExceptionGroup)
74+
assert isinstance(exc.value.exceptions[0].exceptions[0], ValueError)
75+
else:
76+
assert isinstance(exc.value, ValueError)

tests/v/test_ensure_typing.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
- case: int_validation
2+
main: |
3+
from cattrs import v, Converter, ensure
4+
5+
c = Converter()
6+
7+
reveal_type(c.structure("5", ensure(int))) # N: Revealed type is "builtins.int"
8+
9+
reveal_type(c.structure("5", ensure(int, lambda i: i > 5))) # N: Revealed type is "builtins.int"
10+
11+
reveal_type(c.structure("5", ensure(int, v.greater_than(5)))) # N: Revealed type is "builtins.int"
12+
13+
c.structure("5", ensure(int, lambda i: len(i) > 5)) # E: Argument 1 to "len" has incompatible type "int"; expected "Sized" [arg-type]
14+
- case: list_validation
15+
main: |
16+
from typing import List
17+
18+
from cattrs import v, Converter, ensure
19+
20+
c = Converter()
21+
22+
reveal_type(c.structure([], ensure(List[int]))) # N: Revealed type is "builtins.list[builtins.int]"
23+
24+
reveal_type(c.structure([], ensure(List[int], lambda lst: len(lst) > 0))) # N: Revealed type is "builtins.list[builtins.int]"
25+
26+
reveal_type(c.structure([], ensure(List[int], v.len_between(1, 5)))) # N: Revealed type is "builtins.list[builtins.int]"
27+
- case: list_and_int_validation
28+
main: |
29+
from typing import List
30+
31+
from cattrs import Converter, ensure
32+
33+
c = Converter()
34+
35+
reveal_type(c.structure([], ensure(List, elems=ensure(int)))) # N: Revealed type is "builtins.list[builtins.int]"
36+
reveal_type(c.structure([], ensure(List, elems=ensure(int, lambda i: i > 5)))) # N: Revealed type is "builtins.list[builtins.int]"
37+
38+
# Quite unfortunate this doesn't work.
39+
c.structure([], List[ensure(int), lambda i: i > 5]) # E: Type expected within [...] [misc] # E: The type "type[list[Any]]" is not generic and not indexable [misc]
40+
- case: sequence_and_int_validation
41+
main: |
42+
from typing import Sequence
43+
44+
from cattrs import Converter, ensure
45+
46+
c = Converter()
47+
48+
# This doesn't work because of no TypeForm.
49+
c.structure([], ensure(Sequence, elems=ensure(int))) # E: Argument 1 to "ensure" has incompatible type "type[Sequence[Any]]"; expected "type[list[Never]]" [arg-type]
File renamed without changes.

0 commit comments

Comments
 (0)