From 6b8d742d5dc67f898ef528fdb84ce82695612c78 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Mon, 16 Sep 2024 12:45:42 +0100 Subject: [PATCH 1/4] Implement a new type of callback called validators, which get called *before* a value changes --- echo/core.py | 102 +++++++++++++++++++++++++++++++++++----- echo/tests/test_core.py | 33 ++++++++++++- 2 files changed, 123 insertions(+), 12 deletions(-) diff --git a/echo/core.py b/echo/core.py index 55cc9c647..7955ab5ac 100644 --- a/echo/core.py +++ b/echo/core.py @@ -4,12 +4,22 @@ from .callback_container import CallbackContainer -__all__ = ['CallbackProperty', 'callback_property', +__all__ = ['ValidationException', + 'SilentValidationException', + 'CallbackProperty', 'callback_property', 'add_callback', 'remove_callback', 'delay_callback', 'ignore_callback', 'HasCallbackProperties', 'keep_in_sync'] +class ValidationException(Exception): + pass + + +class SilentValidationException(Exception): + pass + + class CallbackProperty(object): """ A property that callback functions can be added to. @@ -37,6 +47,8 @@ def __init__(self, default=None, docstring=None, getter=None, setter=None): :param default: The initial value for the property """ self._default = default + self._validators = WeakKeyDictionary() + self._2arg_validators = WeakKeyDictionary() self._callbacks = WeakKeyDictionary() self._2arg_callbacks = WeakKeyDictionary() self._disabled = WeakKeyDictionary() @@ -66,11 +78,19 @@ def __get__(self, instance, owner=None): return self._getter(instance) def __set__(self, instance, value): + try: old = self.__get__(instance) except AttributeError: # pragma: no cover old = None + + try: + value = self._validate(instance, old, value) + except SilentValidationException: + return + self._setter(instance, value) + new = self.__get__(instance) if old != new: self.notify(instance, old, new) @@ -126,6 +146,32 @@ def notify(self, instance, old, new): for cback in self._2arg_callbacks.get(instance, []): cback(old, new) + def _validate(self, instance, old, new): + """ + Call all validators. + + Each validator will either be called using + validator(new) or validator(old, new) depending + on whether ``echo_old`` was set to `True` when calling + :func:`~echo.add_callback` + + Parameters + ---------- + instance + The instance to consider + old + The old value of the property + new + The new value of the property + """ + # Note: validators can't be delayed so we don't check for + # enabled/disabled as in notify() + for cback in self._validators.get(instance, []): + new = cback(new) + for cback in self._2arg_validators.get(instance, []): + new = cback(old, new) + return new + def disable(self, instance): """ Disable callbacks for a specific instance @@ -141,7 +187,7 @@ def enable(self, instance): def enabled(self, instance): return not self._disabled.get(instance, False) - def add_callback(self, instance, func, echo_old=False, priority=0): + def add_callback(self, instance, func, echo_old=False, priority=0, validator=False): """ Add a callback to a specific instance that manages this property @@ -158,12 +204,30 @@ def add_callback(self, instance, func, echo_old=False, priority=0): priority : int, optional This can optionally be used to force a certain order of execution of callbacks (larger values indicate a higher priority). + validator : bool, optional + Whether the callback is a validator, which is a special kind of + callback that gets called *before* the property is set. The + validator can return a modified value (for example it can be used + to change the types of values or change properties in-place) or it + can also raise an `echo.ValidationException` or + `echo.SilentValidationException`, the latter of which means the + updating of the property will be silently abandonned. """ - if echo_old: - self._2arg_callbacks.setdefault(instance, CallbackContainer()).append(func, priority=priority) + if validator: + if echo_old: + self._2arg_validators.setdefault(instance, CallbackContainer()).append(func, priority=priority) + else: + self._validators.setdefault(instance, CallbackContainer()).append(func, priority=priority) else: - self._callbacks.setdefault(instance, CallbackContainer()).append(func, priority=priority) + if echo_old: + self._2arg_callbacks.setdefault(instance, CallbackContainer()).append(func, priority=priority) + else: + self._callbacks.setdefault(instance, CallbackContainer()).append(func, priority=priority) + + @property + def _all_callbacks(self): + return [self._validators, self._2arg_validators, self._callbacks, self._2arg_callbacks] def remove_callback(self, instance, func): """ @@ -176,7 +240,7 @@ def remove_callback(self, instance, func): func : func The callback function to remove """ - for cb in [self._callbacks, self._2arg_callbacks]: + for cb in self._all_callbacks: if instance not in cb: continue if func in cb[instance]: @@ -189,7 +253,7 @@ def clear_callbacks(self, instance): """ Remove all callbacks on this property. """ - for cb in [self._callbacks, self._2arg_callbacks]: + for cb in self._all_callbacks: if instance in cb: cb[instance].clear() if instance in self._disabled: @@ -262,7 +326,7 @@ def __setattr__(self, attribute, value): if self.is_callback_property(attribute): self._notify_global(**{attribute: value}) - def add_callback(self, name, callback, echo_old=False, priority=0): + def add_callback(self, name, callback, echo_old=False, priority=0, validator=False): """ Add a callback that gets triggered when a callback property of the class changes. @@ -280,10 +344,18 @@ class changes. priority : int, optional This can optionally be used to force a certain order of execution of callbacks (larger values indicate a higher priority). + validator : bool, optional + Whether the callback is a validator, which is a special kind of + callback that gets called *before* the property is set. The + validator can return a modified value (for example it can be used + to change the types of values or change properties in-place) or it + can also raise an `echo.ValidationException` or + `echo.SilentValidationException`, the latter of which means the + updating of the property will be silently abandonned. """ if self.is_callback_property(name): prop = getattr(type(self), name) - prop.add_callback(self, callback, echo_old=echo_old, priority=priority) + prop.add_callback(self, callback, echo_old=echo_old, priority=priority, validator=validator) else: raise TypeError("attribute '{0}' is not a callback property".format(name)) @@ -362,7 +434,7 @@ def clear_callbacks(self): prop.clear_callbacks(self) -def add_callback(instance, prop, callback, echo_old=False, priority=0): +def add_callback(instance, prop, callback, echo_old=False, priority=0, validator=False): """ Attach a callback function to a property in an instance @@ -381,6 +453,14 @@ def add_callback(instance, prop, callback, echo_old=False, priority=0): priority : int, optional This can optionally be used to force a certain order of execution of callbacks (larger values indicate a higher priority). + validator : bool, optional + Whether the callback is a validator, which is a special kind of + callback that gets called *before* the property is set. The + validator can return a modified value (for example it can be used + to change the types of values or change properties in-place) or it + can also raise an `echo.ValidationException` or + `echo.SilentValidationException`, the latter of which means the + updating of the property will be silently abandonned. Examples -------- @@ -400,7 +480,7 @@ def callback(value): p = getattr(type(instance), prop) if not isinstance(p, CallbackProperty): raise TypeError("%s is not a CallbackProperty" % prop) - p.add_callback(instance, callback, echo_old=echo_old, priority=priority) + p.add_callback(instance, callback, echo_old=echo_old, priority=priority, validator=validator) def remove_callback(instance, prop, callback): diff --git a/echo/tests/test_core.py b/echo/tests/test_core.py index b730f8223..62223d7e6 100644 --- a/echo/tests/test_core.py +++ b/echo/tests/test_core.py @@ -1,7 +1,8 @@ import pytest from unittest.mock import MagicMock -from echo import (CallbackProperty, add_callback, +from echo import (ValidationException, SilentValidationException, + CallbackProperty, add_callback, remove_callback, delay_callback, ignore_callback, callback_property, HasCallbackProperties, keep_in_sync) @@ -637,3 +638,33 @@ def callback(*args, **kwargs): with ignore_callback(state, 'a', 'b'): state.a = 100 + + +def test_validator(): + + state = State() + state.a = 1 + state.b = 2.2 + + def add_one_and_silent_ignore(new_value): + if new_value == 'ignore': + raise SilentValidationException() + return new_value + 1 + + def preserve_type(old_value, new_value): + if type(new_value) is not type(old_value): + raise ValidationException('types should not change') + + state.add_callback('a', add_one_and_silent_ignore, validator=True) + state.add_callback('b', preserve_type, validator=True, echo_old=True) + + state.a = 3 + assert state.a == 4 + + state.a = 'ignore' + assert state.a == 4 + + state.b = 3.2 + + with pytest.raises(ValidationException, match='types should not change'): + state.b = 2 From ec8cddb80c579dbc7e4d242211c20d3b75d5e217 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Tue, 17 Sep 2024 11:07:09 +0100 Subject: [PATCH 2/4] Add proper functional API for adding/removing callbacks from callback container, and add support for item validators --- echo/containers.py | 62 +++++++++++++++++++++++++++-------- echo/tests/test_containers.py | 8 ++--- 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/echo/containers.py b/echo/containers.py index b26cdd2f2..7d6129625 100644 --- a/echo/containers.py +++ b/echo/containers.py @@ -7,7 +7,13 @@ class ContainerMixin: + def _setup_container(self): + self._callbacks = CallbackContainer() + self._item_validators = CallbackContainer() + def _prepare_add(self, value): + for validator in self._item_validators: + value = validator(value) if isinstance(value, list): value = CallbackList(self.notify_all, value) elif isinstance(value, dict): @@ -22,7 +28,45 @@ def _cleanup_remove(self, value): if isinstance(value, HasCallbackProperties): value.remove_global_callback(self.notify_all) elif isinstance(value, (CallbackList, CallbackDict)): - value.callbacks.remove(self.notify_all) + value.remove_callback(self.notify_all) + + def add_callback(self, func, priority=0, validator=False): + """ + Add a callback to the container. + + Note that validators are applied on a per item basis, whereas regular + callbacks are called with the whole list after modification. + + Parameters + ---------- + func : func + The callback function to add + priority : int, optional + This can optionally be used to force a certain order of execution of + callbacks (larger values indicate a higher priority). + validator : bool, optional + Whether the callback is a validator, which is a special kind of + callback that gets called with the item being added to the + container *before* the container is modified. The validator can + return the value as-is, modify it, or emit warnings or an exception. + """ + + if validator: + self._item_validator.append(func, priority=priority) + else: + self._callbacks.append(func, priority=priority) + + def remove_callback(self, func): + """ + Remove a callback from the container. + """ + for cb in (self._callbacks, self._item_validators): + if func in cb: + cb.remove(func) + + def notify_all(self, *args, **kwargs): + for callback in self._callbacks: + callback(*args, **kwargs) class CallbackList(list, ContainerMixin): @@ -35,15 +79,11 @@ class CallbackList(list, ContainerMixin): def __init__(self, callback, *args, **kwargs): super(CallbackList, self).__init__(*args, **kwargs) - self.callbacks = CallbackContainer() - self.callbacks.append(callback) + self._setup_container() + self.add_callback(callback) for index, value in enumerate(self): super().__setitem__(index, self._prepare_add(value)) - def notify_all(self, *args, **kwargs): - for callback in self.callbacks: - callback(*args, **kwargs) - def __repr__(self): return "".format(len(self)) @@ -113,15 +153,11 @@ class CallbackDict(dict, ContainerMixin): def __init__(self, callback, *args, **kwargs): super(CallbackDict, self).__init__(*args, **kwargs) - self.callbacks = CallbackContainer() - self.callbacks.append(callback) + self._setup_container() + self.add_callback(callback) for key, value in self.items(): super().__setitem__(key, self._prepare_add(value)) - def notify_all(self, *args, **kwargs): - for callback in self.callbacks: - callback(*args, **kwargs) - def clear(self): for value in self.values(): self._cleanup_remove(value) diff --git a/echo/tests/test_containers.py b/echo/tests/test_containers.py index 8d817e58a..3213755d9 100644 --- a/echo/tests/test_containers.py +++ b/echo/tests/test_containers.py @@ -538,7 +538,7 @@ def test_list_additional_callbacks(): assert test1.call_count == 1 assert test2.call_count == 0 - stub.prop1.callbacks.append(test2) + stub.prop1.add_callback(test2) stub.prop2.append(5) assert test1.call_count == 1 @@ -548,7 +548,7 @@ def test_list_additional_callbacks(): assert test1.call_count == 2 assert test2.call_count == 1 - stub.prop1.callbacks.remove(test2) + stub.prop1.remove_callback(test2) stub.prop1.append(4) assert test1.call_count == 3 assert test2.call_count == 1 @@ -568,7 +568,7 @@ def test_dict_additional_callbacks(): assert test1.call_count == 1 assert test2.call_count == 0 - stub.prop1.callbacks.append(test2) + stub.prop1.add_callback(test2) stub.prop2['c'] = 3 assert test1.call_count == 1 @@ -578,7 +578,7 @@ def test_dict_additional_callbacks(): assert test1.call_count == 2 assert test2.call_count == 1 - stub.prop1.callbacks.remove(test2) + stub.prop1.remove_callback(test2) stub.prop1['e'] = 5 assert test1.call_count == 3 assert test2.call_count == 1 From cccd9a55bc7b97857c2d0157ef698810aa59348e Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Tue, 17 Sep 2024 11:27:25 +0100 Subject: [PATCH 3/4] Remove ValidationException and SilentValidationException for now as not clear they are actually needed --- echo/core.py | 38 +++++++++----------------------------- echo/tests/test_core.py | 16 +++++----------- 2 files changed, 14 insertions(+), 40 deletions(-) diff --git a/echo/core.py b/echo/core.py index 7955ab5ac..eb7ec10b0 100644 --- a/echo/core.py +++ b/echo/core.py @@ -4,22 +4,12 @@ from .callback_container import CallbackContainer -__all__ = ['ValidationException', - 'SilentValidationException', - 'CallbackProperty', 'callback_property', +__all__ = ['CallbackProperty', 'callback_property', 'add_callback', 'remove_callback', 'delay_callback', 'ignore_callback', 'HasCallbackProperties', 'keep_in_sync'] -class ValidationException(Exception): - pass - - -class SilentValidationException(Exception): - pass - - class CallbackProperty(object): """ A property that callback functions can be added to. @@ -84,10 +74,7 @@ def __set__(self, instance, value): except AttributeError: # pragma: no cover old = None - try: - value = self._validate(instance, old, value) - except SilentValidationException: - return + value = self._validate(instance, old, value) self._setter(instance, value) @@ -209,9 +196,7 @@ def add_callback(self, instance, func, echo_old=False, priority=0, validator=Fal callback that gets called *before* the property is set. The validator can return a modified value (for example it can be used to change the types of values or change properties in-place) or it - can also raise an `echo.ValidationException` or - `echo.SilentValidationException`, the latter of which means the - updating of the property will be silently abandonned. + can also raise an exception. """ if validator: @@ -349,10 +334,7 @@ class changes. callback that gets called *before* the property is set. The validator can return a modified value (for example it can be used to change the types of values or change properties in-place) or it - can also raise an `echo.ValidationException` or - `echo.SilentValidationException`, the latter of which means the - updating of the property will be silently abandonned. - """ + can also raise an exception. """ if self.is_callback_property(name): prop = getattr(type(self), name) prop.add_callback(self, callback, echo_old=echo_old, priority=priority, validator=validator) @@ -454,13 +436,11 @@ def add_callback(instance, prop, callback, echo_old=False, priority=0, validator This can optionally be used to force a certain order of execution of callbacks (larger values indicate a higher priority). validator : bool, optional - Whether the callback is a validator, which is a special kind of - callback that gets called *before* the property is set. The - validator can return a modified value (for example it can be used - to change the types of values or change properties in-place) or it - can also raise an `echo.ValidationException` or - `echo.SilentValidationException`, the latter of which means the - updating of the property will be silently abandonned. + Whether the callback is a validator, which is a special kind of + callback that gets called *before* the property is set. The + validator can return a modified value (for example it can be used + to change the types of values or change properties in-place) or it + can also raise an exception. Examples -------- diff --git a/echo/tests/test_core.py b/echo/tests/test_core.py index 62223d7e6..384ac36fc 100644 --- a/echo/tests/test_core.py +++ b/echo/tests/test_core.py @@ -1,8 +1,7 @@ import pytest from unittest.mock import MagicMock -from echo import (ValidationException, SilentValidationException, - CallbackProperty, add_callback, +from echo import (CallbackProperty, add_callback, remove_callback, delay_callback, ignore_callback, callback_property, HasCallbackProperties, keep_in_sync) @@ -646,25 +645,20 @@ def test_validator(): state.a = 1 state.b = 2.2 - def add_one_and_silent_ignore(new_value): - if new_value == 'ignore': - raise SilentValidationException() + def add_one(new_value): return new_value + 1 def preserve_type(old_value, new_value): if type(new_value) is not type(old_value): - raise ValidationException('types should not change') + raise TypeError('types should not change') - state.add_callback('a', add_one_and_silent_ignore, validator=True) + state.add_callback('a', add_one, validator=True) state.add_callback('b', preserve_type, validator=True, echo_old=True) state.a = 3 assert state.a == 4 - state.a = 'ignore' - assert state.a == 4 - state.b = 3.2 - with pytest.raises(ValidationException, match='types should not change'): + with pytest.raises(TypeError, match='types should not change'): state.b = 2 From b0662e5e2328e96605b586c1921bbd7c63824ae2 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Tue, 17 Sep 2024 11:40:26 +0100 Subject: [PATCH 4/4] Test item validators on containers --- echo/containers.py | 2 +- echo/tests/test_containers.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/echo/containers.py b/echo/containers.py index 7d6129625..815ef2c6d 100644 --- a/echo/containers.py +++ b/echo/containers.py @@ -52,7 +52,7 @@ def add_callback(self, func, priority=0, validator=False): """ if validator: - self._item_validator.append(func, priority=priority) + self._item_validators.append(func, priority=priority) else: self._callbacks.append(func, priority=priority) diff --git a/echo/tests/test_containers.py b/echo/tests/test_containers.py index 3213755d9..2e8c2573c 100644 --- a/echo/tests/test_containers.py +++ b/echo/tests/test_containers.py @@ -582,3 +582,20 @@ def test_dict_additional_callbacks(): stub.prop1['e'] = 5 assert test1.call_count == 3 assert test2.call_count == 1 + + +def test_item_validator(): + + stub_list = StubList() + stub_dict = StubDict() + + def add_one(value): + return value + 1 + + stub_list.prop1.add_callback(add_one, validator=True) + stub_list.prop1.append(1) + assert stub_list.prop1 == [2] + + stub_dict.prop1.add_callback(add_one, validator=True) + stub_dict.prop1['a'] = 2 + assert stub_dict.prop1 == {'a': 3}