diff --git a/README.md b/README.md index 9e57dff..a06a95f 100644 --- a/README.md +++ b/README.md @@ -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 \ No newline at end of file +* 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` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4600384..570c7f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/src/myutils/observable.py b/src/myutils/observable.py index 5a67e10..c1af7d2 100644 --- a/src/myutils/observable.py +++ b/src/myutils/observable.py @@ -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): diff --git a/tests/test_observable.py b/tests/test_observable.py index 25dd9dd..cb565f5 100644 --- a/tests/test_observable.py +++ b/tests/test_observable.py @@ -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"])]