Added add_event_listener and remove_event_listener

This commit is contained in:
2025-11-09 18:05:41 +01:00
parent d62b135cee
commit 0ef9523e6b
4 changed files with 594 additions and 24 deletions

View File

@@ -150,6 +150,82 @@ data.value = "final"
# Callback2: test -> final
```
#### Event listener
I can register a listener for the `ObservableEvent.AFTER_PROPERTY_CHANGE` event.
```python
from dataclasses import dataclass
from myutils.observable import make_observable, bind, add_event_listener, ObservableEvent
@dataclass
class Data:
number: int = 1
data = Data()
make_observable(data)
on_change_results = []
on_after_change_results = []
def on_change(old, new):
on_change_results.append((old, new))
return new + 1
def on_after_change(attr, old, new, results):
on_after_change_results.append((attr, old, new, results))
bind(data, 'number', on_change)
add_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "number", on_after_change)
data.number = 5
data.number = 10
assert on_change_results == [(1, 5), (5, 10)]
assert on_after_change_results == [("number", 1, 5, [6]), ("number", 5, 10, [11])]
```
I can register for all attributes change events.
```python
from myutils.observable import make_observable, bind, add_event_listener, ObservableEvent
class Data:
number: int = 1
value: str = "initial"
data = Data()
make_observable(data)
def on_change_1(old, new):
return new
def on_change_2(old, new):
return str(new) + "_1"
on_after_change_results = []
def on_after_change(attr, old, new, results):
on_after_change_results.append((attr, old, new, results))
add_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", on_after_change)
bind(data, 'number', on_change_1)
bind(data, 'number', on_change_2)
bind(data, 'value', on_change_1)
data.number = 5
data.value = "new value"
assert on_after_change_results == [("number", 1, 5, [5, "5_1"]), ("value", "initial", "new value", ["new value"])]
```
#### Using Helper Functions
```python
@@ -319,4 +395,5 @@ Special thanks to the Python and open-source community for their tools, inspirat
* 0.1.0 : Initial release
* 0.2.0 : Observable results can be collected using `collect_return_values`
* 0.3.0 : Added `unbind`, `unbind_all`, `has_listeners` `get_listener_count` to Observable
* 0.3.0 : Added `unbind`, `unbind_all`, `has_listeners` `get_listener_count` to Observable
* 0.4.0 : Added `add_event_listener` and `remove_event_listener`

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "myutils"
version = "0.3.0"
version = "0.4.0"
description = "Base useful classes."
readme = "README.md"
authors = [

View File

@@ -1,8 +1,15 @@
from enum import Enum
class NotObservableError(Exception):
def __init__(self, obj):
super().__init__(f'{obj} is not observable')
class ObservableEvent(Enum):
AFTER_PROPERTY_CHANGE = "__*after_property_change*__"
def make_observable(obj):
if hasattr(obj, '_listeners'):
return obj # Already observable
@@ -24,6 +31,16 @@ def make_observable(obj):
for callback in self._listeners[name]:
res.append(callback(old_value, value))
setattr(self, '_return_values', res)
# Trigger AFTER_PROPERTY_CHANGE event
after_prop_changed_event = ObservableEvent.AFTER_PROPERTY_CHANGE.value
if after_prop_changed_event in self._listeners:
if "" in self._listeners[after_prop_changed_event]: # Trigger for all attributes
for callback in self._listeners[after_prop_changed_event][""]:
callback(name, old_value, value, res)
else:
for callback in self._listeners[after_prop_changed_event].get(name, []):
callback(name, old_value, value, res)
obj.__class__ = ObservableVersion
obj._listeners = {}
@@ -48,12 +65,48 @@ def bind(obj, attr_name, callback):
obj._listeners[attr_name].append(callback)
"""
Implementation of unbind() function for myutils.observable module.
def add_event_listener(event: ObservableEvent, obj, attr_name, callback):
if not hasattr(obj, '_listeners'):
raise NotObservableError(
f"Object must be made observable with make_observable() before binding"
)
event_name = event.value
if event_name not in obj._listeners:
obj._listeners[event_name] = {}
if attr_name not in obj._listeners[event_name]:
obj._listeners[event_name][attr_name] = []
obj._listeners[event_name][attr_name].append(callback)
This implementation is adapted to work with the existing make_observable()
and bind() functions that use the _listeners dictionary pattern.
"""
def remove_event_listener(event: ObservableEvent, obj, attr_name, callback):
if not hasattr(obj, '_listeners'):
raise NotObservableError(
f"Object must be made observable with make_observable() before binding"
)
event_name = event.value
if event_name not in obj._listeners:
return
if attr_name and attr_name not in obj._listeners[event_name]:
return
if not attr_name:
for attr in list(obj._listeners[event_name].keys()):
if callback:
obj._listeners[event_name][attr].remove(callback)
else:
del obj._listeners[event_name][attr]
else:
if callback:
obj._listeners[event_name][attr_name].remove(callback)
else:
del obj._listeners[event_name][attr_name]
# cleanup: remove empty attributes
for attr in list(obj._listeners[event_name].keys()):
if len(obj._listeners[event_name][attr]) == 0:
del obj._listeners[event_name][attr]
if len(obj._listeners[event_name]) == 0:
del obj._listeners[event_name]
def unbind(obj, attr_name, callback):

View File

@@ -1,7 +1,7 @@
import pytest
from myutils.observable import NotObservableError, make_observable, bind, collect_return_values, unbind, unbind_all, \
has_listeners, get_listener_count
has_listeners, get_listener_count, add_event_listener, ObservableEvent, remove_event_listener
# Test fixtures
@@ -552,6 +552,337 @@ def test_i_cannot_get_listener_count_on_non_observable_object(data):
get_listener_count(data, "value")
# ============================================================================
# add_event_listener() tests
# ============================================================================
def test_i_can_add_an_event_listener(observable_data):
"""
add_event_listener() should correctly add a listener for a specific event and attribute.
"""
def callback(attr, old, new, results):
pass
add_event_listener(
ObservableEvent.AFTER_PROPERTY_CHANGE, observable_data, "value", callback
)
# Check that the listener has been added
event_name = ObservableEvent.AFTER_PROPERTY_CHANGE.value
assert event_name in observable_data._listeners
assert "value" in observable_data._listeners[event_name]
assert callback in observable_data._listeners[event_name]["value"]
def test_i_can_add_multiple_event_listeners_for_same_attribute(observable_data):
"""
Multiple event listeners can be added to the same attribute and event.
"""
def callback1(attr, old, new, results):
pass
def callback2(attr, old, new, results):
pass
def callback3(attr, old, new, results):
pass
event = ObservableEvent.AFTER_PROPERTY_CHANGE
add_event_listener(event, observable_data, "value", callback1)
add_event_listener(event, observable_data, "value", callback2)
add_event_listener(event, observable_data, "value", callback3)
# Check that all listeners have been added
event_name = event.value
assert len(observable_data._listeners[event_name]["value"]) == 3
assert callback1 in observable_data._listeners[event_name]["value"]
assert callback2 in observable_data._listeners[event_name]["value"]
assert callback3 in observable_data._listeners[event_name]["value"]
def test_i_can_handle_duplicate_event_listeners(observable_data):
"""
The same callback can be added multiple times and should appear multiple times.
"""
def callback(attr, old, new, results):
pass
event = ObservableEvent.AFTER_PROPERTY_CHANGE
add_event_listener(event, observable_data, "value", callback)
add_event_listener(event, observable_data, "value", callback)
add_event_listener(event, observable_data, "value", callback)
# Check that the same callback appears 3 times
event_name = event.value
assert len(observable_data._listeners[event_name]["value"]) == 3
assert observable_data._listeners[event_name]["value"].count(callback) == 3
def test_i_cannot_add_listener_to_non_observable_object(data):
"""
Trying to add an event listener to a non-observable object should raise NotObservableError.
"""
def callback(attr, old, new, results):
pass
with pytest.raises(NotObservableError, match="must be made observable"):
add_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "value", callback)
# ============================================================================
# remove_event_listener() tests
# ============================================================================
def test_i_can_remove_an_event_listener(observable_data):
"""
remove_event_listener() should correctly remove a previously added listener.
"""
def callback(attr, old, new, results):
pass
event = ObservableEvent.AFTER_PROPERTY_CHANGE
# Add the listener
add_event_listener(event, observable_data, "value", callback)
event_name = event.value
assert callback in observable_data._listeners[event_name]["value"]
# Remove the listener
remove_event_listener(event, observable_data, "value", callback)
# Check that the listener has been removed and structure cleaned up
assert event_name not in observable_data._listeners
def test_i_can_remove_last_event_listener_and_clean_up_structure(observable_data):
"""
Removing the last listener should clean up the internal data structures.
"""
def callback(attr, old, new, results):
pass
event = ObservableEvent.AFTER_PROPERTY_CHANGE
event_name = event.value
add_event_listener(event, observable_data, "value", callback)
# Verify it was added
assert event_name in observable_data._listeners
assert "value" in observable_data._listeners[event_name]
# Remove it
remove_event_listener(event, observable_data, "value", callback)
# Verify cleanup: event should be removed from _listeners
assert event_name not in observable_data._listeners
def test_i_cannot_remove_listener_from_non_observable_object(data):
"""
Trying to remove an event listener from a non-observable object should raise NotObservableError.
"""
def callback(attr, old, new, results):
pass
with pytest.raises(NotObservableError, match="must be made observable"):
remove_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "value", callback)
def test_i_cannot_remove_non_existent_event_listener(observable_data):
"""
Trying to remove a non-existent listener should fail silently (no exception).
"""
def callback(attr, old, new, results):
pass
event = ObservableEvent.AFTER_PROPERTY_CHANGE
# Should not raise an exception
remove_event_listener(event, observable_data, "value", callback)
def test_remove_event_listener_does_not_affect_others(observable_data):
"""
Removing one listener should not affect other listeners on the same attribute.
"""
def callback1(attr, old, new, results):
pass
def callback2(attr, old, new, results):
pass
def callback3(attr, old, new, results):
pass
event = ObservableEvent.AFTER_PROPERTY_CHANGE
event_name = event.value
# Add three listeners
add_event_listener(event, observable_data, "value", callback1)
add_event_listener(event, observable_data, "value", callback2)
add_event_listener(event, observable_data, "value", callback3)
assert len(observable_data._listeners[event_name]["value"]) == 3
# Remove only the second one
remove_event_listener(event, observable_data, "value", callback2)
# Check that only callback2 was removed
assert len(observable_data._listeners[event_name]["value"]) == 2
assert callback1 in observable_data._listeners[event_name]["value"]
assert callback2 not in observable_data._listeners[event_name]["value"]
assert callback3 in observable_data._listeners[event_name]["value"]
# ============================================================================
# Integration tests for add/remove event listeners
# ============================================================================
def test_i_can_add_and_remove_event_listeners_sequentially(observable_data):
"""
Multiple add/remove cycles should work correctly.
"""
def callback(attr, old, new, results):
pass
event = ObservableEvent.AFTER_PROPERTY_CHANGE
event_name = event.value
# Cycle 1: Add and remove
add_event_listener(event, observable_data, "value", callback)
assert event_name in observable_data._listeners
remove_event_listener(event, observable_data, "value", callback)
assert event_name not in observable_data._listeners
# Cycle 2: Add and remove again
add_event_listener(event, observable_data, "value", callback)
assert event_name in observable_data._listeners
remove_event_listener(event, observable_data, "value", callback)
assert event_name not in observable_data._listeners
def test_listener_removal_on_multiple_event_types(observable_data):
"""
Listeners for different attributes should be independently manageable.
"""
def callback1(attr, old, new, results):
pass
def callback2(attr, old, new, results):
pass
event = ObservableEvent.AFTER_PROPERTY_CHANGE
event_name = event.value
# Add listeners to different attributes
add_event_listener(event, observable_data, "value", callback1)
add_event_listener(event, observable_data, "number", callback2)
assert "value" in observable_data._listeners[event_name]
assert "number" in observable_data._listeners[event_name]
# Remove listener from "value"
remove_event_listener(event, observable_data, "value", callback1)
# Check that "value" is removed but "number" remains
assert "value" not in observable_data._listeners[event_name]
assert "number" in observable_data._listeners[event_name]
assert callback2 in observable_data._listeners[event_name]["number"]
def test_i_can_remove_all_event_listener_when_attr_name_is_none(observable_data):
"""
When unbind_all() is called with attr_name=None, all listeners for all attributes
should be removed from the observable object.
"""
def callback1(attr, old, new, results):
pass
def callback2(attr, old, new, results):
pass
event = ObservableEvent.AFTER_PROPERTY_CHANGE
event_name = event.value
# Add listeners to different attributes
add_event_listener(event, observable_data, "value", callback1)
add_event_listener(event, observable_data, "value", callback2)
add_event_listener(event, observable_data, "number", callback1)
add_event_listener(event, observable_data, "number", callback2)
remove_event_listener(event, observable_data, None, callback1)
callbacks = observable_data._listeners[event_name]["value"] + observable_data._listeners[event_name]["number"]
assert callback1 not in callbacks
def test_i_can_remove_all_event_listener_when_callback_is_none(observable_data):
"""
When unbind_all() is called with attr_name=None, all listeners for all attributes
should be removed from the observable object.
"""
def callback1(attr, old, new, results):
pass
def callback2(attr, old, new, results):
pass
event = ObservableEvent.AFTER_PROPERTY_CHANGE
event_name = event.value
# Add listeners to different attributes
add_event_listener(event, observable_data, "value", callback1)
add_event_listener(event, observable_data, "value", callback2)
add_event_listener(event, observable_data, "number", callback1)
add_event_listener(event, observable_data, "number", callback2)
remove_event_listener(event, observable_data, "value", None)
assert "value" not in observable_data._listeners[event_name]
def test_i_can_remove_all_event_listener_when_attr_name_callback_are_none(observable_data):
"""
When unbind_all() is called with attr_name=None, all listeners for all attributes
should be removed from the observable object.
"""
def callback1(attr, old, new, results):
pass
def callback2(attr, old, new, results):
pass
event = ObservableEvent.AFTER_PROPERTY_CHANGE
event_name = event.value
# Add listeners to different attributes
add_event_listener(event, observable_data, "value", callback1)
add_event_listener(event, observable_data, "value", callback2)
add_event_listener(event, observable_data, "number", callback1)
add_event_listener(event, observable_data, "number", callback2)
remove_event_listener(event, observable_data, None, None)
assert "value" not in observable_data._listeners[event_name]
assert "number" not in observable_data._listeners[event_name]
# ============================================================================
# Integration tests
# ============================================================================
@@ -584,24 +915,133 @@ def test_multiple_bind_unbind_cycles(observable_data):
assert len(results) == 2
def test_same_callback_can_be_bound_to_multiple_attributes(observable_data):
"""
The same callback can be bound to multiple attributes and unbound independently.
"""
results = []
# ============================================================================
# After property changed event
# ============================================================================
def test_i_can_receive_changes_in_after_property_change_event(observable_data):
"""Default case : after_property_change event should be fired when property changes."""
data = Data()
make_observable(data)
def callback(old, new):
results.append((old, new))
on_change_results = []
on_after_change_results = []
bind(observable_data, "value", callback)
bind(observable_data, "count", callback)
def on_change(old, new):
on_change_results.append((old, new))
return new + 1
observable_data.value = "first"
observable_data.count = 1
assert len(results) == 2
def on_after_change(attr, old, new, results):
on_after_change_results.append((attr, old, new, results))
unbind(observable_data, "value", callback)
bind(data, 'number', on_change)
add_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "number", on_after_change)
observable_data.value = "second"
observable_data.count = 2
assert len(results) == 3 # Only count callback triggered
data.number = 5
data.number = 10
assert on_change_results == [(1, 5), (5, 10)]
assert on_after_change_results == [("number", 1, 5, [6]), ("number", 5, 10, [11])]
def test_i_can_receive_changes_when_multiple_properties_are_changed(observable_data):
"""When multiple properties are changed, after_property_change event fires when all properties change."""
data = Data()
make_observable(data)
def on_change_1(old, new):
return new
def on_change_2(old, new):
return new + 1
on_after_change_results = []
def on_after_change(attr, old, new, results):
on_after_change_results.append((attr, old, new, results))
bind(data, 'number', on_change_1)
bind(data, 'number', on_change_2)
add_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "number", on_after_change)
data.number = 5
data.number = 10
assert on_after_change_results == [("number", 1, 5, [5, 6]), ("number", 5, 10, [10, 11])]
def test_i_can_receive_changes_in_after_property_change_event_when_declared_first(observable_data):
"""after_property_change event should be fired even if defined before the bindings."""
data = Data()
make_observable(data)
def on_change(old, new):
return new + 1
on_after_change_results = []
def on_after_change(attr, old, new, results):
on_after_change_results.append((attr, old, new, results))
add_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "number", on_after_change)
bind(data, 'number', on_change)
data.number = 5
data.number = 10
assert on_after_change_results == [("number", 1, 5, [6]), ("number", 5, 10, [11])]
def test_i_can_receive_changes_in_after_property_change_event_for_requested_attribute(observable_data):
"""after_property_change event should be fired even if defined before the bindings."""
data = Data()
make_observable(data)
def on_change_1(old, new):
return new
def on_change_2(old, new):
return str(new) + "_1"
on_after_change_results = []
def on_after_change(attr, old, new, results):
on_after_change_results.append((attr, old, new, results))
add_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "number", on_after_change)
bind(data, 'number', on_change_1)
bind(data, 'number', on_change_2)
bind(data, 'value', on_change_1)
data.number = 5
data.value = "new value"
assert on_after_change_results == [("number", 1, 5, [5, "5_1"])]
def test_i_can_receive_changes_in_after_property_change_event_for_all_attributes(observable_data):
"""after_property_change event should be fired even if defined before the bindings."""
data = Data()
make_observable(data)
def on_change_1(old, new):
return new
def on_change_2(old, new):
return str(new) + "_1"
on_after_change_results = []
def on_after_change(attr, old, new, results):
on_after_change_results.append((attr, old, new, results))
add_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", on_after_change)
bind(data, 'number', on_change_1)
bind(data, 'number', on_change_2)
bind(data, 'value', on_change_1)
data.number = 5
data.value = "new value"
assert on_after_change_results == [("number", 1, 5, [5, "5_1"]), ("value", "initial", "new value", ["new value"])]