Skip to content
Draft
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
122 changes: 122 additions & 0 deletions docs/source/design/event_broker.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
EventBroker
===========

The ``EventBroker`` provides a lightweight publish/subscribe (pub-sub) mechanism for
decoupled event-driven communication between components. It acts as a central
dispatcher that routes events to registered subscribers based on event type.

Overview
--------

The broker is implemented as a singleton, so all parts of the application interact
with the same event registry. Components can:

- **Register** handlers for specific event types
- **Publish** events to notify all interested subscribers
- Rely on a simple recursion guard to prevent runaway event loops

This pattern is useful for cross-cutting concerns such as logging, state updates,
UI notifications, or domain events.

Basic Usage
-----------

Registering Subscribers
~~~~~~~~~~~~~~~~~~~~~~~

Subscribers are callables that accept a single event instance.

.. code-block:: python

from tavi.meta.event.event_interface import Event
from myapp.events import UserCreatedEvent
from myapp.event_broker import EventBroker

def on_user_created(event: UserCreatedEvent) -> None:
print(f"User created: {event.user_id}")

broker = EventBroker()
broker.register(UserCreatedEvent, on_user_created)

Publishing Events
~~~~~~~~~~~~~~~~~

When an event is published, all subscribers registered for that event type are invoked.

.. code-block:: python

event = UserCreatedEvent(user_id="123")
broker.publish(event)

Each subscriber receives a **deep copy** of the event instance. This prevents
subscribers from mutating shared state and affecting other listeners.

Event Dispatch Semantics
------------------------

- **Dispatch is synchronous**: subscribers are called in the order they were registered.
- **Dispatch is type-based**: only subscribers registered for the exact event class
(``type(event)``) are invoked.
- **Event instances are copied**: each subscriber receives an isolated event object.

Recursion Guard
---------------

The broker enforces a maximum call depth to prevent infinite or runaway recursion
when events trigger other events during handling.

If the maximum depth is exceeded, a ``RuntimeError`` is raised:

.. code-block:: python

RuntimeError: Event recursive depth of 1 has been exceeded.

This protects against patterns like:

- A handler publishing the same event type it is subscribed to
- Circular event chains between handlers

If deeper event chaining is required, the maximum depth can be increased:

.. code-block:: python

broker = EventBroker()
broker.call_depth_max = 3

Recommended Practices
---------------------

- **Keep handlers small and side-effect focused**
Event handlers should perform limited, well-defined actions and avoid complex control flow.

- **Avoid cyclic event dependencies**
Design event flows to be acyclic where possible. The recursion guard is a safety net,
not a control mechanism.

- **Prefer domain-specific events**
Use narrowly scoped event types (e.g., ``UserCreatedEvent`` instead of a generic
``UserEvent``) to keep subscriptions explicit and predictable.

- **Do not mutate incoming events**
Although handlers receive copies, treat events as immutable to preserve intent
and make behavior easier to reason about.

Typical Use Cases
-----------------

- Emitting domain events from application services
- Triggering side effects such as logging, metrics, or notifications
- Decoupling UI updates from core business logic
- Broadcasting lifecycle events (startup, shutdown, state changes)

Limitations
-----------

- No built-in support for asynchronous handlers
- No wildcard or base-class subscriptions (exact type matching only)
- No unregistration mechanism for subscribers
- No event classification system. It does not validate that a subscriber *should* receive a specific event class. (Model vs Presenter)
- Global singleton scope may be undesirable in some testing or multi-tenant contexts

For more complex workflows (async dispatch, filtering, prioritization, or scoped
brokers), consider layering a more advanced event bus on top of this interface.
1 change: 1 addition & 0 deletions src/tavi/backend/model/tavi_project_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def send(self, event: Event) -> None:
def load_raw_scan_from_folder(self, folder: str) -> None:
"""Load a folder containing raw scans."""
print("folder director received by model:", folder)
raise RuntimeError("test exception")
# TO DO
# Implement load raw scan from folder logic
# raw_scan_loading_event = RawScanLoadingEvent(raw_scan_uuid = ...)
Expand Down
32 changes: 32 additions & 0 deletions src/tavi/frontend/presenter/error_presenter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Presenter the orchestrates what frontend components to show when an error occurs."""

from qtpy.QtCore import QObject, Signal

from tavi.frontend.view.error_view import ErrorView
from tavi.frontend.widget.tavi_message_box import TaviMessageBox
from tavi.meta.exception.nonrecoverable.base import NonRecoverableError
from tavi.meta.exception.recovery_service import RecoveryService


class ErrorPresenter(QObject):
"""Presenter the orchestrates what frontend components to show when an error occurs."""

nonrecoverable_signal = Signal(NonRecoverableError)

def __init__(self) -> None:
"""Initialise the error presenter."""
super().__init__()
self.recovery_service = RecoveryService()

self.nonrecoverable_signal.connect(self._handle_nonrecoverable_exception)

self.recovery_service.register(NonRecoverableError, self.handle_nonrecoverable_exception)
self.view = ErrorView()

def handle_nonrecoverable_exception(self, ex: NonRecoverableError) -> None:
"""Emit signal to handle non-recoverable exception."""
self.nonrecoverable_signal.emit(ex)

def _handle_nonrecoverable_exception(self, ex: NonRecoverableError) -> None:
"""Handle non-recoverable exception."""
TaviMessageBox.critical(self.view, "Error", str(ex))
5 changes: 5 additions & 0 deletions src/tavi/frontend/presenter/main_presenter.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Main presenter for tavi."""

from tavi.frontend.presenter.error_presenter import ErrorPresenter
from tavi.frontend.presenter.file_menu_presenter import FileMenuPresenter
from tavi.frontend.presenter.load_raw_scan_presenter import LoadRawScanPresenter
from tavi.frontend.view.main_view import TaviView
Expand All @@ -20,6 +21,10 @@ def __init__(self, model_dict: dict) -> None:
self.load_raw_scan_view = self._view.main_window.load_view
self.load_raw_scan_presenter = LoadRawScanPresenter(self.load_raw_scan_view, model_dict["TaviProjectProxy"])

self.error_presenter = ErrorPresenter()
self.error_view = self.error_presenter.view
# self.error_view.setParent(self._view)

def exit(self) -> bool:
"""
Presenter handles dirty-save confirmation.
Expand Down
9 changes: 9 additions & 0 deletions src/tavi/frontend/view/error_view.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""View to show when an error occurs."""

from qtpy.QtWidgets import QWidget


class ErrorView(QWidget):
"""Placeholder view for when an error occurs."""

pass
22 changes: 22 additions & 0 deletions src/tavi/frontend/widget/tavi_message_box.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""MessageBox wrapper for tavi."""

from qtpy.QtWidgets import QMessageBox, QWidget


class TaviMessageBox(QMessageBox):
"""MessageBox wrapper for tavi."""

def __init__(self, title: str, message: str, parent: QWidget = None) -> None:
"""Initialise the message box."""
super().__init__(parent)
self.setWindowTitle(title)
self.setText(message)

@staticmethod
def critical(parent: QWidget, title: str, message: str) -> TaviMessageBox:
"""Spawn a critical message box."""
mb = TaviMessageBox(title, message, parent)
mb.setIcon(QMessageBox.Icon.Critical)
mb.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Close)
mb.setDefaultButton(QMessageBox.StandardButton.Ok)
return mb.exec()
37 changes: 37 additions & 0 deletions src/tavi/meta/event/event_broker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Event Broker Module."""

from typing import Callable, Type, TypeVar

from tavi.meta.decorators.singleton import Singleton
from tavi.meta.event.event_interface import Event

T = TypeVar("T", bound=Event)


@Singleton
class EventBroker:
"""Handles event communication between objects."""

def __init__(self) -> None:
"""Initialize the EventBroker."""
self.registry: dict[Type[T], list[Callable]] = {}
self.call_depth = 0
self.call_depth_max = 1

def register(self, event_type: Type[T], callable: Callable) -> None:
"""Register a subscriber to receive published events."""
subscribers = self.registry.get(event_type, [])
subscribers.append(callable)
self.registry[event_type] = subscribers

def publish(self, event: Event) -> None:
"""Publish an event to subscribers."""
if self.call_depth >= self.call_depth_max:
raise RuntimeError(f"Event recursive depth of {self.call_depth_max} has been exceeded.")

event_type = type(event)
if callable_list := self.registry.get(event_type):
for callable in callable_list:
self.call_depth += 1
callable(event.model_copy(deep=True))
self.call_depth -= 1
9 changes: 9 additions & 0 deletions src/tavi/meta/event/event_interface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""The Event Module."""

from pydantic import BaseModel, ConfigDict


class Event(BaseModel):
"""The Base class for all Events sent through the EventBroker."""

model_config: ConfigDict = ConfigDict(arbitrary_types_allowed=True)
10 changes: 10 additions & 0 deletions src/tavi/meta/event/type/exception_event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Event emitted when an exception is raised."""

from tavi.meta.event.event_interface import Event
from tavi.meta.exception.tavi_exception import TaviError


class ExceptionEvent(Event):
"""Event to be emitted and handled by the recovery service."""

e: TaviError
9 changes: 9 additions & 0 deletions src/tavi/meta/event/type/new_raw_scan_event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""New Raw Scan Event module."""

from tavi.meta.event.event_interface import Event


class NewRawScanEvent(Event):
"""Indicates a new RawScan has been added to the Project."""

uuid: str
9 changes: 9 additions & 0 deletions src/tavi/meta/exception/nonrecoverable/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Base class for non-recoverable exceptions."""

from tavi.meta.exception.tavi_exception import TaviError


class NonRecoverableError(TaviError):
"""Type that all non-recoverable exceptions must inherit."""

pass
9 changes: 9 additions & 0 deletions src/tavi/meta/exception/recoverable/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Base class for recoverable exceptions."""

from tavi.meta.exception.tavi_exception import TaviError


class RecoverableError(TaviError):
"""Type that all recoverable exceptions must inherit."""

pass
36 changes: 36 additions & 0 deletions src/tavi/meta/exception/recovery_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Service to recover from exceptions."""

from typing import Callable, TypeVar

from tavi.meta.decorators.singleton import Singleton
from tavi.meta.event.event_broker import EventBroker
from tavi.meta.event.type.exception_event import ExceptionEvent
from tavi.meta.exception.tavi_exception import TaviError

T = TypeVar("T", bound=TaviError)


@Singleton
class RecoveryService:
"""Service to recover from exceptions."""

def __init__(self) -> None:
"""Initialise the recovery service."""
self.event_broker: EventBroker = EventBroker()
self.exception_handlers: dict[T, Callable] = {}

self.event_broker.register(ExceptionEvent, self.handle_exception)

def register(self, ex_type: T, callable: Callable) -> None:
"""Register a handler for an exception type."""
self.exception_handlers[ex_type] = callable

def handle_exception(self, event: ExceptionEvent) -> None:
"""Direct exception to the correct handler."""
ex: TaviError = event.e
handler: Callable = self.exception_handlers.get(type(ex), self.default_handler)
handler(ex)

def default_handler(self, ex: TaviError) -> None:
"""Handle exception with no registered handler."""
raise RuntimeError(f"FATAL: no handler for exception found {ex}")
7 changes: 7 additions & 0 deletions src/tavi/meta/exception/tavi_exception.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Class each tavi base exception must inherit."""


class TaviError(Exception):
"""Base exception for tavi."""

pass
6 changes: 6 additions & 0 deletions src/tavi/meta/multithreading/worker_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

from tavi.library.data.model_response import ModelResponse, ResponseCode
from tavi.meta.decorators.singleton import Singleton
from tavi.meta.event.event_broker import EventBroker
from tavi.meta.event.type.exception_event import ExceptionEvent
from tavi.meta.exception.nonrecoverable.base import NonRecoverableError
from tavi.meta.multithreading.signal import Signal

# logger = taviLogger.getLogger(__name__)
Expand All @@ -23,6 +26,7 @@ def __init__(self, loop: asyncio.AbstractEventLoop, target: Callable, *args: Any
self.target = target
self.args = args
self.kwargs = kwargs
self.event_broker = EventBroker()
self.finished: Signal = Signal(loop) # None

def bindSignals(self) -> None:
Expand All @@ -45,6 +49,8 @@ def run(self) -> None:
# traceback.print_exc()

results = ModelResponse(code=ResponseCode.ERROR, message=str(e))
self.event_broker.publish(ExceptionEvent(e=NonRecoverableError(str(e))))

self.finished.emit()
if not isinstance(results, ModelResponse):
raise ValueError("Worker target must return a ModelResponse object.")
Expand Down
Loading