import inspect import asyncio from typing import Callable, Optional, Union, Any, Tuple, Literal from .types import ( BaseEvent, EventFunction, EventGroup, EventInput, _SpecialEventReturn, ReturnBehavior, InvokeInterCache, ) from .broker import BaseBroker from .utils import logger, string_to_md5_hash, generate_uuid class EventEngineCls: def __init__(self, name="default", broker: Optional[BaseBroker] = None): self.name = name self.broker = broker or BaseBroker() self.__event_maps: dict[str, BaseEvent] = {} self.__max_group_size = 0 def reset(self): self.__event_maps = {} def get_event_from_id(self, event_id: str) -> Optional[BaseEvent]: return self.__event_maps.get(event_id) def make_event(self, func: Union[EventFunction, BaseEvent]) -> BaseEvent: if isinstance(func, BaseEvent): self.__event_maps[func.id] = func return func assert inspect.iscoroutinefunction( func ), "Event function must be a coroutine function" event = BaseEvent(func) self.__event_maps[event.id] = event return event def listen_group( self, group_markers: list[BaseEvent], group_name: Optional[str] = None, retrigger_type: Literal["all", "any"] = "all", ) -> Callable[[BaseEvent], BaseEvent]: assert all( [isinstance(m, BaseEvent) for m in group_markers] ), "group_markers must be a list of BaseEvent" assert all( [m.id in self.__event_maps for m in group_markers] ), f"group_markers must be registered in the same event engine, current event engine is {self.name}" group_markers_in_dict = {event.id: event for event in group_markers} def decorator(func: BaseEvent) -> BaseEvent: if not isinstance(func, BaseEvent): func = self.make_event(func) assert ( func.id in self.__event_maps ), f"Event function must be registered in the same event engine, current event engine is {self.name}" this_group_name = group_name or f"{len(func.parent_groups)}" this_group_hash = string_to_md5_hash(":".join(group_markers_in_dict.keys())) new_group = EventGroup( this_group_name, this_group_hash, group_markers_in_dict, retrigger_type=retrigger_type, ) self.__max_group_size = max( self.__max_group_size, len(group_markers_in_dict) ) if new_group.hash() in func.parent_groups: logger.warning(f"Group {group_markers} already listened by {func}") return func func.parent_groups[new_group.hash()] = new_group return func return decorator def goto(self, group_markers: list[BaseEvent], *args): raise NotImplementedError() async def invoke_event( self, event: BaseEvent, event_input: Optional[EventInput] = None, global_ctx: Any = None, max_async_events: Optional[int] = None, ) -> dict[str, Any]: this_run_ctx: dict[str, InvokeInterCache] = {} queue: list[Tuple[str, EventInput]] = [(event.id, event_input)] async def run_event(current_event_id: str, current_event_input: Any): current_event = self.get_event_from_id(current_event_id) assert current_event is not None, f"Event {current_event_id} not found" result = await current_event.solo_run(current_event_input, global_ctx) this_run_ctx[current_event.id] = { "result": result, "already_sent_to_event_group": set(), } if isinstance(result, _SpecialEventReturn): if result.behavior == ReturnBehavior.GOTO: group_markers, any_return = result.returns for group_marker in group_markers: this_group_returns = {current_event.id: any_return} build_input_goto = EventInput( group_name="$goto", results=this_group_returns, behavior=ReturnBehavior.GOTO, ) queue.append((group_marker.id, build_input_goto)) elif result.behavior == ReturnBehavior.ABORT: return else: # dispath to events who listen for cand_event in self.__event_maps.values(): cand_event_parents = cand_event.parent_groups for group_hash, group in cand_event_parents.items(): if_current_event_trigger = current_event.id in group.events if_ctx_cover = all( [event_id in this_run_ctx for event_id in group.events] ) event_group_id = f"{cand_event.id}:{group_hash}" if if_current_event_trigger and if_ctx_cover: if ( any( [ event_group_id in this_run_ctx[event_id][ "already_sent_to_event_group" ] for event_id in group.events ] ) and group.retrigger_type == "all" ): # some events already dispatched to this event and group, skip logger.debug(f"Skip {cand_event} for {current_event}") continue this_group_returns = { event_id: this_run_ctx[event_id]["result"] for event_id in group.events } for event_id in group.events: this_run_ctx[event_id][ "already_sent_to_event_group" ].add(event_group_id) build_input = EventInput( group_name=group.name, results=this_group_returns ) queue.append((cand_event.id, build_input)) tasks = set() try: while len(queue) or len(tasks): this_batch_events = ( queue[:max_async_events] if max_async_events else queue ) queue = queue[max_async_events:] if max_async_events else [] new_tasks = { asyncio.create_task(run_event(*run_event_input)) for run_event_input in this_batch_events } tasks.update(new_tasks) done, tasks = await asyncio.wait( tasks, return_when=asyncio.FIRST_COMPLETED ) for task in done: await task # Handle any exceptions except asyncio.CancelledError: for task in tasks: task.cancel() await asyncio.gather(*tasks, return_exceptions=True) raise return this_run_ctx