From 91b036753c0c1405bbc3343bc3a14a5cedaa2fb5 Mon Sep 17 00:00:00 2001 From: Tin Tvrtkovic Date: Sun, 17 Mar 2024 20:00:30 +0100 Subject: [PATCH] Initial dataclass support, start of docs --- docs/validation.md | 67 +++++++++++++++++++++++++-- src/cattrs/_compat.py | 8 +++- src/cattrs/_types.py | 58 ++++++++++++++++++++++++ src/cattrs/errors.py | 4 ++ src/cattrs/v/__init__.py | 17 ++++++- src/cattrs/v/_fluent.py | 82 ++++++++++++++++++++-------------- src/cattrs/v/_hooks.py | 11 +++-- src/cattrs/v/_types.py | 7 +++ src/cattrs/v/_validators.py | 18 ++++---- src/cattrs/v/fns.py | 6 +++ tests/v/test_ensure.py | 14 +++--- tests/v/test_fluent.py | 51 +++++++++++++++++++-- tests/v/test_fluent_typing.yml | 39 +++++++++++++++- 13 files changed, 319 insertions(+), 63 deletions(-) create mode 100644 src/cattrs/_types.py create mode 100644 src/cattrs/v/_types.py create mode 100644 src/cattrs/v/fns.py diff --git a/docs/validation.md b/docs/validation.md index a059fe20..a6d0e133 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -1,14 +1,75 @@ # Validation -_cattrs_ has a detailed validation mode since version 22.1.0, and this mode is enabled by default. -When running under detailed validation, the structuring hooks are slightly slower but produce richer and more precise error messages. -Unstructuring hooks are not affected. +_cattrs_ supports _structuring_ since its initial release, and _validation_ since release 24.1. + +**Structuring** is the process of ensuring data matches a set of Python types; +it can be thought of as validating data against structural constraints. +Structuring ensures the shape of your data. +Structuring ensures data typed as `list[int]` really contains a list of integers. + +**Validation** is the process of ensuring data matches a set of user-provided constraints; +it can be thought of as validating the value of data. +Validation happens after the shape of the data has been ensured. +Validation can ensure a `list[int]` contains at least one integer, and that all integers are positive. + +## (Value) Validation + +```{versionadded} 24.1.0 + +``` +```{note} _This API is still provisional; as such it is subject to breaking changes._ + +``` + +_cattrs_ can be configured to validate the values of your data (ensuring a list of integers has at least one member, and that all elements are positive). + +The basic unit of value validation is a function that takes a value and, if the value is unacceptable, either raises an exception or returns exactly `False`. +These functions are called _validators_. + +The attributes of _attrs_ classes can be validated with the use of a helper function, {func}`cattrs.v.customize`, and a helper class, {class}`cattrs.v.V`. +_V_ is the validation attribute, mapping to _attrs_ or _dataclass_ attributes. + +```python +from attrs import define +from cattrs import Converter +from cattrs.v import customize, V + +@define +class C: + a: int + +converter = Converter() + +customize(converter, C, V("a").ensure(lambda a: a > 0)) +``` + +Now, every structuring of class `C` will run the provided validator(s). + +```python +converter.structure({"a": -1}, C) +``` + +This process also works with dataclasses: + +```python +from dataclasses import dataclass + +@dataclass +class D: + a: int + +customize(converter, D, V("a").ensure(lambda a: a == 5)) +``` ## Detailed Validation ```{versionadded} 22.1.0 ``` +Detailed validation is enabled by default and can be disabled for a speed boost by creating a converter with `detailed_validation=False`. +When running under detailed validation, the structuring hooks are slightly slower but produce richer and more precise error messages. +Unstructuring hooks are not affected. + In detailed validation mode, any structuring errors will be grouped and raised together as a {class}`cattrs.BaseValidationError`, which is a [PEP 654 ExceptionGroup](https://www.python.org/dev/peps/pep-0654/). ExceptionGroups are special exceptions which contain lists of other exceptions, which may themselves be other ExceptionGroups. In essence, ExceptionGroups are trees of exceptions. diff --git a/src/cattrs/_compat.py b/src/cattrs/_compat.py index bad9d037..3d31f6c8 100644 --- a/src/cattrs/_compat.py +++ b/src/cattrs/_compat.py @@ -32,10 +32,12 @@ from typing import Sequence as TypingSequence from typing import Set as TypingSet -from attrs import NOTHING, Attribute, Factory, resolve_types +from attrs import NOTHING, Attribute, AttrsInstance, Factory, resolve_types from attrs import fields as attrs_fields from attrs import fields_dict as attrs_fields_dict +from ._types import DataclassLike + __all__ = [ "ANIES", "adapted_fields", @@ -131,7 +133,9 @@ def fields(type): return dataclass_fields(type) -def fields_dict(type) -> Dict[str, Union[Attribute, Field]]: +def fields_dict( + type: Union[Type[AttrsInstance], Type[DataclassLike]] +) -> Dict[str, Union[Attribute, Field]]: """Return the fields_dict for attrs and dataclasses.""" if is_dataclass(type): return {f.name: f for f in dataclass_fields(type)} diff --git a/src/cattrs/_types.py b/src/cattrs/_types.py new file mode 100644 index 00000000..834486d4 --- /dev/null +++ b/src/cattrs/_types.py @@ -0,0 +1,58 @@ +"""Types for internal use.""" + +from __future__ import annotations + +from dataclasses import Field +from types import FrameType, TracebackType +from typing import ( + TYPE_CHECKING, + Any, + Callable, + ClassVar, + Tuple, + Type, + TypeVar, + Union, + final, +) + +from typing_extensions import LiteralString, Protocol, TypeAlias + +ExcInfo: TypeAlias = Tuple[Type[BaseException], BaseException, TracebackType] +OptExcInfo: TypeAlias = Union[ExcInfo, Tuple[None, None, None]] + +# Superset of typing.AnyStr that also includes LiteralString +AnyOrLiteralStr = TypeVar("AnyOrLiteralStr", str, bytes, LiteralString) + +# Represents when str or LiteralStr is acceptable. Useful for string processing +# APIs where literalness of return value depends on literalness of inputs +StrOrLiteralStr = TypeVar("StrOrLiteralStr", LiteralString, str) + +# Objects suitable to be passed to sys.setprofile, threading.setprofile, and similar +ProfileFunction: TypeAlias = Callable[[FrameType, str, Any], object] + +# Objects suitable to be passed to sys.settrace, threading.settrace, and similar +TraceFunction: TypeAlias = Callable[[FrameType, str, Any], Union["TraceFunction", None]] + + +# Copied over from https://github.com/hauntsaninja/useful_types/blob/main/useful_types/experimental.py +# Might not work as expected for pyright, see +# https://github.com/python/typeshed/pull/9362 +# https://github.com/microsoft/pyright/issues/4339 +@final +class DataclassLike(Protocol): + """Abstract base class for all dataclass types. + + Mainly useful for type-checking. + """ + + __dataclass_fields__: ClassVar[dict[str, Field[Any]]] = {} + + # we don't want type checkers thinking this is a protocol member; it isn't + if not TYPE_CHECKING: + + def __init_subclass__(cls): + raise TypeError( + "Use the @dataclass decorator to create dataclasses, " + "rather than subclassing dataclasses.DataclassLike" + ) diff --git a/src/cattrs/errors.py b/src/cattrs/errors.py index 2dea0fd7..54ffb9a7 100644 --- a/src/cattrs/errors.py +++ b/src/cattrs/errors.py @@ -125,3 +125,7 @@ def __init__( message or f"Extra fields in constructor for {cln}: {', '.join(extra_fields)}" ) + + +class ValueValidationError(BaseValidationError): + """Raised when a custom value validator fails under detailed validation.""" diff --git a/src/cattrs/v/__init__.py b/src/cattrs/v/__init__.py index c6e0a993..89052c7e 100644 --- a/src/cattrs/v/__init__.py +++ b/src/cattrs/v/__init__.py @@ -9,6 +9,7 @@ ClassValidationError, ForbiddenExtraKeysError, IterableValidationError, + ValueValidationError, ) from ._fluent import V, customize from ._validators import ( @@ -31,6 +32,7 @@ "len_between", "transform_error", "V", + "ValidatorFactory", ] @@ -62,8 +64,10 @@ def format_exception(exc: BaseException, type: Union[type, None]) -> str: if type is not None: tn = type.__name__ if hasattr(type, "__name__") else repr(type) res = f"invalid value for type, expected {tn} ({exc.args[0]})" - else: + elif exc.args: res = f"invalid value ({exc.args[0]})" + else: + res = "invalid value" elif isinstance(exc, TypeError): if type is None: if exc.args[0].endswith("object is not iterable"): @@ -93,7 +97,12 @@ def format_exception(exc: BaseException, type: Union[type, None]) -> str: def transform_error( - exc: Union[ClassValidationError, IterableValidationError, BaseException], + exc: Union[ + ClassValidationError, + IterableValidationError, + ValueValidationError, + BaseException, + ], path: str = "$", format_exception: Callable[ [BaseException, Union[type, None]], str @@ -137,6 +146,10 @@ def transform_error( errors.append(f"{format_exception(exc, note.type)} @ {p}") for exc in without: errors.append(f"{format_exception(exc, None)} @ {path}") + elif isinstance(exc, ValueValidationError): + # This is a value validation error, which we should just flatten. + for inner in exc.exceptions: + errors.append(f"{format_exception(inner, None)} @ {path}") elif isinstance(exc, ExceptionGroup): # Likely from a nested validator, needs flattening. errors.extend( diff --git a/src/cattrs/v/_fluent.py b/src/cattrs/v/_fluent.py index f0498304..6db59a27 100644 --- a/src/cattrs/v/_fluent.py +++ b/src/cattrs/v/_fluent.py @@ -1,4 +1,5 @@ """The fluent validation API.""" + from __future__ import annotations from typing import Any, Callable, Generic, Literal, Sequence, TypeVar @@ -19,14 +20,15 @@ from attrs import fields as f from .. import BaseConverter -from .._compat import ExceptionGroup, TypeAlias +from .._compat import ExceptionGroup, fields_dict, get_origin +from .._types import DataclassLike from ..dispatch import StructureHook from ..gen import make_dict_structure_fn, override +from ._types import Validator, ValidatorFactory +from .fns import invalid_value T = TypeVar("T") -ValidatorFactory: TypeAlias = Callable[[bool], Callable[[T], None]] - @define class VOmitted: @@ -35,7 +37,7 @@ class VOmitted: The class contains no methods. """ - attr: Attribute[Any] + attr: str @define @@ -45,15 +47,19 @@ class VRenamed(Generic[T]): This class has no `omit` and no `rename`. """ - attr: Attribute[T] + attr: Attribute[T] | str new_name: str def ensure( self: VRenamed[T], - validator: Callable[[T], None | bool] | ValidatorFactory[T], - *validators: Callable[[T], None | bool] | ValidatorFactory[T], + validator: Validator[T] | ValidatorFactory[T], + *validators: Validator[T] | ValidatorFactory[T], ) -> VCustomized[T]: - return VCustomized(self.attr, self.new_name, (validator, *validators)) + return VCustomized( + self.attr if isinstance(self.attr, str) else self.attr.name, + self.new_name, + (validator, *validators), + ) @define @@ -63,7 +69,7 @@ class VCustomized(Generic[T]): This class has no `omit`. """ - attr: Attribute[T] + attr: str new_name: str | None validators: tuple[Callable[[T], None | bool] | ValidatorFactory[T], ...] = () @@ -73,24 +79,22 @@ class V(Generic[T]): """ The cattrs.v validation attribute. - Instances are initialized from `attrs.Attribute`s. + Instances are initialized from strings or `attrs.Attribute`s. One V attribute maps directly to each class attribute. - - """ - def __init__(self, attr: Attribute[T]) -> None: + def __init__(self, attr: Attribute[T] | str) -> None: self.attr = attr self.validators = () - attr: Attribute[T] + attr: Attribute[T] | str validators: tuple[Callable[[T], None | bool] | ValidatorFactory[T], ...] = () def ensure( self: V[T], - validator: Callable[[T], None | bool] | ValidatorFactory[T], - *validators: Callable[[T], None] | ValidatorFactory[T], + validator: Validator[T] | ValidatorFactory[T], + *validators: Validator[T] | ValidatorFactory[T], ) -> VCustomized[T]: return VCustomized(self.attr, None, (*self.validators, validator, *validators)) @@ -100,7 +104,7 @@ def rename(self: V[T], new_name: str) -> VRenamed[T]: def omit(self) -> VOmitted: """Omit the attribute.""" - return VOmitted(self.attr) + return VOmitted(self.attr if isinstance(self.attr, str) else self.attr.name) def _is_validator_factory( @@ -110,9 +114,9 @@ def _is_validator_factory( sig = signature(validator) ra = sig.return_annotation return ( - callable(ra) - or isinstance(ra, str) - and sig.return_annotation.startswith("Callable") + ra.startswith("Validator") + if isinstance(ra, str) + else get_origin(ra) is Validator ) @@ -144,7 +148,8 @@ def structure_hook( errors: list[Exception] = [] for hook in _hooks: try: - hook(val) + if hook(val) is False: + invalid_value(val) except Exception as exc: errors.append(exc) if errors: @@ -158,7 +163,8 @@ def structure_hook( ) -> Any: res = _bs(val, t) for hook in _hooks: - hook(val) + if hook(val) is False: + invalid_value(val) return res return structure_hook @@ -166,7 +172,7 @@ def structure_hook( def customize( converter: BaseConverter, - cl: type[AttrsInstance], + cl: type[AttrsInstance] | type[DataclassLike], *fields: VCustomized[Any] | VRenamed[Any] | VOmitted, detailed_validation: bool | Literal["from_converter"] = "from_converter", forbid_extra_keys: bool | Literal["from_converter"] = "from_converter", @@ -187,21 +193,31 @@ def customize( if detailed_validation == "from_converter": detailed_validation = converter.detailed_validation for field in fields: - if field.attr.name in seen: - raise TypeError(f"Duplicate customization for field {field.attr.name}") - if field.attr is not getattr(f(cl), field.attr.name): + field_name = field.attr if isinstance(field.attr, str) else field.attr.name + if field_name in seen: + raise TypeError(f"Duplicate customization for field {field_name}") + + if isinstance(field.attr, str): + try: + attribute = fields_dict(cl)[field.attr] + except KeyError: + raise TypeError(f"Class {cl} has no field {field}") from None + else: + attribute = field.attr + + if not isinstance(field.attr, str) and field.attr is not getattr( + f(cl), field.attr.name + ): raise TypeError(f"Customizing {cl}, but {field} is from a different class") - seen.add(field.attr.name) + seen.add(field_name) if isinstance(field, VOmitted): - overrides[field.attr.name] = override(omit=True) + overrides[field_name] = override(omit=True) elif isinstance(field, VRenamed): - overrides[field.attr.name] = override(rename=field.new_name) + overrides[field_name] = override(rename=field.new_name) elif isinstance(field, VCustomized): - base_hook = converter._structure_func.dispatch(field.attr.type) + base_hook = converter._structure_func.dispatch(attribute.type) hook = _compose_validators(base_hook, field.validators, detailed_validation) - overrides[field.attr.name] = override( - rename=field.new_name, struct_hook=hook - ) + overrides[field_name] = override(rename=field.new_name, struct_hook=hook) else: # The match is exhaustive. assert_never(field) diff --git a/src/cattrs/v/_hooks.py b/src/cattrs/v/_hooks.py index 3531dcb2..ac1e9e23 100644 --- a/src/cattrs/v/_hooks.py +++ b/src/cattrs/v/_hooks.py @@ -1,11 +1,14 @@ """Hooks and hook factories for validation.""" + from __future__ import annotations from typing import TYPE_CHECKING, Any -from .._compat import Annotated, ExceptionGroup, is_annotated +from .._compat import Annotated, is_annotated from ..dispatch import StructureHook +from ..errors import ValueValidationError from . import VAnnotation +from .fns import invalid_value if TYPE_CHECKING: from ..converters import BaseConverter @@ -43,11 +46,11 @@ def validating_hook(val: Any, _: Any) -> Any: for validator in val_annotation.validators: try: if validator(res) is False: - raise ValueError(f"Validation failed for {res}") + invalid_value(res) except Exception as exc: errors.append(exc) if errors: - raise ExceptionGroup("Value validation failed", errors) + raise ValueValidationError("Value validation failed", errors, type) return res else: @@ -56,7 +59,7 @@ def validating_hook(val: Any, _: Any) -> Any: res = base_hook(val, type) for validator in val_annotation.validators: if validator(res) is False: - raise ValueError(f"Validation failed for {res}") + invalid_value(res) return res return validating_hook diff --git a/src/cattrs/v/_types.py b/src/cattrs/v/_types.py new file mode 100644 index 00000000..6538e272 --- /dev/null +++ b/src/cattrs/v/_types.py @@ -0,0 +1,7 @@ +from typing import Any, Callable, TypeAlias, TypeVar + +#: Value validators take a single value and return a single value. +T = TypeVar("T") +Validator: TypeAlias = Callable[[T], Any] + +ValidatorFactory: TypeAlias = Callable[[bool], Validator[T]] diff --git a/src/cattrs/v/_validators.py b/src/cattrs/v/_validators.py index 01d2ed4d..0bd0751a 100644 --- a/src/cattrs/v/_validators.py +++ b/src/cattrs/v/_validators.py @@ -5,17 +5,15 @@ from .._compat import ExceptionGroup from ..errors import IterableValidationError, IterableValidationNote -from ._fluent import ValidatorFactory +from ._types import Validator, ValidatorFactory T = TypeVar("T") class Comparable(Protocol[T]): - def __lt__(self: T, other: T) -> bool: - ... + def __lt__(self: T, other: T) -> bool: ... - def __eq__(self: T, other: T) -> bool: - ... + def __eq__(self: T, other: T) -> bool: ... C = TypeVar("C", bound=Comparable) @@ -69,7 +67,7 @@ def ignoring_none( validators = (validator, *validators) - def factory(detailed_validation: bool) -> Callable[[T | None], None]: + def factory(detailed_validation: bool) -> Validator[T | None]: if detailed_validation: def skip_none(val: T | None, _validators=validators) -> None: @@ -108,7 +106,7 @@ def for_all( validators = (validator, *validators) - def factory(detailed_validation: bool) -> Callable[[T], None]: + def factory(detailed_validation: bool) -> Validator[Iterable[T]]: if detailed_validation: def assert_all_elements(val: Iterable[T], _validators=validators) -> None: @@ -118,7 +116,8 @@ def assert_all_elements(val: Iterable[T], _validators=validators) -> None: try: for v in _validators: try: - v(e) + if v(e) is False: + raise ValueError() except Exception as exc: exc.__notes__ = [ *getattr(exc, "__notes__", []), @@ -137,7 +136,8 @@ def assert_all_elements(val: Iterable[T], _validators=validators) -> None: def assert_all_elements(val: Iterable[T], _validators=validators) -> None: for e in val: for v in _validators: - v(e) + if v(e) is False: + raise ValueError() return assert_all_elements diff --git a/src/cattrs/v/fns.py b/src/cattrs/v/fns.py new file mode 100644 index 00000000..4fff84c6 --- /dev/null +++ b/src/cattrs/v/fns.py @@ -0,0 +1,6 @@ +from typing import Never + + +def invalid_value(val) -> Never: + """Called with an invalid value when a value validator returns `False`.""" + raise ValueError(f"Validation failed for {val}") diff --git a/tests/v/test_ensure.py b/tests/v/test_ensure.py index eff81674..5cfbf47d 100644 --- a/tests/v/test_ensure.py +++ b/tests/v/test_ensure.py @@ -1,4 +1,5 @@ """Tests for `cattrs.v.ensure`.""" + import sys from typing import Dict, List, MutableSequence, Sequence @@ -6,8 +7,8 @@ from cattrs import BaseConverter from cattrs._compat import ExceptionGroup -from cattrs.errors import IterableValidationError -from cattrs.v import ensure +from cattrs.errors import IterableValidationError, ValueValidationError +from cattrs.v import ensure, transform_error from cattrs.v._hooks import is_validated, validator_factory @@ -39,8 +40,11 @@ def test_ensured_lists(valconv: BaseConverter): valconv.structure([], ensure(List[int], lambda lst: len(lst) > 0)) if valconv.detailed_validation: - assert isinstance(exc.value, IterableValidationError) + assert isinstance(exc.value, ValueValidationError) assert isinstance(exc.value.exceptions[0], ValueError) + assert transform_error(exc.value) == [ + "invalid value (Validation failed for []) @ $" + ] else: assert isinstance(exc.value, ValueError) @@ -73,7 +77,7 @@ def test_ensured_list_elements(valconv: BaseConverter, type): ) if valconv.detailed_validation: - assert isinstance(exc.value, IterableValidationError) + assert isinstance(exc.value, ValueValidationError) assert isinstance(exc.value.exceptions[0], ValueError) else: assert isinstance(exc.value, ValueError) @@ -133,7 +137,7 @@ def test_ensured_typing_dict(valconv: BaseConverter): valconv.structure({}, ensure(Dict, lambda d: len(d) > 0, keys=str, values=int)) if valconv.detailed_validation: - assert isinstance(exc.value, IterableValidationError) + assert isinstance(exc.value, ValueValidationError) assert isinstance(exc.value.exceptions[0], ValueError) else: assert isinstance(exc.value, ValueError) diff --git a/tests/v/test_fluent.py b/tests/v/test_fluent.py index 141da251..cd9bfa9c 100644 --- a/tests/v/test_fluent.py +++ b/tests/v/test_fluent.py @@ -1,4 +1,6 @@ """Tests for the fluent validation API.""" + +from dataclasses import dataclass from typing import Dict, List, Union from attrs import Factory, define, evolve @@ -32,11 +34,16 @@ class Model: h: Dict[str, int] = Factory(dict) -def is_lowercase(val: str) -> None: - """A validator included with cattrs. +@dataclass +class DataclassModel: + """A dataclass we want to validate.""" - Probably the simplest possible validator, only takes a string. - """ + a: int + b: str + + +def is_lowercase(val: str) -> None: + """Probably the simplest possible validator, only takes a string.""" if val != val.lower(): raise ValueError(f"{val!r} not lowercase") @@ -133,6 +140,31 @@ def test_simple_string_validation(c: Converter) -> None: assert instance == c.structure(c.unstructure(instance), Model) +def test_simple_string_validation_dc(c: Converter) -> None: + """Simple string validation works for dataclasses.""" + customize(c, DataclassModel, V("b").ensure(is_lowercase)) + + instance = DataclassModel(1, "A") + + unstructured = c.unstructure(instance) + + if c.detailed_validation: + with raises(ClassValidationError) as exc_info: + c.structure(unstructured, DataclassModel) + + assert transform_error(exc_info.value) == [ + "invalid value ('A' not lowercase) @ $.b" + ] + else: + with raises(ValueError) as exc_info: + c.structure(unstructured, DataclassModel) + + assert repr(exc_info.value) == "ValueError(\"'A' not lowercase\")" + + instance.b = "a" + assert instance == c.structure(c.unstructure(instance), DataclassModel) + + def test_multiple_string_validators(c: Converter) -> None: """Simple string validation works.""" customize(c, Model, V(f(Model).b).ensure(is_lowercase, is_email)) @@ -213,3 +245,14 @@ class AnotherModel: with raises(TypeError): customize(c, AnotherModel, V(fs.a).ensure(greater_than(5))) + + +def test_dataclass_typo(c: Converter): + """Customizing a non-existent field is a runtime error.""" + + @dataclass + class AnotherModel: + a: int + + with raises(TypeError): + customize(c, AnotherModel, V("b").ensure(greater_than(5))) diff --git a/tests/v/test_fluent_typing.yml b/tests/v/test_fluent_typing.yml index 28c45d81..988573e4 100644 --- a/tests/v/test_fluent_typing.yml +++ b/tests/v/test_fluent_typing.yml @@ -11,6 +11,30 @@ v.customize(c, A) +- case: empty_customize_dc + main: | + from dataclasses import dataclass + from cattrs import v, Converter + + @dataclass + class A: + a: int + + c = Converter() + + v.customize(c, A) + +- case: empty_customize_unsupported + main: | + from cattrs import v, Converter + + class A: + a: int + + c = Converter() + + v.customize(c, A) # E: Argument 2 to "customize" has incompatible type "type[A]"; expected "type[AttrsInstance] | type[DataclassLike]" [arg-type] + - case: customize_int main: | from attrs import define, fields as f @@ -24,6 +48,19 @@ v.customize(c, A, v.V(f(A).a).ensure(v.between(5, 10))) +- case: customize_int_dc + main: | + from dataclasses import dataclass + from cattrs import v, Converter + + @dataclass + class A: + a: int + + c = Converter() + + v.customize(c, A, v.V("a").ensure(v.between(5, 10))) + - case: customize_int_no_empty_ensure main: | from attrs import define, fields as f @@ -48,4 +85,4 @@ c = Converter() - v.customize(c, A, v.V(f(A).a).ensure(v.len_between(0, 10))) # E: Argument 1 to "ensure" of "V" has incompatible type "Callable[[Sized], None]"; expected "Callable[[int], bool | None] | Callable[[bool], Callable[[int], None]]" [arg-type] + v.customize(c, A, v.V(f(A).a).ensure(v.len_between(0, 10))) # E: Argument 1 to "ensure" of "V" has incompatible type "Callable[[Sized], None]"; expected "Callable[[int], Any] | Callable[[bool], Callable[[int], Any]]" [arg-type]