From 7062dc0de22781150ab4d55bc3946de4b582dea7 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 19 Jun 2024 18:45:23 -0400 Subject: [PATCH 1/5] feat: validators --- src/psygnal/__init__.py | 8 +++- src/psygnal/_evented_decorator.py | 1 + src/psygnal/_group_descriptor.py | 71 ++++++++++++++++++++++++++++++- tests/test_validator.py | 29 +++++++++++++ 4 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 tests/test_validator.py diff --git a/src/psygnal/__init__.py b/src/psygnal/__init__.py index 8bceb9e7..c76dec48 100644 --- a/src/psygnal/__init__.py +++ b/src/psygnal/__init__.py @@ -34,6 +34,7 @@ "SignalGroupDescriptor", "SignalInstance", "throttled", + "Validator", ] @@ -51,7 +52,12 @@ from ._evented_decorator import evented from ._exceptions import EmitLoopError from ._group import EmissionInfo, SignalGroup -from ._group_descriptor import SignalGroupDescriptor, get_evented_namespace, is_evented +from ._group_descriptor import ( + SignalGroupDescriptor, + Validator, + get_evented_namespace, + is_evented, +) from ._queue import emit_queued from ._signal import Signal, SignalInstance, _compiled from ._throttler import debounced, throttled diff --git a/src/psygnal/_evented_decorator.py b/src/psygnal/_evented_decorator.py index 4b084b9c..1823a92e 100644 --- a/src/psygnal/_evented_decorator.py +++ b/src/psygnal/_evented_decorator.py @@ -133,6 +133,7 @@ def _decorate(cls: T) -> T: # as a decorator, this will have already been called descriptor.__set_name__(cls, events_namespace) setattr(cls, events_namespace, descriptor) + descriptor._do_patch_setattr(cls) return cls return _decorate(cls) if cls is not None else _decorate diff --git a/src/psygnal/_group_descriptor.py b/src/psygnal/_group_descriptor.py index bf789fdd..cb2b82a5 100644 --- a/src/psygnal/_group_descriptor.py +++ b/src/psygnal/_group_descriptor.py @@ -6,8 +6,10 @@ import sys import warnings import weakref +from dataclasses import dataclass from typing import ( TYPE_CHECKING, + Annotated, Any, Callable, ClassVar, @@ -15,9 +17,12 @@ Literal, Mapping, Optional, + Sequence, Type, TypeVar, cast, + get_args, + get_origin, overload, ) @@ -325,7 +330,10 @@ def __exit__(self, *args: Any) -> None: @overload def evented_setattr( - signal_group_name: str, super_setattr: SetAttr, with_aliases: bool = ... + signal_group_name: str, + super_setattr: SetAttr, + with_aliases: bool = ..., + validators: Mapping[str, Sequence[Validator]] | None = ..., ) -> SetAttr: ... @@ -334,6 +342,7 @@ def evented_setattr( signal_group_name: str, super_setattr: Literal[None] | None = ..., with_aliases: bool = ..., + validators: Mapping[str, Sequence[Validator]] | None = ..., ) -> Callable[[SetAttr], SetAttr]: ... @@ -341,6 +350,7 @@ def evented_setattr( signal_group_name: str, super_setattr: SetAttr | None = None, with_aliases: bool = True, + validators: Mapping[str, Sequence[Validator]] | None = None, ) -> SetAttr | Callable[[SetAttr], SetAttr]: """Create a new __setattr__ method that emits events when fields change. @@ -374,7 +384,11 @@ def __getattr__(self, name: str) -> SignalInstanceProtocol: ... Whether to lookup the signal name in the signal aliases mapping, by default True. This is slightly slower, and so can be set to False if you know you don't have any signal aliases. + validators: Mapping[str, Sequence[Validator]] | None + A mapping of field name to a sequence of validators to run on the value before + setting it. If None, no validators are run. Default to None """ + validators = validators or {} def _inner(super_setattr: SetAttr) -> SetAttr: # don't patch twice @@ -391,6 +405,9 @@ def _setattr_and_emit_(self: object, name: str, value: Any) -> None: if name == signal_group_name: return super_setattr(self, name, value) + for validator in validators.get(name, ()): + value = validator(value, name=name, owner=self) + group = cast(SignalGroup, getattr(self, signal_group_name)) if not with_aliases and name not in group: return super_setattr(self, name, value) @@ -524,6 +541,7 @@ def __init__( signal_group_class: type[SignalGroup] | None = None, collect_fields: bool = True, signal_aliases: Mapping[str, str | None] | FieldAliasFunc | None = None, + eager: bool | None = None, ): grp_cls = signal_group_class or SignalGroup if not (isinstance(grp_cls, type) and issubclass(grp_cls, SignalGroup)): @@ -552,6 +570,7 @@ def __init__( self._signal_group_class: type[SignalGroup] = grp_cls self._collect_fields = collect_fields self._signal_aliases = signal_aliases + self._eager = eager self._signal_groups: dict[int, type[SignalGroup]] = {} @@ -561,6 +580,18 @@ def __set_name__(self, owner: type, name: str) -> None: with contextlib.suppress(AttributeError): # This is the flag that identifies this object as evented setattr(owner, PSYGNAL_GROUP_NAME, name) + if self._eager is not False: + if self._find_validators(owner): + self._get_signal_group(owner) + + def _find_validators(self, owner: type) -> dict[str, list[Validator]]: + validators: dict[str, list[Validator]] = {} + for field, annotation in owner.__annotations__.items(): + if get_origin(annotation) is Annotated: + for item in get_args(annotation)[1:]: + if isinstance(item, Validator): + validators.setdefault(field, []).append(item) + return validators def _do_patch_setattr(self, owner: type, with_aliases: bool = True) -> None: """Patch the owner class's __setattr__ method to emit events.""" @@ -581,6 +612,7 @@ def _do_patch_setattr(self, owner: type, with_aliases: bool = True) -> None: name, owner.__setattr__, # type: ignore with_aliases=with_aliases, + validators=self._find_validators(owner), ) except Exception as e: # pragma: no cover # not sure what might cause this ... but it will have consequences @@ -656,3 +688,40 @@ def _create_group(self, owner: type) -> type[SignalGroup]: self._do_patch_setattr(owner, with_aliases=bool(Group._psygnal_aliases)) return Group + + +@dataclass +class Validator: + """Annotated metadatax that marks a function validates a value before setting. + + Examples + -------- + ```python + from psygnal import Validator, evented + + + def is_positive(value: int) -> int: + if not value > 0: + raise ValueError("Value must be positive") + return value + + + @evented + @dataclass + class Foo: + x: Annotated[int, Validator(is_positive)] + ``` + + """ + + func: Callable[[Any], Any] + + def __call__(self, value: Any, *, name: str, owner: Any) -> Any: + """Validate the input.""" + try: + return self.func(value) + except Exception as e: + raise ValueError( + f"Error setting value {value!r} for field {name!r} " + f"on type {type(owner)}: {e}" + ) from e diff --git a/tests/test_validator.py b/tests/test_validator.py new file mode 100644 index 00000000..dffd7ec3 --- /dev/null +++ b/tests/test_validator.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass +from typing import Annotated, Any + +import pytest + +from psygnal import Validator, evented + + +def test_validator(): + def _is_positive(value: Any) -> int: + try: + _value = int(value) + except (ValueError, TypeError): + raise ValueError("Value must be an integer") from None + if not _value > 0: + raise ValueError("Value must be positive") + return _value + + @evented + @dataclass + class Foo: + x: Annotated[int, Validator(_is_positive)] + + with pytest.raises(ValueError, match="Value must be positive"): + Foo(x=-1) + foo = Foo(x="1") # type: ignore + assert isinstance(foo.x, int) + with pytest.raises(ValueError): + foo.x = -1 From d25f89816af053bf5a0cc92d5b3b7f1aac423df3 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 19 Jun 2024 19:09:54 -0400 Subject: [PATCH 2/5] resolve --- src/psygnal/_group_descriptor.py | 29 ++++++++++++++++++++---- tests/test_validator.py | 39 ++++++++++++++++++++++++-------- 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/src/psygnal/_group_descriptor.py b/src/psygnal/_group_descriptor.py index cb2b82a5..43ffca85 100644 --- a/src/psygnal/_group_descriptor.py +++ b/src/psygnal/_group_descriptor.py @@ -2,6 +2,7 @@ import contextlib import copy +import inspect import operator import sys import warnings @@ -13,6 +14,7 @@ Any, Callable, ClassVar, + ForwardRef, Iterable, Literal, Mapping, @@ -587,10 +589,19 @@ def __set_name__(self, owner: type, name: str) -> None: def _find_validators(self, owner: type) -> dict[str, list[Validator]]: validators: dict[str, list[Validator]] = {} for field, annotation in owner.__annotations__.items(): - if get_origin(annotation) is Annotated: - for item in get_args(annotation)[1:]: - if isinstance(item, Validator): - validators.setdefault(field, []).append(item) + try: + annotation = _resolve(annotation, owner) + if get_origin(annotation) is Annotated: + for item in get_args(annotation)[1:]: + if isinstance(item, Validator): + validators.setdefault(field, []).append(item) + except Exception: + warnings.warn( + f"Unable to resolve type annotation {annotation}" + "Psygnal Validator will not work", + stacklevel=2, + ) + return validators def _do_patch_setattr(self, owner: type, with_aliases: bool = True) -> None: @@ -725,3 +736,13 @@ def __call__(self, value: Any, *, name: str, owner: Any) -> Any: f"Error setting value {value!r} for field {name!r} " f"on type {type(owner)}: {e}" ) from e + + +def _resolve(annotation: Any, owner: Any) -> Any: + if isinstance(annotation, str): + annotation = ForwardRef(annotation) + if isinstance(annotation, ForwardRef): + guard: frozenset = frozenset() + _globals = inspect.getmodule(owner).__dict__ + annotation = annotation._evaluate(_globals, {}, guard) + return annotation diff --git a/tests/test_validator.py b/tests/test_validator.py index dffd7ec3..19d4b43f 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -6,16 +6,17 @@ from psygnal import Validator, evented -def test_validator(): - def _is_positive(value: Any) -> int: - try: - _value = int(value) - except (ValueError, TypeError): - raise ValueError("Value must be an integer") from None - if not _value > 0: - raise ValueError("Value must be positive") - return _value +def _is_positive(value: Any) -> int: + try: + _value = int(value) + except (ValueError, TypeError): + raise ValueError("Value must be an integer") from None + if not _value > 0: + raise ValueError("Value must be positive") + return _value + +def test_validator(): @evented @dataclass class Foo: @@ -27,3 +28,23 @@ class Foo: assert isinstance(foo.x, int) with pytest.raises(ValueError): foo.x = -1 + + +def test_validator_resolution(): + @evented + @dataclass + class Bar: + x: "Annotated[int, Validator(_is_positive)]" + + with pytest.raises(ValueError, match="Value must be positive"): + Bar(x=-1) + + def _local_func(value: Any) -> Any: + return value + + with pytest.warns(UserWarning, match="Unable to resolve type"): + + @evented + @dataclass + class Baz: + x: "Annotated[int, Validator(_local_func)]" From 72213f2b8782338f514768f3fd310a8beac55999 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 19 Jun 2024 19:13:23 -0400 Subject: [PATCH 3/5] add docs --- src/psygnal/_group_descriptor.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/psygnal/_group_descriptor.py b/src/psygnal/_group_descriptor.py index 43ffca85..42a7acda 100644 --- a/src/psygnal/_group_descriptor.py +++ b/src/psygnal/_group_descriptor.py @@ -507,6 +507,12 @@ def __setattr__(self, name: str, value: Any) -> None: field name. If the output is None, no signal is created for this field. If None, defaults to an empty dict, no aliases. Default to None + eager: bool | None, optional + If True, the SignalGroup will be created when the descriptor is set on the + class. If False, the SignalGroup will not be created until the first access of + the descriptor on an instance. If None, the SignalGroup will be created when + the descriptor is set on the class only if validators are found in the class + annotations. Examples -------- From 1cc4fce034ff03d6329dd30155f3092b31cc943c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 19 Jun 2024 19:13:44 -0400 Subject: [PATCH 4/5] add default --- src/psygnal/_group_descriptor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/psygnal/_group_descriptor.py b/src/psygnal/_group_descriptor.py index 42a7acda..bbf2e152 100644 --- a/src/psygnal/_group_descriptor.py +++ b/src/psygnal/_group_descriptor.py @@ -512,7 +512,7 @@ def __setattr__(self, name: str, value: Any) -> None: class. If False, the SignalGroup will not be created until the first access of the descriptor on an instance. If None, the SignalGroup will be created when the descriptor is set on the class only if validators are found in the class - annotations. + annotations. By default None Examples -------- From 44af4b22293936ce7be46713576c6f94e5316ad1 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 20 Jun 2024 04:49:34 -0400 Subject: [PATCH 5/5] Update _group_descriptor.py Co-authored-by: Davis Bennett --- src/psygnal/_group_descriptor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/psygnal/_group_descriptor.py b/src/psygnal/_group_descriptor.py index bbf2e152..aab60ce7 100644 --- a/src/psygnal/_group_descriptor.py +++ b/src/psygnal/_group_descriptor.py @@ -709,7 +709,7 @@ def _create_group(self, owner: type) -> type[SignalGroup]: @dataclass class Validator: - """Annotated metadatax that marks a function validates a value before setting. + """Annotated metadata marking that a function validates a value before setting. Examples --------