Skip to content

Commit

Permalink
Implement a new type of callback called validators, which get called …
Browse files Browse the repository at this point in the history
…*before* a value changes
  • Loading branch information
astrofrog committed Sep 16, 2024
1 parent afe8097 commit 6b8d742
Show file tree
Hide file tree
Showing 2 changed files with 123 additions and 12 deletions.
102 changes: 91 additions & 11 deletions echo/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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):
"""
Expand All @@ -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]:
Expand All @@ -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:

Check warning on line 256 in echo/core.py

View check run for this annotation

Codecov / codecov/patch

echo/core.py#L256

Added line #L256 was not covered by tests
if instance in cb:
cb[instance].clear()
if instance in self._disabled:
Expand Down Expand Up @@ -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.
Expand All @@ -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))

Expand Down Expand Up @@ -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
Expand All @@ -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
--------
Expand All @@ -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):
Expand Down
33 changes: 32 additions & 1 deletion echo/tests/test_core.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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

0 comments on commit 6b8d742

Please sign in to comment.