Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement a new type of callback called validators, which get called before a value changes #42

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 49 additions & 13 deletions echo/containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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_validators.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):
Expand All @@ -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 "<CallbackList with {0} elements>".format(len(self))

Expand Down Expand Up @@ -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)
Expand Down
82 changes: 71 additions & 11 deletions echo/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
: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 +68,16 @@
return self._getter(instance)

def __set__(self, instance, value):

try:
old = self.__get__(instance)
except AttributeError: # pragma: no cover
old = None

value = self._validate(instance, old, value)

self._setter(instance, value)

new = self.__get__(instance)
if old != new:
self.notify(instance, old, new)
Expand Down Expand Up @@ -126,6 +133,32 @@
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 +174,7 @@
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 +191,28 @@
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 exception.
"""

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 +225,7 @@
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 +238,7 @@
"""
Remove all callbacks on this property.
"""
for cb in [self._callbacks, self._2arg_callbacks]:
for cb in self._all_callbacks:

Check warning on line 241 in echo/core.py

View check run for this annotation

Codecov / codecov/patch

echo/core.py#L241

Added line #L241 was not covered by tests
if instance in cb:
cb[instance].clear()
if instance in self._disabled:
Expand Down Expand Up @@ -262,7 +311,7 @@
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 +329,15 @@
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 exception. """
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 +416,7 @@
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 +435,12 @@
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 exception.

Examples
--------
Expand All @@ -400,7 +460,7 @@
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
25 changes: 21 additions & 4 deletions echo/tests/test_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -578,7 +578,24 @@ 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


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}
25 changes: 25 additions & 0 deletions echo/tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -637,3 +637,28 @@ 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(new_value):
return new_value + 1

def preserve_type(old_value, new_value):
if type(new_value) is not type(old_value):
raise TypeError('types should not change')

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.b = 3.2

with pytest.raises(TypeError, match='types should not change'):
state.b = 2