From af477c29485ee7b2d4d380753d9846b7d93c19c5 Mon Sep 17 00:00:00 2001 From: Asif Tamuri Date: Mon, 24 Nov 2025 09:51:58 +0000 Subject: [PATCH 1/5] Add a very simple synchronous notification dispatcher - avoided using the more typical naming `event` or `signal` because they are already used. --- src/tlo/notify.py | 64 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_notify.py | 23 ++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 src/tlo/notify.py create mode 100644 tests/test_notify.py diff --git a/src/tlo/notify.py b/src/tlo/notify.py new file mode 100644 index 0000000000..28765f5afd --- /dev/null +++ b/src/tlo/notify.py @@ -0,0 +1,64 @@ +""" +A dead simple synchronous notification dispatcher. + +Usage +----- +# In the notifying class/module +from tlo.notify import notifier + +notifier.dispatch("simulation.on_start", data={"one": 1, "two": 2}) + +# In the listening class/module +from tlo.notify import notifier + +def on_notification(data): + print("Received notification:", data) + +notifier.add_listener("simulation.on_start", on_notification) +""" + +class Notifier: + """ + A simple synchronous notification dispatcher supporting listeners. + """ + + def __init__(self): + self.listeners = {} + + def add_listener(self, notification_key, listener): + """ + Register a listener for a specific notification. + + :param notification_key: The identifier to listen for. + :param listener: A callable to be invoked when the notification is dispatched. + """ + if notification_key not in self.listeners: + self.listeners[notification_key] = [] + self.listeners[notification_key].append(listener) + + def remove_listener(self, notification_key, listener): + """ + Remove a previously registered listener for a notification. + + :param notification_key: The identifier. + :param listener: The listener callable to remove. + """ + if notification_key in self.listeners: + self.listeners[notification_key].remove(listener) + if not self.listeners[notification_key]: + del self.listeners[notification_key] + + def dispatch(self, notification_key, data=None): + """ + Dispatch a notification to all registered listeners. + + :param notification_key: The identifier. + :param data: Optional data to pass to each listener. + """ + if notification_key in self.listeners: + for listener in list(self.listeners[notification_key]): + listener(data) + +# Create a global dispatcher instance +notifier = Notifier() + diff --git a/tests/test_notify.py b/tests/test_notify.py new file mode 100644 index 0000000000..e71e2acb9a --- /dev/null +++ b/tests/test_notify.py @@ -0,0 +1,23 @@ +from tlo.notify import notifier + +def test_notifier(): + # in listening code + received_data = [] + + def callback(data): + received_data.append(data) + + notifier.add_listener("test.signal", callback) + + # in emitting code + notifier.dispatch("test.signal", data={"value": 42}) + + assert len(received_data) == 1 + assert received_data[0] == {"value": 42} + + # Unsubscribe and test no further calls + notifier.remove_listener("test.signal", callback) + notifier.dispatch("test.signal", data={"value": 100}) + + assert len(received_data) == 1 # No new data + From 01e35d0079877dd7d12cdbd2cb6f7b285fef863f Mon Sep 17 00:00:00 2001 From: Asif Tamuri Date: Mon, 24 Nov 2025 10:02:59 +0000 Subject: [PATCH 2/5] Fix comment --- src/tlo/notify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tlo/notify.py b/src/tlo/notify.py index 28765f5afd..325131a1c7 100644 --- a/src/tlo/notify.py +++ b/src/tlo/notify.py @@ -59,6 +59,6 @@ def dispatch(self, notification_key, data=None): for listener in list(self.listeners[notification_key]): listener(data) -# Create a global dispatcher instance +# Create a global notifier instance notifier = Notifier() From 9f23fcbeb46e2af5b6a1c6334aa579574ec18b66 Mon Sep 17 00:00:00 2001 From: Asif Tamuri Date: Mon, 24 Nov 2025 10:23:00 +0000 Subject: [PATCH 3/5] Fix formatting --- src/tlo/notify.py | 3 ++- tests/test_notify.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/tlo/notify.py b/src/tlo/notify.py index 325131a1c7..2906fa712a 100644 --- a/src/tlo/notify.py +++ b/src/tlo/notify.py @@ -17,6 +17,7 @@ def on_notification(data): notifier.add_listener("simulation.on_start", on_notification) """ + class Notifier: """ A simple synchronous notification dispatcher supporting listeners. @@ -59,6 +60,6 @@ def dispatch(self, notification_key, data=None): for listener in list(self.listeners[notification_key]): listener(data) + # Create a global notifier instance notifier = Notifier() - diff --git a/tests/test_notify.py b/tests/test_notify.py index e71e2acb9a..ad5e828bbf 100644 --- a/tests/test_notify.py +++ b/tests/test_notify.py @@ -1,5 +1,6 @@ from tlo.notify import notifier + def test_notifier(): # in listening code received_data = [] @@ -20,4 +21,3 @@ def callback(data): notifier.dispatch("test.signal", data={"value": 100}) assert len(received_data) == 1 # No new data - From 5ff53bb7e104e46969199dbfefc15e3fccc02eec Mon Sep 17 00:00:00 2001 From: Asif Tamuri Date: Mon, 24 Nov 2025 12:23:49 +0000 Subject: [PATCH 4/5] Remove unnecessary list wrap --- src/tlo/notify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tlo/notify.py b/src/tlo/notify.py index 2906fa712a..48c46b82b4 100644 --- a/src/tlo/notify.py +++ b/src/tlo/notify.py @@ -57,7 +57,7 @@ def dispatch(self, notification_key, data=None): :param data: Optional data to pass to each listener. """ if notification_key in self.listeners: - for listener in list(self.listeners[notification_key]): + for listener in self.listeners[notification_key]: listener(data) From e617aa9a1885a260c28dfc47db5c72cac09fdcdd Mon Sep 17 00:00:00 2001 From: Asif Tamuri Date: Tue, 25 Nov 2025 13:39:35 +0000 Subject: [PATCH 5/5] Clear listeners in the global notifier instance at the start of simulation --- src/tlo/notify.py | 7 +++++++ src/tlo/simulation.py | 3 +++ 2 files changed, 10 insertions(+) diff --git a/src/tlo/notify.py b/src/tlo/notify.py index 48c46b82b4..b1b4434ba9 100644 --- a/src/tlo/notify.py +++ b/src/tlo/notify.py @@ -60,6 +60,13 @@ def dispatch(self, notification_key, data=None): for listener in self.listeners[notification_key]: listener(data) + def clear_listeners(self): + """ + Clear all registered listeners. Essential because the notifier is a global singleton. + e.g. if you are running multiple tests or simulations in the same process. + """ + self.listeners.clear() + # Create a global notifier instance notifier = Notifier() diff --git a/src/tlo/simulation.py b/src/tlo/simulation.py index d2560f92d9..b0bd733234 100644 --- a/src/tlo/simulation.py +++ b/src/tlo/simulation.py @@ -26,6 +26,7 @@ topologically_sort_modules, ) from tlo.events import Event, IndividualScopeEventMixin +from tlo.notify import notifier from tlo.progressbar import ProgressBar if TYPE_CHECKING: @@ -116,6 +117,8 @@ def __init__( self._custom_log_levels = None self._log_filepath = self._configure_logging(**log_config) + # clear notifier listeners from any previous simulation in this process + notifier.clear_listeners() # random number generator seed_from = "auto" if seed is None else "user"