From b4cc6cd66b6ad44ed829f0bb58c86b5f2bd2f562 Mon Sep 17 00:00:00 2001 From: HAL9000 Date: Sat, 16 May 2026 09:04:50 +0000 Subject: [PATCH] fix(events): add unsubscribe() to EventBus protocol and implementations (#10356) Add EventBus.unsubscribe(event_type, handler) -> bool method to the EventBus structural Protocol and both concrete implementations (ReactiveEventBus and LoggingEventBus). Returns True if a matching handler was found and removed, False otherwise. Resolves #10356 --- features/steps/event_bus_steps.py | 1 + .../infrastructure/events/logging_bus.py | 33 +++++++++++++++++ .../infrastructure/events/protocol.py | 21 +++++++++++ .../infrastructure/events/reactive.py | 35 +++++++++++++++++++ 4 files changed, 90 insertions(+) diff --git a/features/steps/event_bus_steps.py b/features/steps/event_bus_steps.py index a414e2ed2..63d2d7e82 100644 --- a/features/steps/event_bus_steps.py +++ b/features/steps/event_bus_steps.py @@ -267,6 +267,7 @@ def step_protocol_stubs_callable(ctx: Context) -> None: event = _make_event("plan.created") obj.emit(event) obj.subscribe(EventType.PLAN_CREATED, lambda e: None) + obj.unsubscribe(EventType.PLAN_CREATED, lambda e: None) # --------------------------------------------------------------------------- diff --git a/src/cleveragents/infrastructure/events/logging_bus.py b/src/cleveragents/infrastructure/events/logging_bus.py index 307bf4ed3..316cca351 100644 --- a/src/cleveragents/infrastructure/events/logging_bus.py +++ b/src/cleveragents/infrastructure/events/logging_bus.py @@ -105,5 +105,38 @@ class LoggingEventBus: raise TypeError("handler must be callable") self._subscriptions.setdefault(event_type, []).append(handler) + def unsubscribe( + self, + event_type: EventType, + handler: Callable[[DomainEvent], None], + ) -> bool: + """Remove *handler* from events of *event_type*. + + Removes only the **first** occurrence of *handler* (in case it was + registered multiple times). Returns ``True`` if a matching handler + was found and removed, ``False`` otherwise. + + Args: + event_type: The :class:`EventType` whose handler list to modify. + handler: The previously-registered callable to remove. + + Returns: + ``True`` if a handler was removed; ``False`` if no match found. + """ + if not isinstance(event_type, EventType): + raise TypeError( + f"event_type must be an EventType, got {type(event_type).__name__!r}" + ) + if not callable(handler): + raise TypeError("handler must be callable") + handlers = self._subscriptions.get(event_type) + if handlers is None: + return False + try: + handlers.remove(handler) + return True + except ValueError: + return False + __all__ = ["LoggingEventBus"] diff --git a/src/cleveragents/infrastructure/events/protocol.py b/src/cleveragents/infrastructure/events/protocol.py index 2c3cae85f..f0bb251eb 100644 --- a/src/cleveragents/infrastructure/events/protocol.py +++ b/src/cleveragents/infrastructure/events/protocol.py @@ -49,5 +49,26 @@ class EventBus(Protocol): """ ... + def unsubscribe( + self, + event_type: EventType, + handler: Callable[[DomainEvent], None], + ) -> bool: + """Remove *handler* from the subscriber list for *event_type*. + + Removes only the **first** occurrence of *handler* in the list for + *event_type* (in case it was registered multiple times). Returns + ``True`` if a matching handler was found and removed, ``False`` + otherwise. + + Args: + event_type: The :class:`EventType` whose handler list to modify. + handler: The previously-registered callable to remove. + + Returns: + ``True`` if a handler was removed; ``False`` if no match found. + """ + ... + __all__ = ["EventBus"] diff --git a/src/cleveragents/infrastructure/events/reactive.py b/src/cleveragents/infrastructure/events/reactive.py index ec7c63194..e829f93f3 100644 --- a/src/cleveragents/infrastructure/events/reactive.py +++ b/src/cleveragents/infrastructure/events/reactive.py @@ -184,6 +184,41 @@ class ReactiveEventBus: """ return self._stream + def unsubscribe( + self, + event_type: EventType, + handler: Callable[[DomainEvent], None], + ) -> bool: + """Remove *handler* from events of *event_type*. + + Removes only the **first** occurrence of *handler* (in case it was + registered multiple times). Returns ``True`` if a matching handler + was found and removed, ``False`` otherwise. + + Args: + event_type: The :class:`EventType` whose handler list to modify. + handler: The previously-registered callable to remove. + + Returns: + ``True`` if a handler was removed; ``False`` if no match found. + """ + if not isinstance(event_type, EventType): + raise TypeError( + f"event_type must be an EventType, got {type(event_type).__name__!r}" + ) + if not callable(handler): + raise TypeError("handler must be callable") + handlers = self._subscriptions.get(event_type) + if handlers is None: + return False + try: + handlers.remove(handler) + return True + except ValueError: + # Handler was not found in list (duplicates registered but + # this specific reference is not present). + return False + def close(self) -> None: """Complete the RxPY stream and prevent further event emission.