diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 638b8d0..4e5046c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,8 +3,10 @@ "allow": [ "Bash(python -m pytest tests/test_eventbus.py::TestEventTypeOverride -xvs)", "Bash(python -m pytest tests/test_eventbus.py::TestEventTypeOverride::test_automatic_event_type_derivation -xvs)", - "Bash(python -m pytest tests/test_eventbus.py -x)" + "Bash(python -m pytest tests/test_eventbus.py -x)", + "Bash(pytest:*)", + "Bash(rg:*)" ], "deny": [] } -} +} \ No newline at end of file diff --git a/bubus/models.py b/bubus/models.py index d4165ba..d1f23ae 100644 --- a/bubus/models.py +++ b/bubus/models.py @@ -1,19 +1,27 @@ +from __future__ import annotations + import asyncio +import inspect import logging import os from collections.abc import Awaitable, Callable, Generator from datetime import UTC, datetime -from typing import TYPE_CHECKING, Annotated, Any, Self +from typing import TYPE_CHECKING, Annotated, Any, Coroutine, Literal, Self, TypeAlias from uuid import UUID from pydantic import AfterValidator, BaseModel, ConfigDict, Field, PrivateAttr, model_validator -from uuid_extensions import uuid7str # type: ignore +from uuid_extensions import uuid7str if TYPE_CHECKING: from bubus.service import EventBus -logger = logging.getLogger(__name__) +logger = logging.getLogger('bubus') + +BUBUS_LOG_LEVEL = os.getenv('BUBUS_LOG_LEVEL', 'WARNING') +LIBRARY_VERSION = os.getenv('LIBRARY_VERSION', '1.0.0') + +logger.setLevel(BUBUS_LOG_LEVEL) def validate_event_name(s: str) -> str: @@ -31,10 +39,19 @@ def validate_uuid_str(s: str) -> str: return str(uuid) -UUIDStr = Annotated[str, AfterValidator(validate_uuid_str)] -PythonIdStr = Annotated[str, AfterValidator(validate_python_id_str)] -PythonIdentifierStr = Annotated[str, AfterValidator(validate_event_name)] -EventHandler = Callable[['BaseEvent'], Any] | Callable[['BaseEvent'], Awaitable[Any]] +UUIDStr: TypeAlias = Annotated[str, AfterValidator(validate_uuid_str)] +PythonIdStr: TypeAlias = Annotated[str, AfterValidator(validate_python_id_str)] +PythonIdentifierStr: TypeAlias = Annotated[str, AfterValidator(validate_event_name)] +EventHandler: TypeAlias = Callable[['BaseEvent'], Any] | Callable[['BaseEvent'], Awaitable[Any]] | Coroutine[Any, Any, Any] + + +def get_handler_name(handler: EventHandler) -> str: + if inspect.ismethod(handler): + return f'{handler.__self__}.{handler.__name__}' + elif callable(handler): + return f'{handler.__module__}.{handler.__name__}' + else: + raise ValueError(f'Invalid handler: {handler} {type(handler)}, expected a function, coroutine, or method') class BaseEvent(BaseModel): @@ -42,11 +59,21 @@ class BaseEvent(BaseModel): The base model used for all Events that flow through the EventBus system. """ - model_config = ConfigDict(extra='allow', arbitrary_types_allowed=True, validate_assignment=True, validate_default=True) + model_config = ConfigDict( + extra='allow', + arbitrary_types_allowed=True, + validate_assignment=True, + validate_default=True, + revalidate_instances='always', + ) - event_type: PythonIdentifierStr | None = Field(default=None, description='Event type name') - event_schema: str | None = Field(default=None, description='Event schema version in format ClassName@version', max_length=250) - event_timeout: float | None = Field(default=60.0, description='Timeout in seconds for event to complete') + event_type: PythonIdentifierStr = Field(default='UndefinedEvent', description='Event type name', max_length=64) + event_schema: str = Field( + default=f'UndefinedEvent@{LIBRARY_VERSION}', + description='Event schema version in format ClassName@version', + max_length=250, + ) # long because it can include long function names / module paths + event_timeout: float | None = Field(default=300.0, description='Timeout in seconds for event to finish processing') # Runtime metadata event_id: UUIDStr = Field(default_factory=uuid7str, max_length=36) @@ -66,10 +93,15 @@ class BaseEvent(BaseModel): ) # Results indexed by str(id(handler_func)) # Completion signal - _event_completed: asyncio.Event | None = PrivateAttr(default=None) + _event_completed_signal: asyncio.Event | None = PrivateAttr(default=None) _event_processed_at: datetime | None = PrivateAttr(default=None) + def __hash__(self) -> int: + """Make events hashable using their unique event_id""" + return hash(self.event_id) + def __str__(self) -> str: + """BaseEvent#ab12⏳""" icon = ( '⏳' if self.event_status == 'pending' @@ -81,18 +113,15 @@ def __str__(self) -> str: ) return f'{self.__class__.__name__}#{self.event_id[-4:]}{icon}{">".join(self.event_path[1:])})' - def _log_safe_summary(self) -> dict[str, Any]: - """only event metadata without contents, avoid potentially sensitive event contents in logs""" - return {k: v for k, v in self.model_dump(mode='json').items() if k.startswith('event_') and 'results' not in k} - def __await__(self) -> Generator[Self, Any, Any]: """Wait for event to complete and return self""" + # long descriptive name here really helps make traceback easier to follow async def wait_for_handlers_to_complete_then_return_event(): - assert self.event_completed is not None + assert self.event_completed_signal is not None try: - await asyncio.wait_for(self.event_completed.wait(), timeout=self.event_timeout) + await asyncio.wait_for(self.event_completed_signal.wait(), timeout=self.event_timeout) except TimeoutError: raise RuntimeError( f'{self} waiting for results timed out after {self.event_timeout}s (being processed by {len(self.event_results)} handlers)' @@ -102,20 +131,40 @@ async def wait_for_handlers_to_complete_then_return_event(): return wait_for_handlers_to_complete_then_return_event().__await__() - def __hash__(self) -> int: - """Make events hashable using their unique event_id""" - return hash(self.event_id) + @model_validator(mode='before') + @classmethod + def _set_event_type_from_class_name(cls, data: dict[str, Any]) -> dict[str, Any]: + """Automatically set event_type to the class name if not provided""" + is_class_default_unchanged = cls.model_fields['event_type'].default == 'UndefinedEvent' + is_event_type_not_provided = 'event_type' not in data or data['event_type'] == 'UndefinedEvent' + if is_class_default_unchanged and is_event_type_not_provided: + data['event_type'] = cls.__name__ + return data + + @model_validator(mode='before') + @classmethod + def _set_event_schema_from_class_name(cls, data: dict[str, Any]) -> dict[str, Any]: + """Append the library version number to the event schema so we know what version was used to create any JSON dump""" + is_class_default_unchanged = cls.model_fields['event_schema'].default == f'UndefinedEvent@{LIBRARY_VERSION}' + is_event_schema_not_provided = 'event_schema' not in data or data['event_schema'] == f'UndefinedEvent@{LIBRARY_VERSION}' + if is_class_default_unchanged and is_event_schema_not_provided: + data['event_schema'] = f'{cls.__module__}.{cls.__qualname__}@{LIBRARY_VERSION}' + return data @property - def event_completed(self) -> asyncio.Event | None: + def event_completed_signal(self) -> asyncio.Event | None: """Lazily create asyncio.Event when accessed""" - if self._event_completed is None: + if self._event_completed_signal is None: try: asyncio.get_running_loop() - self._event_completed = asyncio.Event() + self._event_completed_signal = asyncio.Event() except RuntimeError: pass # Keep it None if no event loop - return self._event_completed + return self._event_completed_signal + + @property + def event_status(self) -> str: + return 'completed' if self.event_completed_at else 'started' if self.event_started_at else 'pending' @property def event_started_at(self) -> datetime | None: @@ -142,49 +191,112 @@ def event_completed_at(self) -> datetime | None: completed_times = [result.completed_at for result in self.event_results.values() if result.completed_at is not None] return max(completed_times) if completed_times else self._event_processed_at - @property - def event_status(self) -> str: - return 'completed' if self.event_completed_at else 'started' if self.event_started_at else 'pending' + async def event_result(self, timeout: float | None = None) -> Any: + """Get the first non-None result from the event handlers""" + results = await self.event_results_list(timeout=timeout) + results = list(filter(lambda r: r is not None, results)) + return results[0] if results else None - @model_validator(mode='before') - @classmethod - def _set_event_type_from_class_name(cls, data: dict[str, Any]) -> dict[str, Any]: - """Automatically set event_type to the class name if not provided""" - if isinstance(data, dict): # pyright: ignore[reportUnnecessaryIsInstance] - # Only set event_type if it's not in the data dict AND there's no default - if 'event_type' not in data: - # Check if there's a field default - field_info: Any | None = cls.model_fields.get('event_type') - if field_info and field_info.default is None: - data['event_type'] = cls.__name__ - return data + async def event_results_by_handler_id(self, timeout: float | None = None) -> dict[PythonIdStr, Any]: + """Get all raw result values organized by handler id {handler1_id: handler1_result, handler2_id: handler2_result, ...}""" + assert self.event_completed_signal is not None, 'EventResult cannot be awaited outside of an async context' - def model_post_init(self, __context: Any) -> None: - """Append the library version number to the event schema so we know what version was used to create any JSON dump""" - if self.event_schema is None: - version = os.getenv('LIBRARY_VERSION', '1.0.0') - self.event_schema = f'{self.__class__.__module__}.{self.__class__.__qualname__}@{version}' + await asyncio.wait_for(self.event_completed_signal.wait(), timeout=timeout or self.event_timeout) + + return {handler_id: (await event_result) for handler_id, event_result in self.event_results.items()} + + async def event_results_by_handler_name(self, timeout: float | None = None) -> dict[PythonIdentifierStr, Any]: + """Get all raw result values organized by handler name {handler1_name: handler1_result, handler2_name: handler2_result, ...}""" + assert self.event_completed_signal is not None, 'EventResult cannot be awaited outside of an async context' + + await asyncio.wait_for(self.event_completed_signal.wait(), timeout=timeout or self.event_timeout) + + return {event_result.handler_name: (await event_result) for event_result in self.event_results.values()} + + async def event_results_list(self, timeout: float | None = None) -> list[Any]: + """Get all result values in a list [handler1_result, handler2_result, ...]""" + assert self.event_completed_signal is not None, 'EventResult cannot be awaited outside of an async context' + + await asyncio.wait_for(self.event_completed_signal.wait(), timeout=timeout or self.event_timeout) + + return [(await event_result) for event_result in self.event_results.values()] + + async def event_results_flat_dict(self, timeout: float | None = None) -> dict[Any, Any]: + """Assuming all handlers return dicts, merge all the returned dicts into a single flat dict {**handler1_result, **handler2_result, ...}""" + assert self.event_completed_signal is not None, 'EventResult cannot be awaited outside of an async context' + + await asyncio.wait_for(self.event_completed_signal.wait(), timeout=timeout or self.event_timeout) + + merged_results: dict[Any, Any] = {} + for event_result in self.event_results.values(): + if event_result.status != 'completed' or not event_result.result: # omit if result is pending, {}, or None + continue + if isinstance(event_result.result, BaseEvent): + # this is returned by EventBus.dispatch() to allow for event forwarding, + # handlers on other busses will automatically insert their own results into the event_results dict + continue + if not isinstance(event_result.result, dict): # omit if result is not a dict + logger.warning( + f'⚠️ {self}.event_results_flat_dict() expects all handlers to return a dict, but handler {event_result.handler_name}() returned a {type(event_result.result).__name__}: {repr(event_result.result)[:12]}...' + ) + continue + merged_results.update( + event_result.result # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType] + ) # update the merged dict with the contents of the result dict + return merged_results + + async def event_results_flat_list(self, timeout: float | None = None) -> list[Any]: + """Assuming all handlers return lists, merge all the returned lists into a single flat list [*handler1_result, *handler2_result, ...]""" + assert self.event_completed_signal is not None, 'EventResult cannot be awaited outside of an async context' + + await asyncio.wait_for(self.event_completed_signal.wait(), timeout=timeout or self.event_timeout) + + merged_results: list[Any] = [] + for event_result in self.event_results.values(): + if event_result.status != 'completed' or not event_result.result: # omit if result is pending, {}, or None + continue + if isinstance(event_result.result, BaseEvent): + # this is returned by EventBus.dispatch() to allow for event forwarding, + # handlers on other busses will automatically insert their own results into the event_results dict + continue + if not isinstance(event_result.result, list): # omit if result is not a list + logger.warning( + f'⚠️ {self}.event_results_flat_list() expects all handlers to return a list, but handler {event_result.handler_name}() returned a {type(event_result.result).__name__}: {repr(event_result.result)[:12]}...' + ) + continue + merged_results.extend( + event_result.result # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType] + ) # append the contents of the list to the merged list + return merged_results def event_result_update( - self, handler: EventHandler | None = None, eventbus: 'EventBus | None' = None, **kwargs: Any + self, handler: EventHandler | None = None, eventbus: EventBus | None = None, **kwargs: Any ) -> 'EventResult': """Create or update an EventResult for a handler""" - handler_id: PythonIdStr = str(id(handler)) - eventbus = eventbus or (handler and hasattr(handler, '__self__') and handler.__self__) or None # type: ignore - eventbus_id: PythonIdStr = str(id(eventbus)) + + from bubus.service import EventBus + + assert eventbus is None or isinstance(eventbus, EventBus) + if eventbus is None and handler and inspect.ismethod(handler) and isinstance(handler.__self__, EventBus): + eventbus = handler.__self__ + + handler_id: PythonIdStr = str(id(handler)) # will be id(None) if handler is None + handler_name: str = get_handler_name(handler) if handler else 'unknown_handler' + eventbus_id: PythonIdStr = str(id(eventbus) if eventbus is not None else '000000000000') + eventbus_name: PythonIdentifierStr = str(eventbus and eventbus.name or 'EventBus') # Get or create EventResult if handler_id not in self.event_results: self.event_results[handler_id] = EventResult( + event_id=self.event_id, handler_id=handler_id, - handler_name=handler and handler.__name__ or str(handler), + handler_name=handler_name, eventbus_id=eventbus_id, - eventbus_name=eventbus and eventbus.name or str(eventbus), - event_parent_id=self.event_id, + eventbus_name=eventbus_name, status=kwargs.get('status', 'pending'), timeout=self.event_timeout, ) - # logger.debug(f'Created EventResult for handler {handler_id}: {handler and handler.__name__}') + # logger.debug(f'Created EventResult for handler {handler_id}: {handler and get_handler_name(handler)}') # Update the EventResult with provided kwargs self.event_results[handler_id].update(**kwargs) @@ -196,80 +308,22 @@ def event_result_update( def _mark_complete_if_all_handlers_completed(self) -> None: """Check if all handlers are done and signal completion""" - if self.event_completed and not self.event_completed.is_set(): + if self.event_completed_signal and not self.event_completed_signal.is_set(): # If there are no results at all, the event is complete if not self.event_results: self._event_processed_at = datetime.now(UTC) - self.event_completed.set() + self.event_completed_signal.set() return # Otherwise check if all results are done all_done = all(result.status in ('completed', 'error') for result in self.event_results.values()) if all_done: self._event_processed_at = datetime.now(UTC) - self.event_completed.set() - - async def event_results_by_handler_id(self, timeout: float | None = None) -> dict[PythonIdStr, Any]: - """Get all results by handler id""" - assert self.event_completed, 'EventResult cannot be awaited outside of an async context' - - await asyncio.wait_for(self.event_completed.wait(), timeout=timeout or self.event_timeout) - - return {handler_id: await event_result for handler_id, event_result in self.event_results.items()} - - async def event_results_list(self, timeout: float | None = None) -> list[Any]: - """Get all results as a list""" - assert self.event_completed, 'EventResult cannot be awaited outside of an async context' - - await asyncio.wait_for(self.event_completed.wait(), timeout=timeout or self.event_timeout) - - return [await event_result for event_result in self.event_results.values()] - - async def event_results_flat_dict(self, timeout: float | None = None) -> dict[Any, Any]: - """Merge all dict results into single dict""" - - assert self.event_completed, 'EventResult cannot be awaited outside of an async context' - - await asyncio.wait_for(self.event_completed.wait(), timeout=timeout or self.event_timeout) - - merged_results: dict[Any, Any] = {} - for event_result in self.event_results.values(): - if event_result.status == 'completed' and event_result.result is not None: - if not event_result.result: # skip if result is {} or None - continue - if isinstance(event_result.result, BaseEvent): # skip if result is another Event - continue - if not isinstance(event_result.result, dict): - # raise TypeError(f"Handler '{event_result.handler_name}' returned {type(event_result.result).__name__} instead of dict") - continue - merged_results.update( - event_result.result # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType] - ) # update the merged dict with the contents of the result dict - return merged_results - - async def event_results_flat_list(self, timeout: float | None = None) -> list[Any]: - """Merge all list results into single list""" - - assert self.event_completed, 'EventResult cannot be awaited outside of an async context' - - await asyncio.wait_for(self.event_completed.wait(), timeout=timeout or self.event_timeout) - - merged_results: list[Any] = [] - for event_result in self.event_results.values(): - if event_result.status == 'completed' and event_result.result is not None: - if isinstance(event_result.result, list): - merged_results.extend( - event_result.result # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType] - ) # append the contents of the list to the merged list - elif isinstance(event_result.result, BaseEvent): # skip if result is another Event - continue - else: - merged_results.append(event_result.result) # append individual item to the merged list - return merged_results + self.event_completed_signal.set() - async def event_result(self, timeout: float | None = None) -> Any: - results = await self.event_results_flat_list(timeout=timeout) - return results[0] if results else None + def _log_safe_summary(self) -> dict[str, Any]: + """only event metadata without contents, avoid potentially sensitive event contents in logs""" + return {k: v for k, v in self.model_dump(mode='json').items() if k.startswith('event_') and 'results' not in k} def attr_name_allowed(key: str): @@ -292,36 +346,44 @@ def attr_name_allowed(key: str): class EventResult(BaseModel): """Individual result from a single handler""" - id: str = Field(default_factory=uuid7str) - handler_id: str + model_config = ConfigDict( + extra='forbid', + arbitrary_types_allowed=True, + validate_assignment=True, + validate_default=True, + revalidate_instances='always', + ) + + # Result fields, updated by the EventBus._execute_sync_or_async_handler() calling event_result.update(...) + result: Any = None + error: BaseException | None = None + + # Automatically set fields, updated by the EventBus._execute_sync_or_async_handler() calling event_result.update(...) + id: UUIDStr = Field(default_factory=uuid7str) + + status: Literal['pending', 'started', 'completed', 'error'] = 'pending' + event_id: UUIDStr + handler_id: PythonIdStr handler_name: str - eventbus_id: str - eventbus_name: str + eventbus_id: PythonIdStr + eventbus_name: PythonIdentifierStr timeout: float | None = None - status: str = 'pending' # pending, started, completed, error - result: Any = None - error: str | None = None started_at: datetime | None = None completed_at: datetime | None = None - event_parent_id: str | None = None # Completion signal - _completed: asyncio.Event | None = PrivateAttr(default=None) + _handler_completed_signal: asyncio.Event | None = PrivateAttr(default=None) @property - def completed(self) -> asyncio.Event | None: + def handler_completed_signal(self) -> asyncio.Event | None: """Lazily create asyncio.Event when accessed""" - if self._completed is None: + if self._handler_completed_signal is None: try: asyncio.get_running_loop() - self._completed = asyncio.Event() + self._handler_completed_signal = asyncio.Event() except RuntimeError: pass # Keep it None if no event loop - return self._completed - - # _event: BaseEvent | None = PrivateAttr(default=None) # in a db we'd store a foreign key to the event - - model_config = {'arbitrary_types_allowed': True} + return self._handler_completed_signal def __str__(self) -> str: handler_qualname = f'{self.eventbus_name}.{self.handler_name}' @@ -329,44 +391,53 @@ def __str__(self) -> str: def __repr__(self) -> str: icon = '🏃' if self.status == 'pending' else '✅' if self.status == 'completed' else '❌' - return f'{self.handler_name}#{self.handler_id[-4:]}(e) {icon}' + return f'{self.handler_name}#{self.handler_id[-4:]}() {icon}' def __await__(self): - """Wait for this result to complete and return the result or raise error""" + """ + Wait for this result to complete and return the result or raise error. + Does not execute the handler itself, only waits for it to be marked completed by the EventBus. + EventBus triggers handlers and calls event_result.update() to mark them as started or completed. + """ async def wait_for_handler_to_complete_and_return_result(): - assert self.completed, 'EventResult cannot be awaited outside of an async context' + assert self.handler_completed_signal is not None, 'EventResult cannot be awaited outside of an async context' try: - await asyncio.wait_for(self.completed.wait(), timeout=self.timeout) + await asyncio.wait_for(self.handler_completed_signal.wait(), timeout=self.timeout) except TimeoutError: - raise RuntimeError(f'Handler {self.handler_name} timed out after {self.timeout}s') + self.handler_completed_signal.clear() + raise RuntimeError(f'Event handler {self.handler_name} timed out after {self.timeout}s') if self.status == 'error' and self.error: - raise RuntimeError(f'Handler {self.handler_name} failed: {self.error}') + raise self.error if isinstance(self.error, BaseException) else Exception(self.error) # pyright: ignore[reportUnnecessaryIsInstance] return self.result return wait_for_handler_to_complete_and_return_result().__await__() def update(self, **kwargs: Any) -> None: - """Update the EventResult with provided kwargs""" + """Update the EventResult with provided kwargs, called by EventBus during handler execution.""" if 'result' in kwargs: self.result = kwargs['result'] self.status = 'completed' - self.completed_at = datetime.now(UTC) - if self.completed: - self.completed.set() elif 'error' in kwargs: - self.error = kwargs['error'] + assert isinstance(kwargs['error'], (BaseException, str)), ( + f'Invalid error type: {type(kwargs["error"]).__name__} {kwargs["error"]}' + ) + self.error = kwargs['error'] if isinstance(kwargs['error'], BaseException) else Exception(kwargs['error']) self.status = 'error' - self.completed_at = datetime.now(UTC) - if self.completed: - self.completed.set() elif 'status' in kwargs: + assert kwargs['status'] in ('pending', 'started', 'completed', 'error'), f'Invalid status: {kwargs["status"]}' self.status = kwargs['status'] - if self.status == 'started' and not self.started_at: - self.started_at = datetime.now(UTC) + + if self.status != 'pending' and not self.started_at: + self.started_at = datetime.now(UTC) + + if self.status in ('completed', 'error') and not self.completed_at: + self.completed_at = datetime.now(UTC) + if self.handler_completed_signal: + self.handler_completed_signal.set() # Resolve forward references diff --git a/bubus/service.py b/bubus/service.py index a7be4a8..7590c9d 100644 --- a/bubus/service.py +++ b/bubus/service.py @@ -3,17 +3,20 @@ import logging import warnings import weakref -from collections import defaultdict +from collections import defaultdict, deque from collections.abc import Callable from contextvars import ContextVar from pathlib import Path -from typing import Any, Literal +from typing import Any, Literal, TypeVar import anyio from pydantic import BaseModel -from uuid_extensions import uuid7str # type: ignore +from uuid_extensions import uuid7str -from bubus.models import BaseEvent, EventHandler, PythonIdentifierStr, PythonIdStr, UUIDStr +from bubus.models import BUBUS_LOG_LEVEL, BaseEvent, EventHandler, PythonIdentifierStr, PythonIdStr, UUIDStr, get_handler_name + +logger = logging.getLogger('bubus') +logger.setLevel(BUBUS_LOG_LEVEL) # Define our own QueueShutDown exception @@ -23,15 +26,17 @@ class QueueShutDown(Exception): pass -# Custom Queue with proper shutdown support -class CleanShutdownQueue(asyncio.Queue): +QueueEntryType = TypeVar('QueueEntryType', bound=BaseEvent) + + +class CleanShutdownQueue(asyncio.Queue[QueueEntryType]): """asyncio.Queue subclass that handles shutdown cleanly without warnings.""" - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._is_shutdown = False + _is_shutdown: bool = False + _getters: deque[asyncio.Future[QueueEntryType]] + _putters: deque[asyncio.Future[QueueEntryType]] - def shutdown(self, immediate=True): + def shutdown(self, immediate: bool = True): """Shutdown the queue and clean up all pending futures.""" self._is_shutdown = True @@ -48,13 +53,14 @@ def shutdown(self, immediate=True): if not putter.done(): putter.set_exception(QueueShutDown()) - async def get(self): + async def get(self) -> QueueEntryType: """Remove and return an item from the queue, with shutdown support.""" while self.empty(): if self._is_shutdown: raise QueueShutDown - getter = self._get_loop().create_future() + getter: asyncio.Future[QueueEntryType] = self._get_loop().create_future() # type: ignore + assert isinstance(getter, asyncio.Future) self._getters.append(getter) try: await getter @@ -70,13 +76,14 @@ async def get(self): return self.get_nowait() - async def put(self, item): + async def put(self, item: QueueEntryType) -> None: """Put an item into the queue, with shutdown support.""" while self.full(): if self._is_shutdown: raise QueueShutDown - putter = self._get_loop().create_future() + putter: asyncio.Future[QueueEntryType] = self._get_loop().create_future() # type: ignore + assert isinstance(putter, asyncio.Future) self._putters.append(putter) try: await putter @@ -90,25 +97,19 @@ async def put(self, item): return self.put_nowait(item) - def put_nowait(self, item): + def put_nowait(self, item: QueueEntryType) -> None: """Put an item into the queue without blocking, with shutdown support.""" if self._is_shutdown: raise QueueShutDown return super().put_nowait(item) - def get_nowait(self): + def get_nowait(self) -> QueueEntryType: """Remove and return an item if one is immediately available, with shutdown support.""" if self._is_shutdown and self.empty(): raise QueueShutDown return super().get_nowait() -# Use our custom queue -Queue = CleanShutdownQueue - -logger = logging.getLogger(__name__) - - # Context variable to track the current event being processed (for setting event_parent_id from inside a child event) _current_event_context: ContextVar[BaseEvent | None] = ContextVar('current_event', default=None) @@ -146,14 +147,14 @@ class EventBus: """ # Class Attributes - name: PythonIdentifierStr + name: PythonIdentifierStr = 'EventBus' parallel_handlers: bool = False wal_path: Path | None = None - handlers: dict[PythonIdStr, list[EventHandler]] # collected by .on(, ) # Runtime State - id: UUIDStr - event_queue: Queue[BaseEvent] | None + id: UUIDStr = '00000000-0000-0000-0000-000000000000' + handlers: dict[PythonIdStr, list[EventHandler]] # collected by .on(, ) + event_queue: CleanShutdownQueue[BaseEvent] | None event_history: dict[UUIDStr, BaseEvent] # collected by .dispatch() _is_running: bool = False @@ -165,7 +166,7 @@ def __init__( self, name: PythonIdentifierStr | None = None, wal_path: Path | str | None = None, parallel_handlers: bool = False ): self.id = uuid7str() - self.name = name or f'EventBus_{self.id[-8:]}' + self.name = name or f'{self.__class__.__name__}_{self.id[-8:]}' assert self.name.isidentifier(), f'EventBus name must be a unique identifier string, got: {self.name}' self.event_queue = None self.event_history = {} @@ -239,15 +240,12 @@ def on(self, event_pattern: PythonIdentifierStr | Literal['*'] | type[BaseModel] event_key = str(event_pattern) # Check for duplicate handler names - if hasattr(handler, '__self__'): - handler_name = f'{handler.__self__}.{handler.__name__}()' # pyright: ignore[reportFunctionMemberAccess] - else: - handler_name = handler.__name__ - existing_names = [h.__name__ for h in self.handlers.get(event_key, [])] + new_handler_name = get_handler_name(handler) + existing_registered_handlers = [get_handler_name(h) for h in self.handlers.get(event_key, [])] - if handler_name in existing_names: + if new_handler_name in existing_registered_handlers: warnings.warn( - f"⚠️ Handler '{handler_name}' already registered for event '{event_key}'. " + f"⚠️ {self} Handler {new_handler_name} already registered for event '{event_key}'. " f'This may cause ambiguous results when using name-based access. ' f'Consider using unique function names.', UserWarning, @@ -256,7 +254,7 @@ def on(self, event_pattern: PythonIdentifierStr | Literal['*'] | type[BaseModel] # Register handler self.handlers[event_key].append(handler) - logger.debug(f'👂 {self}.on({event_key}, {handler_name}) Registered event handler') + logger.debug(f'👂 {self}.on({event_key}, {get_handler_name(handler)}) Registered event handler') def dispatch(self, event: BaseEvent) -> BaseEvent: """ @@ -276,6 +274,12 @@ def dispatch(self, event: BaseEvent) -> BaseEvent: # 2. returns a pending SomeEvent() with pending results in .event_results # 3. awaiting .event_result() waits until all pending results are complete, and returns the raw result value of the first one """ + + try: + asyncio.get_running_loop() + except RuntimeError: + raise RuntimeError(f'{self}.dispatch() called but no event loop is running! Event not queued: {event.event_type}') + assert event.event_id, 'Missing event.event_id: UUIDStr = uuid7str()' assert event.event_created_at, 'Missing event.event_created_at: datetime = datetime.now(UTC)' assert event.event_type and event.event_type.isidentifier(), 'Missing event.event_type: str' @@ -313,7 +317,7 @@ def dispatch(self, event: BaseEvent) -> BaseEvent: if self.event_queue: try: self.event_queue.put_nowait(event) - logger.debug( + logger.info( f'🗣️ {self}.dispatch({event.event_type}) ➡️ Event#{event.event_id[-8:]}({event.event_status} #{self.event_queue.qsize()})' ) except asyncio.QueueFull: @@ -368,7 +372,9 @@ def notify_expect_handler(event: BaseEvent) -> None: if not future.done() and (predicate is None or predicate(event)): future.set_result(event) - notify_expect_handler.__name__ = f'{self}.expect({event_type}, predicate={predicate and id(predicate)})@{_log_pretty_path(inspect.currentframe().f_code.co_filename)}:{inspect.currentframe().f_lineno}' # add file and line number to the name + current_frame = inspect.currentframe() + assert current_frame + notify_expect_handler.__name__ = f'{self}.expect({event_type}, predicate={predicate and id(predicate)})@{_log_pretty_path(current_frame.f_code.co_filename)}:{current_frame.f_lineno}' # add file and line number to the name # Register temporary handler self.on(event_type, notify_expect_handler) @@ -392,11 +398,12 @@ def _start(self) -> None: loop = asyncio.get_running_loop() # Hook into the event loop's close method to cleanup before it closes + # this is necessary to silence "RuntimeError: no running event loop" and "event loop is closed" errors on shutdown if not hasattr(loop, '_eventbus_close_hooked'): original_close = loop.close - registered_eventbuses = weakref.WeakSet() + registered_eventbuses: weakref.WeakSet[EventBus] = weakref.WeakSet() - def close_with_cleanup(): + def close_with_cleanup() -> None: # Clean up all registered EventBuses before closing the loop for eventbus in list(registered_eventbuses): try: @@ -411,7 +418,7 @@ def close_with_cleanup(): if eventbus._runloop_task and not eventbus._runloop_task.done(): # Suppress warning before cancelling if hasattr(eventbus._runloop_task, '_log_destroy_pending'): - eventbus._runloop_task._log_destroy_pending = False + eventbus._runloop_task._log_destroy_pending = False # type: ignore eventbus._runloop_task.cancel() except Exception: pass @@ -420,19 +427,20 @@ def close_with_cleanup(): original_close() loop.close = close_with_cleanup - loop._eventbus_close_hooked = True - loop._eventbus_instances = registered_eventbuses + loop._eventbus_close_hooked = True # type: ignore + loop._eventbus_instances = registered_eventbuses # type: ignore - # Register this EventBus instance + # Register this EventBus instance in the WeakSet of all EventBuses on the loop if hasattr(loop, '_eventbus_instances'): - loop._eventbus_instances.add(self) + loop._eventbus_instances.add(self) # type: ignore # Create async objects if needed if self.event_queue is None: - self.event_queue = Queue() + self.event_queue = CleanShutdownQueue[BaseEvent]() self._runloop_lock = asyncio.Lock() self._on_idle = asyncio.Event() self._on_idle.clear() # Start in a busy state unless we confirm queue is empty by running _run_loop_step() at least once + # Create and start the run loop task self._runloop_task = loop.create_task(self._run_loop(), name=f'{self}._run_loop') self._is_running = True @@ -465,7 +473,7 @@ async def stop(self, timeout: float | None = None) -> None: # Wait for the run loop task to finish if self._runloop_task and not self._runloop_task.done(): # Give it a short time to finish cleanly - done, pending = await asyncio.wait({self._runloop_task}, timeout=0.1) + done, _pending = await asyncio.wait({self._runloop_task}, timeout=0.1) if not done: # If it doesn't finish in time, cancel it @@ -474,8 +482,9 @@ async def stop(self, timeout: float | None = None) -> None: await self._runloop_task except asyncio.CancelledError: pass - except Exception as e: - logger.debug(f'Exception while stopping {self}: {e}') + except Exception: + # logger.debug(f'Exception while stopping {self}: {e}') + pass # Clear references self._runloop_task = None @@ -522,10 +531,10 @@ async def _run_loop(self) -> None: if 'Event loop is closed' in str(e) or 'no running event loop' in str(e): break else: - logger.exception(f'❌ {self} Runtime error in event loop: {e}') + logger.exception(f'❌ {self} Runtime error in event loop: {type(e).__name__} {e}', exc_info=True) # Continue running even if there's an error except Exception as e: - logger.exception(f'❌ {self} Error in event loop: {type(e).__name__} {e}') + logger.exception(f'❌ {self} Error in event loop: {type(e).__name__} {e}', exc_info=True) # Continue running even if there's an error except asyncio.CancelledError: # Task was cancelled, clean exit @@ -535,49 +544,51 @@ async def _run_loop(self) -> None: # Don't call stop() here as it might create new tasks self._is_running = False + async def _get_next_event(self, wait_for_timeout: float = 0.1) -> BaseEvent | None: + """Get the next event from the queue""" + + assert self._runloop_lock and self._on_idle and self.event_queue, ( + 'EventBus._start() must be called before _get_next_event()' + ) + if not self._is_running: + return None + + try: + # Create a task for queue.get() so we can cancel it cleanly + get_next_queued_event = asyncio.create_task(self.event_queue.get()) + if hasattr(get_next_queued_event, '_log_destroy_pending'): + get_next_queued_event._log_destroy_pending = False # type: ignore # Suppress warnings on this task in case of cleanup + + # Wait for next event with timeout + has_next_event, _pending = await asyncio.wait({get_next_queued_event}, timeout=wait_for_timeout) + if has_next_event: + return await get_next_queued_event # await to actually resolve it to the next event + else: + # Get task timed out, cancel it cleanly to suppress warnings + get_next_queued_event.cancel() + + # Check if we're idle, if so, set the idle flag + if not (self.events_pending or self.events_started or self.event_queue.qsize()): + self._on_idle.set() + return None + + except (asyncio.CancelledError, RuntimeError, QueueShutDown): + # Clean cancellation during shutdown or queue was shut down + return None + async def _run_loop_step( self, event: BaseEvent | None = None, timeout: float | None = None, wait_for_timeout: float = 0.1 ) -> BaseEvent | None: """Process a single event from the queue""" - - assert self._runloop_lock and self._on_idle and self.event_queue, ( + assert self._on_idle and self._runloop_lock and self.event_queue, ( 'EventBus._start() must be called before _run_loop_step()' ) # Wait for next event with timeout to periodically check idle state if event is None: - # Check if we should stop - if not self._is_running: - return None - - try: - # Create a task for queue.get() so we can cancel it cleanly - get_task = asyncio.create_task(self.event_queue.get()) - # Suppress warnings on this task in case of cleanup - if hasattr(get_task, '_log_destroy_pending'): - get_task._log_destroy_pending = False - - # Wait with timeout - done, pending = await asyncio.wait({get_task}, timeout=wait_for_timeout) - - if done: - event = await get_task - else: - # Timeout - cancel the get task cleanly - get_task.cancel() - try: - await get_task - except asyncio.CancelledError: - pass - - # Check if we're idle - if not (self.events_pending or self.events_started or self.event_queue.qsize()): - self._on_idle.set() - return None - - except (asyncio.CancelledError, RuntimeError, QueueShutDown): - # Clean cancellation during shutdown or queue was shut down - return None + event = await self._get_next_event(wait_for_timeout=wait_for_timeout) + if event is None: + return None logger.debug(f'🏃 {self}._run_loop_step({event}) STARTING') @@ -617,7 +628,6 @@ def _get_applicable_handlers(self, event: BaseEvent) -> dict[str, EventHandler]: filtered_handlers: dict[PythonIdStr, EventHandler] = {} for handler in applicable_handlers: if self._would_create_loop(event, handler): - logger.debug(f'⚠️ {self} Skipping {handler.__name__}#{str(id(handler))[-4:]}({event}) to prevent infinite loop') continue else: handler_id = str(id(handler)) @@ -639,7 +649,8 @@ async def _execute_handlers( handler_tasks: dict[PythonIdStr, tuple[asyncio.Task[Any], EventHandler]] = {} for handler_id, handler in applicable_handlers.items(): task = asyncio.create_task( - self._execute_sync_or_async_handler(event, handler, timeout=timeout), name=f'{self}.{handler.__name__}' + self._execute_sync_or_async_handler(event, handler, timeout=timeout), + name=f'{self}._execute_sync_or_async_handler({event}, {get_handler_name(handler)})', ) handler_tasks[handler_id] = (task, handler) @@ -658,14 +669,14 @@ async def _execute_handlers( except Exception as e: # Error already logged and recorded in _execute_sync_or_async_handler logger.debug( - f'❌ {self} Handler {handler.__name__}#{str(id(handler))[-4:]}({event}) failed with {type(e).__name__}: {e}' + f'❌ {self} Handler {get_handler_name(handler)}#{str(id(handler))[-4:]}({event}) failed with {type(e).__name__}: {e}' ) pass async def _execute_sync_or_async_handler(self, event: BaseEvent, handler: EventHandler, timeout: float | None = None) -> Any: """Safely execute a single handler with deadlock detection""" - logger.debug(f' ↳ {self}._execute_handler({event}, handler={handler.__name__}#{str(id(handler))[-4:]})') + logger.debug(f' ↳ {self}._execute_handler({event}, handler={get_handler_name(handler)}#{str(id(handler))[-4:]})') # Check if this handler has already been executed for this event handler_id = str(id(handler)) @@ -673,7 +684,7 @@ async def _execute_sync_or_async_handler(self, event: BaseEvent, handler: EventH existing_result = event.event_results[handler_id] if existing_result.started_at is not None: raise RuntimeError( - f'Handler {handler.__name__}#{handler_id[-4:]} has already been executed for event {event.event_id}. ' + f'Handler {get_handler_name(handler)}#{handler_id[-4:]} has already been executed for event {event.event_id}. ' f'Previous execution started at {existing_result.started_at}' ) @@ -689,47 +700,54 @@ async def _execute_sync_or_async_handler(self, event: BaseEvent, handler: EventH async def deadlock_monitor(): await asyncio.sleep(15.0) logger.warning( - f'⚠️ {self}.{handler.__name__}() has been running for >15s on event. Possible slow processing or deadlock.\n' - '(handler could be trying to await its own result or another blocked async task).\n' - f'{self}.{handler.__name__}({event})' + f'⚠️ {self} handler {get_handler_name(handler)}() has been running for >15s on event. Possible slow processing or deadlock.\n' + '(handler could be trying to await its own result or could be blocked by another async task).\n' + f'{get_handler_name(handler)}({event})' ) - monitor_task = asyncio.create_task(deadlock_monitor(), name=f'{self}.deadlock_monitor.{handler.__name__}') + monitor_task = asyncio.create_task( + deadlock_monitor(), name=f'{self}.deadlock_monitor({event}, {get_handler_name(handler)}#{handler_id[-4:]})' + ) try: if inspect.iscoroutinefunction(handler): - # Create handler task - handler_task = asyncio.create_task(handler(event)) - - # Wait with timeout - result = await asyncio.wait_for(handler_task, timeout=event_result.timeout) + # Create handler task if it's an async handler function + event_handler_task = asyncio.create_task(handler(event)) + result_value: Any = await asyncio.wait_for(event_handler_task, timeout=event_result.timeout) + elif inspect.isfunction(handler) or inspect.ismethod(handler): + # If handler function is sync function, run it directly in the main thread + # This blocks but ensures we have access to the event loop, dont run it in a subthread! + result_value: Any = handler(event) else: - # Run sync handler directly in the main thread - # This blocks but ensures we have access to the event loop - result = handler(event) - logger.debug(f' ↳ Sync handler {handler.__name__}#{handler_id[-4:]} returned: {result}') + raise ValueError(f'Handler {get_handler_name(handler)} must be a sync or async function, got: {type(handler)}') + + logger.debug( + f' ↳ Handler {get_handler_name(handler)}#{handler_id[-4:]} returned: {type(result_value).__name__} {result_value}' + ) # Cancel the monitor task since handler completed successfully monitor_task.cancel() # Record successful result - event.event_result_update(handler=handler, eventbus=self, result=result) + event.event_result_update(handler=handler, eventbus=self, result=result_value) if handler_id in event.event_results: - logger.debug( - f' ↳ Updated result for {handler.__name__}#{handler_id[-4:]}: {event.event_results[handler_id].status}' - ) + # logger.debug( + # f' ↳ Updated result for {get_handler_name(handler)}#{handler_id[-4:]}: {event.event_results[handler_id].status}' + # ) + pass else: - logger.error(f' ↳ ERROR: Result not found for {handler.__name__}#{handler_id[-4:]} after update!') - return result + logger.error(f' ↳ ERROR: Result not found for {get_handler_name(handler)}#{handler_id[-4:]} after update!') + return result_value except Exception as e: # Cancel the monitor task on error too monitor_task.cancel() # Record error - event.event_result_update(handler=handler, eventbus=self, error=str(e)) + event.event_result_update(handler=handler, eventbus=self, error=e) logger.exception( - f'❌ {self} Error in handler {handler.__name__}#{str(id(handler))[-4:]}({event}): {type(e).__name__} {e}' + f'❌ {self} Error in event handler {get_handler_name(handler)}#{str(id(handler))[-4:]}({event}) -> {type(e).__name__}({e})', + exc_info=True, ) raise finally: @@ -743,16 +761,23 @@ async def deadlock_monitor(): except asyncio.CancelledError: pass # Expected when we cancel the monitor except Exception as e: - # logger.debug(f"❌ {self} Handler monitor task cleanup error for {handler.__name__}#{str(id(handler))[-4:]}({event}): {type(e).__name__}: {e}") + # logger.debug(f"❌ {self} Handler monitor task cleanup error for {get_handler_name(handler)}#{str(id(handler))[-4:]}({event}): {type(e).__name__}: {e}") pass def _would_create_loop(self, event: BaseEvent, handler: EventHandler) -> bool: """Check if calling this handler would create a loop (i.e. re-process an event that has already been processed by this EventBus)""" + assert inspect.isfunction(handler) or inspect.iscoroutinefunction(handler) or inspect.ismethod(handler), ( + f'Handler {get_handler_name(handler)} must be a sync or async function, got: {type(handler)}' + ) + # First check: If handler is another EventBus.dispatch method, check if we're forwarding to another bus that it's already been processed by - if hasattr(handler, '__self__') and isinstance(handler.__self__, EventBus) and handler.__name__ == 'dispatch': # pyright: ignore[reportFunctionMemberAccess] - target_bus = handler.__self__ # pyright: ignore[reportFunctionMemberAccess] + if hasattr(handler, '__self__') and isinstance(handler.__self__, EventBus) and handler.__name__ == 'dispatch': # pyright: ignore[reportFunctionMemberAccess] # type: ignore + target_bus = handler.__self__ # pyright: ignore[reportFunctionMemberAccess] # type: ignore if target_bus.name in event.event_path: + logger.debug( + f'⚠️ {self} handler {get_handler_name(handler)}#{str(id(handler))[-4:]}({event}) skipped to prevent infinite loop with {target_bus.name}' + ) return True # Second check: Check if there's already a completed result for this handler ID @@ -762,7 +787,7 @@ def _would_create_loop(self, event: BaseEvent, handler: EventHandler) -> bool: existing_result = event.event_results[handler_id] if existing_result.completed_at is not None: logger.debug( - f'⚠️ Preventing loop: handler {handler.__name__} (id={handler_id}) already completed at {existing_result.completed_at} for event {event.event_id}' + f'⚠️ {self} handler {get_handler_name(handler)}#{handler_id[-4:]}({event}) already completed @ {existing_result.completed_at} for event {event.event_id} (will not re-run)' ) return True diff --git a/pyproject.toml b/pyproject.toml index cabb2aa..67c26a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "bubus" description = "Advanced Pydantic-powered event bus with async support" authors = [{ name = "Nick Sweeting" }] -version = "1.1.0" +version = "1.1.1" readme = "README.md" requires-python = ">=3.11,<4.0" classifiers = [ diff --git a/tests/test_eventbus.py b/tests/test_eventbus.py index 970e88b..6d1abe0 100644 --- a/tests/test_eventbus.py +++ b/tests/test_eventbus.py @@ -16,6 +16,7 @@ import asyncio import json +import logging import os import time from datetime import datetime, timezone @@ -155,12 +156,12 @@ def test_emit_sync(self, mock_agent): """Test sync event emission""" bus = EventBus() event = SystemEventModel(event_name='startup', severity='info') - result = bus.dispatch(event) - # Check result and write-ahead log - assert isinstance(result, SystemEventModel) - assert result.event_type == 'SystemEventModel' - assert len(bus.event_history) == 1 + with pytest.raises(RuntimeError) as e: + bus.dispatch(event) + + assert 'no event loop is running' in str(e.value) + assert len(bus.event_history) == 0 class TestHandlerRegistration: @@ -233,8 +234,8 @@ async def slow_handler_2(event: BaseEvent) -> str: assert len(end_times) == 2 # Check results - handler1_result = next((r for r in event.event_results.values() if r.handler_name == 'slow_handler_1'), None) - handler2_result = next((r for r in event.event_results.values() if r.handler_name == 'slow_handler_2'), None) + handler1_result = next((r for r in event.event_results.values() if r.handler_name.endswith('slow_handler_1')), None) + handler2_result = next((r for r in event.event_results.values() if r.handler_name.endswith('slow_handler_2')), None) assert handler1_result is not None and handler1_result.result == 'handler1' assert handler2_result is not None and handler2_result.result == 'handler2' @@ -298,7 +299,7 @@ async def test_error_handling(self, eventbus): results = [] async def failing_handler(event: BaseEvent) -> str: - raise ValueError('Expected to fail') + raise ValueError('Expected to fail - testing error handling in event handlers') async def working_handler(event: BaseEvent) -> str: results.append('success') @@ -312,11 +313,11 @@ async def working_handler(event: BaseEvent) -> str: event = await eventbus.dispatch(UserActionEvent(action='test', user_id='u1')) # Verify error capture and isolation - failing_result = next((r for r in event.event_results.values() if r.handler_name == 'failing_handler'), None) + failing_result = next((r for r in event.event_results.values() if r.handler_name.endswith('failing_handler')), None) assert failing_result is not None assert failing_result.status == 'error' - assert 'Expected to fail' in failing_result.error - working_result = next((r for r in event.event_results.values() if r.handler_name == 'working_handler'), None) + assert 'Expected to fail' in str(failing_result.error) + working_result = next((r for r in event.event_results.values() if r.handler_name.endswith('working_handler')), None) assert working_result is not None assert working_result.result == 'worked' assert results == ['success'] @@ -1120,9 +1121,9 @@ async def handler3(event): event = await eventbus.dispatch(BaseEvent(event_type='TestEvent')) # Get results by handler name - handler1_result = next((r for r in event.event_results.values() if r.handler_name == 'handler1'), None) - handler2_result = next((r for r in event.event_results.values() if r.handler_name == 'handler2'), None) - handler3_result = next((r for r in event.event_results.values() if r.handler_name == 'handler3'), None) + handler1_result = next((r for r in event.event_results.values() if r.handler_name.endswith('handler1')), None) + handler2_result = next((r for r in event.event_results.values() if r.handler_name.endswith('handler2')), None) + handler3_result = next((r for r in event.event_results.values() if r.handler_name.endswith('handler3')), None) assert handler1_result is not None and handler1_result.result == 'first' assert handler2_result is not None and handler2_result.result == 'second' @@ -1145,8 +1146,8 @@ async def late_handler(event): # Check both handlers ran assert len(result.event_results) == 2 - early_result = next((r for r in result.event_results.values() if r.handler_name == 'early_handler'), None) - late_result = next((r for r in result.event_results.values() if r.handler_name == 'late_handler'), None) + early_result = next((r for r in result.event_results.values() if r.handler_name.endswith('early_handler')), None) + late_result = next((r for r in result.event_results.values() if r.handler_name.endswith('late_handler')), None) assert early_result is not None and early_result.result == 'early' assert late_result is not None and late_result.result == 'late' @@ -1180,11 +1181,11 @@ async def unique_handler(event): event = await eventbus.dispatch(BaseEvent(event_type='TestEvent')) # Check results - with duplicate names, both handlers run - process_results = [r for r in event.event_results.values() if r.handler_name == 'process_data'] + process_results = [r for r in event.event_results.values() if r.handler_name.endswith('process_data')] assert len(process_results) == 2 assert {r.result for r in process_results} == {'version1', 'version2'} - unique_result = next((r for r in event.event_results.values() if r.handler_name == 'unique_handler'), None) + unique_result = next((r for r in event.event_results.values() if r.handler_name.endswith('unique_handler')), None) assert unique_result is not None and unique_result.result == 'unique' async def test_by_handler_id(self, eventbus): @@ -1269,7 +1270,7 @@ async def errors3(event): all_errors = await event.event_results_flat_list() # Check that all errors are collected (order may vary due to handler execution) - assert set(all_errors) == {'error1', 'error2', 'error3', 'error4', 'error5'} + assert all_errors == ['error1', 'error2', 'error3', 'error4', 'error5'] # Test with non-list handler async def single_value(event): @@ -1279,8 +1280,8 @@ async def single_value(event): event_single = await eventbus.dispatch(BaseEvent(event_type='GetSingle')) result = await event_single.event_results_flat_list() - assert 'single' in result # Single values are appended - assert len(result) == 1 + assert 'single' not in result # Single values should be skipped, as they are not lists + assert len(result) == 0 async def test_by_handler_name_access(self, eventbus): """Test accessing results by handler name""" @@ -1297,8 +1298,8 @@ async def handler_b(event): event = await eventbus.dispatch(BaseEvent(event_type='TestEvent')) # Access results by handler name - handler_a_result = next((r for r in event.event_results.values() if r.handler_name == 'handler_a'), None) - handler_b_result = next((r for r in event.event_results.values() if r.handler_name == 'handler_b'), None) + handler_a_result = next((r for r in event.event_results.values() if r.handler_name.endswith('handler_a')), None) + handler_b_result = next((r for r in event.event_results.values() if r.handler_name.endswith('handler_b')), None) assert handler_a_result is not None and handler_a_result.result == 'result_a' assert handler_b_result is not None and handler_b_result.result == 'result_b' @@ -1313,11 +1314,11 @@ async def my_handler(event): event = await eventbus.dispatch(BaseEvent(event_type='TestEvent')) # Access result by handler name - my_handler_result = next((r for r in event.event_results.values() if r.handler_name == 'my_handler'), None) + my_handler_result = next((r for r in event.event_results.values() if r.handler_name.endswith('my_handler')), None) assert my_handler_result is not None and my_handler_result.result == 'my_result' # Check missing handler returns None - missing_result = next((r for r in event.event_results.values() if r.handler_name == 'missing'), None) + missing_result = next((r for r in event.event_results.values() if r.handler_name.endswith('missing')), None) assert missing_result is None @@ -1366,9 +1367,9 @@ async def bus3_handler(event): event = await event # All handlers from all buses should be visible - bus1_result = next((r for r in event.event_results.values() if r.handler_name == 'bus1_handler'), None) - bus2_result = next((r for r in event.event_results.values() if r.handler_name == 'bus2_handler'), None) - bus3_result = next((r for r in event.event_results.values() if r.handler_name == 'bus3_handler'), None) + bus1_result = next((r for r in event.event_results.values() if r.handler_name.endswith('bus1_handler')), None) + bus2_result = next((r for r in event.event_results.values() if r.handler_name.endswith('bus2_handler')), None) + bus3_result = next((r for r in event.event_results.values() if r.handler_name.endswith('bus3_handler')), None) assert bus1_result is not None and bus1_result.result == 'from_bus1' assert bus2_result is not None and bus2_result.result == 'from_bus2' @@ -1412,9 +1413,9 @@ async def plugin_handler2(event): event = await event # Check results from both buses - main_result = next((r for r in event.event_results.values() if r.handler_name == 'main_handler'), None) - plugin1_result = next((r for r in event.event_results.values() if r.handler_name == 'plugin_handler1'), None) - plugin2_result = next((r for r in event.event_results.values() if r.handler_name == 'plugin_handler2'), None) + main_result = next((r for r in event.event_results.values() if r.handler_name.endswith('main_handler')), None) + plugin1_result = next((r for r in event.event_results.values() if r.handler_name.endswith('plugin_handler1')), None) + plugin2_result = next((r for r in event.event_results.values() if r.handler_name.endswith('plugin_handler2')), None) assert main_result is not None and main_result.result == 'main_result' assert plugin1_result is not None and plugin1_result.result == 'plugin_result1' @@ -1431,7 +1432,7 @@ async def plugin_handler2(event): class TestComplexIntegration: """Complex integration test with all features""" - async def test_complex_multi_bus_scenario(self): + async def test_complex_multi_bus_scenario(self, caplog): """Test complex scenario with multiple buses, duplicate names, and all query methods""" # Create a hierarchy of buses app_bus = EventBus(name='AppBus') @@ -1492,8 +1493,8 @@ async def data_process(event): # Test that all handlers ran # Count handlers by name - validate_results = [r for r in event.event_results.values() if r.handler_name == 'validate'] - process_results = [r for r in event.event_results.values() if r.handler_name == 'process'] + validate_results = [r for r in event.event_results.values() if r.handler_name.endswith('validate')] + process_results = [r for r in event.event_results.values() if r.handler_name.endswith('process')] # Should have multiple validate and process handlers from different buses assert len(validate_results) >= 3 # One per bus @@ -1505,14 +1506,18 @@ async def data_process(event): assert 'DataBus' in event.event_path # Test flat dict merging - dict_result = await event.event_results_flat_dict() + with caplog.at_level(logging.WARNING): + dict_result = await event.event_results_flat_dict() # Should have merged all dict returns assert 'app_valid' in dict_result and 'auth_valid' in dict_result and 'data_valid' in dict_result + assert 'expects all handlers to return a dict' in caplog.text # Test flat list - list_result = await event.event_results_flat_list() + with caplog.at_level(logging.WARNING): + list_result = await event.event_results_flat_list() # Should include all list items and non-list values assert any('log' in str(item) for item in list_result) + assert 'expects all handlers to return a list' in caplog.text finally: await app_bus.stop() diff --git a/tests/test_parent_event_tracking.py b/tests/test_parent_event_tracking.py index 25f9e2a..9e2419a 100644 --- a/tests/test_parent_event_tracking.py +++ b/tests/test_parent_event_tracking.py @@ -61,7 +61,7 @@ async def parent_handler(event: ParentEvent) -> str: # Verify parent processed await parent_result parent_handler_result = next( - (r for r in parent_result.event_results.values() if r.handler_name == 'parent_handler'), None + (r for r in parent_result.event_results.values() if r.handler_name.endswith('parent_handler')), None ) assert parent_handler_result is not None and parent_handler_result.result == 'parent_handled' @@ -74,19 +74,19 @@ async def test_multi_level_parent_tracking(self, eventbus: EventBus): """Test parent tracking across multiple levels""" events_by_level: dict[str, BaseEvent | None] = {'parent': None, 'child': None, 'grandchild': None} - async def parent_handler(event: ParentEvent) -> str: + async def parent_handler(event: BaseEvent) -> str: events_by_level['parent'] = event child = ChildEvent(data='child_data') eventbus.dispatch(child) return 'parent' - async def child_handler(event: ChildEvent) -> str: + async def child_handler(event: BaseEvent) -> str: events_by_level['child'] = event grandchild = GrandchildEvent(value=42) eventbus.dispatch(grandchild) return 'child' - async def grandchild_handler(event: GrandchildEvent) -> str: + async def grandchild_handler(event: BaseEvent) -> str: events_by_level['grandchild'] = event return 'grandchild' @@ -116,7 +116,7 @@ async def test_multiple_children_same_parent(self, eventbus: EventBus): """Test multiple child events from same parent""" child_events: list[BaseEvent] = [] - async def parent_handler(event: ParentEvent) -> str: + async def parent_handler(event: BaseEvent) -> str: # Dispatch multiple children for i in range(3): child = ChildEvent(data=f'child_{i}') @@ -141,14 +141,14 @@ async def test_parallel_handlers_parent_tracking(self, eventbus: EventBus): """Test parent tracking with parallel handlers""" events_from_handlers: dict[str, list[BaseEvent]] = {'h1': [], 'h2': []} - async def handler1(event: ParentEvent) -> str: + async def handler1(event: BaseEvent) -> str: await asyncio.sleep(0.01) # Simulate work child = ChildEvent(data='from_h1') eventbus.dispatch(child) events_from_handlers['h1'].append(child) return 'h1' - async def handler2(event: ParentEvent) -> str: + async def handler2(event: BaseEvent) -> str: await asyncio.sleep(0.02) # Different timing child = ChildEvent(data='from_h2') eventbus.dispatch(child) @@ -175,7 +175,7 @@ async def test_explicit_parent_not_overridden(self, eventbus: EventBus): """Test that explicitly set event_parent_id is not overridden""" captured_child = None - async def parent_handler(event: ParentEvent) -> str: + async def parent_handler(event: BaseEvent) -> str: nonlocal captured_child # Create child with explicit event_parent_id explicit_parent_id = '01234567-89ab-cdef-0123-456789abcdef' @@ -201,17 +201,17 @@ async def test_cross_eventbus_parent_tracking(self): bus1 = EventBus(name='Bus1') bus2 = EventBus(name='Bus2') - captured_events: list[tuple[str, BaseEvent, BaseEvent]] = [] + captured_events: list[tuple[str, BaseEvent, BaseEvent | None]] = [] - async def bus1_handler(event: ParentEvent) -> str: + async def bus1_handler(event: BaseEvent) -> str: # Dispatch child to bus2 child = ChildEvent(data='cross_bus_child') bus2.dispatch(child) captured_events.append(('bus1', event, child)) return 'bus1_handled' - async def bus2_handler(event: ChildEvent) -> str: - captured_events.append(('bus2', event)) + async def bus2_handler(event: BaseEvent) -> str: + captured_events.append(('bus2', event, None)) return 'bus2_handled' bus1.on('ParentEvent', bus1_handler) @@ -228,9 +228,9 @@ async def bus2_handler(event: ChildEvent) -> str: # Verify parent tracking works across buses assert len(captured_events) == 2 _, _parent_event, child_event = captured_events[0] - _, received_child = captured_events[1] + _, received_child, _ = captured_events[1] - assert child_event.event_parent_id == parent.event_id + assert child_event is not None and child_event.event_parent_id == parent.event_id assert received_child.event_parent_id == parent.event_id finally: @@ -241,7 +241,7 @@ async def test_sync_handler_parent_tracking(self, eventbus: EventBus): """Test parent tracking works with sync handlers""" child_events: list[BaseEvent] = [] - def sync_parent_handler(event: ParentEvent) -> str: + def sync_parent_handler(event: BaseEvent) -> str: # Sync handler that dispatches child child = ChildEvent(data='from_sync') eventbus.dispatch(child) @@ -263,14 +263,16 @@ async def test_error_handler_parent_tracking(self, eventbus: EventBus): """Test parent tracking when handler errors occur""" child_events: list[BaseEvent] = [] - async def failing_handler(event: ParentEvent) -> str: + async def failing_handler(event: BaseEvent) -> str: # Dispatch child before failing child = ChildEvent(data='before_error') eventbus.dispatch(child) child_events.append(child) - raise ValueError('Handler failed') + raise ValueError( + 'Handler error - expected to fail - testing that parent event tracking works even when handlers error' + ) - async def success_handler(event: ParentEvent) -> str: + async def success_handler(event: BaseEvent) -> str: # This should still run child = ChildEvent(data='after_error') eventbus.dispatch(child) @@ -289,7 +291,3 @@ async def success_handler(event: ParentEvent) -> str: assert len(child_events) == 2 for child in child_events: assert child.event_parent_id == parent.event_id - - -if __name__ == '__main__': - pytest.main([__file__, '-v'])