PyEventEngine 0.6.2__cp315-cp315-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. event_engine/__infra__.pxd +2 -0
  2. event_engine/__init__.pxd +2 -0
  3. event_engine/__init__.py +62 -0
  4. event_engine/base/__infra__.pxd +9 -0
  5. event_engine/base/__init__.pxd +9 -0
  6. event_engine/base/__init__.py +31 -0
  7. event_engine/base/c_allocator_protocol.c +14310 -0
  8. event_engine/base/c_allocator_protocol.cp315-win_amd64.pyd +0 -0
  9. event_engine/base/c_allocator_protocol.h +23 -0
  10. event_engine/base/c_allocator_protocol.pxd +31 -0
  11. event_engine/base/c_allocator_protocol.pyx +193 -0
  12. event_engine/capi/__infra__.pxd +189 -0
  13. event_engine/capi/__init__.pxd +189 -0
  14. event_engine/capi/__init__.py +84 -0
  15. event_engine/capi/c_engine.c +32542 -0
  16. event_engine/capi/c_engine.cp315-win_amd64.pyd +0 -0
  17. event_engine/capi/c_engine.h +393 -0
  18. event_engine/capi/c_engine.pxd +84 -0
  19. event_engine/capi/c_engine.pyi +478 -0
  20. event_engine/capi/c_engine.pyx +673 -0
  21. event_engine/capi/c_event.c +22826 -0
  22. event_engine/capi/c_event.cp315-win_amd64.pyd +0 -0
  23. event_engine/capi/c_event.h +342 -0
  24. event_engine/capi/c_event.pxd +159 -0
  25. event_engine/capi/c_event.pyi +265 -0
  26. event_engine/capi/c_event.pyx +444 -0
  27. event_engine/capi/c_topic.c +34545 -0
  28. event_engine/capi/c_topic.cp315-win_amd64.pyd +0 -0
  29. event_engine/capi/c_topic.h +1007 -0
  30. event_engine/capi/c_topic.pxd +147 -0
  31. event_engine/capi/c_topic.pyi +644 -0
  32. event_engine/capi/c_topic.pyx +818 -0
  33. event_engine/capi/fallback_engine.py +707 -0
  34. event_engine/config_view.c +10271 -0
  35. event_engine/config_view.cp315-win_amd64.pyd +0 -0
  36. event_engine/config_view.pyx +55 -0
  37. event_engine/includes/event_engine/base/c_allocator_protocol.c +14310 -0
  38. event_engine/includes/event_engine/base/c_allocator_protocol.h +23 -0
  39. event_engine/includes/event_engine/capi/c_engine.c +32542 -0
  40. event_engine/includes/event_engine/capi/c_engine.h +393 -0
  41. event_engine/includes/event_engine/capi/c_event.c +22826 -0
  42. event_engine/includes/event_engine/capi/c_event.h +342 -0
  43. event_engine/includes/event_engine/capi/c_topic.c +34545 -0
  44. event_engine/includes/event_engine/capi/c_topic.h +1007 -0
  45. event_engine/includes/event_engine/config_view.c +10271 -0
  46. event_engine/native/__init__.py +60 -0
  47. event_engine/native/_event.py +420 -0
  48. event_engine/native/_topic.py +171 -0
  49. event_engine/native/engine.py +793 -0
  50. event_engine/native/event.py +469 -0
  51. event_engine/native/topic.py +943 -0
  52. pyeventengine-0.6.2.dist-info/METADATA +189 -0
  53. pyeventengine-0.6.2.dist-info/RECORD +56 -0
  54. pyeventengine-0.6.2.dist-info/WHEEL +5 -0
  55. pyeventengine-0.6.2.dist-info/licenses/LICENSE +21 -0
  56. pyeventengine-0.6.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,793 @@
1
+ """
2
+ Native Python fallback implementation for EventEngine and EventEngineEx.
3
+
4
+ This module provides a pure Python implementation that mimics the behavior of the
5
+ Cython-based c_engine module. It is used as a fallback when the Cython extension
6
+ cannot be compiled (e.g., due to lack of Cython, GCC, or Clang).
7
+
8
+ The API is designed to match event_engine.capi.c_engine as closely as possible.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import threading
14
+ from collections import deque
15
+ from datetime import datetime, timedelta
16
+ from logging import Logger
17
+ from threading import Thread
18
+ from time import sleep, time
19
+ from typing import Optional
20
+
21
+ from .event import EventHook, EventHookEx, PyMessagePayload
22
+ from .topic import PyTopic
23
+
24
+ # Get logger from base module
25
+ try:
26
+ from ..base import LOGGER
27
+ except ImportError:
28
+ import logging
29
+
30
+ LOGGER = logging.getLogger(__name__)
31
+
32
+ LOGGER = LOGGER.getChild('Event')
33
+
34
+ # Default constants (matching c_engine defaults)
35
+ DEFAULT_MQ_CAPACITY = 0x0fff # 4095
36
+ DEFAULT_MQ_SPIN_LIMIT = 0xffff # 65535
37
+ DEFAULT_MQ_TIMEOUT_SECONDS = 1.0
38
+
39
+
40
+ class Full(Exception):
41
+ """Raised when attempting to publish to a full event queue."""
42
+ pass
43
+
44
+
45
+ class Empty(Exception):
46
+ """Raised when attempting to retrieve from an empty event queue."""
47
+ pass
48
+
49
+
50
+ class EventEngine:
51
+ """
52
+ High-performance, topic-driven event dispatcher backed by threading primitives.
53
+
54
+ The engine manages an internal message queue and dispatches events to registered handlers
55
+ based on topic matching rules. In this native Python implementation:
56
+
57
+ - A threading-based event loop consumes messages and triggers callbacks
58
+ - Message queue uses deque with threading.Lock and Condition variables
59
+ - Two dict instances for exact and generic topic routing
60
+
61
+ **Matching priority**: exact topic matches take precedence over generic matches.
62
+ Exact matches are based on the topic's literal key.
63
+ Generic matches are evaluated by testing whether the published topic matches a registered pattern.
64
+
65
+ Attributes:
66
+ capacity (int): Maximum number of messages the internal queue can hold.
67
+ logger (Logger): Logger instance used for diagnostics.
68
+ """
69
+
70
+ __slots__ = ('logger', '_capacity', '_queue', '_lock', '_not_empty', '_not_full', '_exact_topic_hooks', '_generic_topic_hooks', '_seq_id', 'active', 'engine')
71
+
72
+ def __init__(self, capacity: int = DEFAULT_MQ_CAPACITY, logger: Optional[Logger] = None) -> None:
73
+ """
74
+ Initialize an ``EventEngine``.
75
+
76
+ Args:
77
+ capacity: Maximum number of pending messages.
78
+ logger: Optional logger. If ``None``, a default logger is created.
79
+ """
80
+ self.logger: Logger = LOGGER.getChild('EventEngine') if logger is None else logger
81
+
82
+ # Message queue using deque with threading primitives
83
+ self._capacity: int = capacity
84
+ self._queue: deque[PyMessagePayload] = deque()
85
+ self._lock: threading.Lock = threading.Lock()
86
+ self._not_empty: threading.Condition = threading.Condition(self._lock)
87
+ self._not_full: threading.Condition = threading.Condition(self._lock)
88
+
89
+ # Topic-to-hook mappings using dict (key: topic.value string)
90
+ self._exact_topic_hooks: dict[str, EventHook] = {}
91
+ self._generic_topic_hooks: dict[str, EventHook] = {}
92
+
93
+ # Sequence ID counter
94
+ self._seq_id: int = 0
95
+
96
+ # Engine state
97
+ self.active: bool = False
98
+ self.engine: Optional[Thread] = None
99
+
100
+ def __len__(self) -> int:
101
+ """Return the total number of registered topics (both exact and generic)."""
102
+ return len(self._exact_topic_hooks) + len(self._generic_topic_hooks)
103
+
104
+ def __repr__(self) -> str:
105
+ """String representation of the engine."""
106
+ status = "active" if self.active else "idle"
107
+ return f'<{self.__class__.__name__} {status}>(capacity={self.capacity})'
108
+
109
+ def _loop(self) -> None:
110
+ """Main event loop - consumes and processes messages."""
111
+ while self.active:
112
+ msg = self._get_message(block=True, timeout=DEFAULT_MQ_TIMEOUT_SECONDS)
113
+ if msg is not None:
114
+ self._trigger(msg)
115
+
116
+ def _get_message(self, block: bool = True, timeout: float = 0.0) -> Optional[PyMessagePayload]:
117
+ """
118
+ Get a message from the queue.
119
+
120
+ Args:
121
+ block: Whether to block if queue is empty
122
+ timeout: Timeout in seconds (0 means no timeout)
123
+
124
+ Returns:
125
+ PyMessagePayload or None if timeout/non-blocking and queue is empty
126
+ """
127
+ with self._not_empty:
128
+ if not block:
129
+ if len(self._queue) == 0:
130
+ return None
131
+ msg = self._queue.popleft()
132
+ self._not_full.notify()
133
+ return msg
134
+
135
+ # Blocking mode
136
+ if timeout > 0:
137
+ end_time = time() + timeout
138
+ while len(self._queue) == 0 and self.active:
139
+ remaining = end_time - time()
140
+ if remaining <= 0:
141
+ return None
142
+ self._not_empty.wait(timeout=remaining)
143
+ else:
144
+ # No timeout - wait indefinitely
145
+ while len(self._queue) == 0 and self.active:
146
+ self._not_empty.wait(timeout=DEFAULT_MQ_TIMEOUT_SECONDS)
147
+
148
+ if len(self._queue) == 0:
149
+ return None
150
+
151
+ msg = self._queue.popleft()
152
+ self._not_full.notify()
153
+ return msg
154
+
155
+ def _publish(self, topic: PyTopic, args: tuple, kwargs: dict, block: bool = True, max_spin: int = DEFAULT_MQ_SPIN_LIMIT, timeout: float = 0.0) -> int:
156
+ """
157
+ Publish a message to the queue.
158
+
159
+ Args:
160
+ topic: Topic for the message
161
+ args: Positional arguments
162
+ kwargs: Keyword arguments
163
+ block: Whether to block if queue is full
164
+ max_spin: Spin limit (ignored in pure Python implementation)
165
+ timeout: Timeout in seconds
166
+
167
+ Returns:
168
+ 0 on success, non-zero on failure
169
+ """
170
+ if not topic.is_exact:
171
+ raise ValueError('Topic must be all of exact parts')
172
+
173
+ # Create payload
174
+ payload = PyMessagePayload(alloc=True)
175
+ payload.topic = topic
176
+ payload.args = args
177
+ payload.kwargs = kwargs
178
+ payload.seq_id = self._seq_id
179
+
180
+ with self._not_full:
181
+ if not block:
182
+ if len(self._queue) >= self._capacity:
183
+ return 1 # Queue full
184
+ self._queue.append(payload)
185
+ self._seq_id += 1
186
+ self._not_empty.notify()
187
+ return 0
188
+
189
+ # Blocking mode
190
+ if timeout > 0:
191
+ end_time = time() + timeout
192
+ while len(self._queue) >= self._capacity:
193
+ remaining = end_time - time()
194
+ if remaining <= 0:
195
+ return 1 # Timeout
196
+ self._not_full.wait(timeout=remaining)
197
+ else:
198
+ # No timeout - wait indefinitely
199
+ while len(self._queue) >= self._capacity:
200
+ self._not_full.wait(timeout=DEFAULT_MQ_TIMEOUT_SECONDS)
201
+
202
+ if len(self._queue) >= self._capacity:
203
+ return 1 # Failed
204
+
205
+ self._queue.append(payload)
206
+ self._seq_id += 1
207
+ self._not_empty.notify()
208
+ return 0
209
+
210
+ def _trigger(self, msg: PyMessagePayload) -> None:
211
+ """
212
+ Trigger event hooks matching the message topic.
213
+
214
+ Args:
215
+ msg: Message payload to dispatch
216
+ """
217
+ msg_topic = msg.topic
218
+
219
+ # Step 1: Match exact topic hooks
220
+ hook = self._exact_topic_hooks.get(msg_topic.value)
221
+ if hook:
222
+ hook.trigger(msg)
223
+
224
+ # Step 2: Match generic topic hooks (wildcards, patterns, etc.)
225
+ for hook in self._generic_topic_hooks.values():
226
+ # Use topic matching from PyTopic
227
+ if hook.topic.match(msg_topic).matched:
228
+ hook.trigger(msg)
229
+
230
+ def _register_hook(self, hook: EventHook) -> None:
231
+ """
232
+ Register an event hook.
233
+
234
+ Args:
235
+ hook: EventHook to register
236
+
237
+ Raises:
238
+ KeyError: If a hook is already registered for the same topic
239
+ """
240
+ topic = hook.topic
241
+ topic_str = topic.value
242
+
243
+ if topic.is_exact:
244
+ if topic_str in self._exact_topic_hooks:
245
+ raise KeyError(f'Another EventHook already registered for {topic_str}')
246
+ self._exact_topic_hooks[topic_str] = hook
247
+ else:
248
+ if topic_str in self._generic_topic_hooks:
249
+ raise KeyError(f'Another EventHook already registered for {topic_str}')
250
+ self._generic_topic_hooks[topic_str] = hook
251
+
252
+ def _unregister_hook(self, topic: PyTopic) -> EventHook:
253
+ """
254
+ Unregister an event hook by topic.
255
+
256
+ Args:
257
+ topic: Topic of the hook to unregister
258
+
259
+ Returns:
260
+ The unregistered EventHook
261
+
262
+ Raises:
263
+ KeyError: If no hook is registered for the given topic
264
+ """
265
+ topic_str = topic.value
266
+
267
+ if topic.is_exact:
268
+ if topic_str not in self._exact_topic_hooks:
269
+ raise KeyError(f'No EventHook registered for {topic_str}')
270
+ return self._exact_topic_hooks.pop(topic_str)
271
+ else:
272
+ if topic_str not in self._generic_topic_hooks:
273
+ raise KeyError(f'No EventHook registered for {topic_str}')
274
+ return self._generic_topic_hooks.pop(topic_str)
275
+
276
+ def _register_handler(self, topic: PyTopic, handler, deduplicate: bool = False) -> None:
277
+ """
278
+ Register a handler for a topic (creates hook if needed).
279
+
280
+ Args:
281
+ topic: Topic to register handler for
282
+ handler: Callable handler
283
+ deduplicate: Whether to skip if handler already registered
284
+ """
285
+ topic_str = topic.value
286
+
287
+ if topic.is_exact:
288
+ hook_map = self._exact_topic_hooks
289
+ else:
290
+ hook_map = self._generic_topic_hooks
291
+
292
+ if topic_str not in hook_map:
293
+ hook = EventHook(topic, self.logger)
294
+ hook_map[topic_str] = hook
295
+ else:
296
+ hook = hook_map[topic_str]
297
+
298
+ hook.add_handler(handler, deduplicate=deduplicate)
299
+
300
+ def _unregister_handler(self, topic: PyTopic, handler) -> None:
301
+ """
302
+ Unregister a handler from a topic.
303
+
304
+ Mirrors the Cython engine: when no hook is registered for the topic,
305
+ an error is logged and the call is a no-op (no exception raised).
306
+
307
+ Args:
308
+ topic: Topic to unregister handler from
309
+ handler: Callable handler to remove
310
+ """
311
+ topic_str = topic.value
312
+
313
+ if topic.is_exact:
314
+ hook_map = self._exact_topic_hooks
315
+ else:
316
+ hook_map = self._generic_topic_hooks
317
+
318
+ if topic_str not in hook_map:
319
+ LOGGER.error(f'No EventHook registered for "{topic_str}"')
320
+ return
321
+
322
+ hook = hook_map[topic_str]
323
+ hook.remove_handler(handler)
324
+
325
+ # Remove hook if no handlers left
326
+ if len(hook) == 0:
327
+ del hook_map[topic_str]
328
+
329
+ def _clear(self) -> None:
330
+ """Clear all hooks and handlers."""
331
+ # Clear all hooks
332
+ for hook in self._exact_topic_hooks.values():
333
+ hook.clear()
334
+ self._exact_topic_hooks.clear()
335
+
336
+ for hook in self._generic_topic_hooks.values():
337
+ hook.clear()
338
+ self._generic_topic_hooks.clear()
339
+
340
+ # --- Public API ---
341
+
342
+ def activate(self) -> None:
343
+ """
344
+ Activate the event engine.
345
+
346
+ This method is called automatically when ``start`` is invoked.
347
+ It can also be called manually to prepare the engine for operation.
348
+ """
349
+ self.active = True
350
+
351
+ def deactivate(self) -> None:
352
+ """
353
+ Deactivate the event engine.
354
+
355
+ This method is called automatically when ``stop`` is invoked.
356
+ It can also be called manually to halt the engine's operation.
357
+ """
358
+ self.active = False
359
+
360
+ def run(self) -> None:
361
+ """
362
+ Run the event loop in the current thread (blocking).
363
+ """
364
+ self._loop()
365
+
366
+ def start(self) -> None:
367
+ """
368
+ Start the event loop in a dedicated background thread.
369
+
370
+ If the engine is already running, this method has no effect.
371
+ """
372
+ if self.active:
373
+ self.logger.warning(f'{self} already started!')
374
+ return
375
+
376
+ self.active = True
377
+ self.engine = Thread(target=self.run, name='EventEngine')
378
+ self.engine.start()
379
+ self.logger.info(f'{self} started.')
380
+
381
+ def stop(self) -> None:
382
+ """
383
+ Stop the event loop and wait for the background thread to terminate.
384
+
385
+ If the engine is already stopped, this method has no effect.
386
+ """
387
+ if not self.active:
388
+ self.logger.warning('EventEngine already stopped!')
389
+ return
390
+
391
+ self.active = False
392
+
393
+ # Wake up the event loop if it's waiting
394
+ with self._not_empty:
395
+ self._not_empty.notify_all()
396
+
397
+ if self.engine:
398
+ self.engine.join()
399
+ self.engine = None
400
+
401
+ def clear(self) -> None:
402
+ """
403
+ Unregister all event hooks.
404
+
405
+ Notes:
406
+ This method only works when the engine is stopped. If called while running,
407
+ an error is logged and no action is taken.
408
+ """
409
+ if self.active:
410
+ self.logger.error('EventEngine must be stopped before cleared!')
411
+ return
412
+
413
+ self._clear()
414
+
415
+ def get(self, block: bool = True, max_spin: int = DEFAULT_MQ_SPIN_LIMIT, timeout: float = 0.0) -> PyMessagePayload:
416
+ """
417
+ Retrieve an event from the internal queue.
418
+
419
+ Args:
420
+ block: If ``True``, wait until an event is available.
421
+ max_spin: Maximum number of spin-loop iterations before blocking (ignored in pure Python).
422
+ timeout: Maximum wait time in seconds when blocking (``0.0`` means indefinite wait).
423
+
424
+ Returns:
425
+ A ``PyMessagePayload`` instance.
426
+
427
+ Raises:
428
+ Empty: If ``block=False`` and the queue is empty.
429
+ """
430
+ msg = self._get_message(block=block, timeout=timeout)
431
+ if msg is None:
432
+ raise Empty()
433
+ return msg
434
+
435
+ def put(self, topic: PyTopic, *args, block: bool = True, max_spin: int = DEFAULT_MQ_SPIN_LIMIT, timeout: float = 0.0, **kwargs) -> None:
436
+ """
437
+ Publish an event to the queue (convenience alias for ``publish``).
438
+
439
+ Args:
440
+ topic: Must be an **exact** ``PyTopic`` (i.e., ``topic.is_exact`` must be ``True``).
441
+ *args: Positional arguments for the event.
442
+ block: If ``True``, wait if the queue is full.
443
+ max_spin: Spin count before blocking (ignored in pure Python).
444
+ timeout: Maximum wait time in seconds when blocking (``0.0`` = indefinite).
445
+ **kwargs: Keyword arguments for the event.
446
+
447
+ Raises:
448
+ Full: If ``block=False`` and the queue is full.
449
+ ValueError: If ``topic`` is not an exact topic.
450
+ """
451
+ ret_code = self._publish(topic, args, kwargs, block, max_spin, timeout)
452
+ if ret_code:
453
+ raise Full()
454
+
455
+ def publish(self, topic: PyTopic, args: tuple, kwargs: dict, block: bool = True, max_spin: int = DEFAULT_MQ_SPIN_LIMIT, timeout: float = 0.0) -> None:
456
+ """
457
+ Publish an event to the queue.
458
+
459
+ Args:
460
+ topic: Must be an **exact** ``PyTopic`` (i.e., ``topic.is_exact`` must be ``True``).
461
+ args: Positional arguments for the event.
462
+ kwargs: Keyword arguments for the event.
463
+ block: If ``True``, wait if the queue is full.
464
+ max_spin: Spin count before blocking (ignored in pure Python; kept for
465
+ API parity with the Cython engine).
466
+ timeout: Maximum wait time in seconds when blocking (``0.0`` = indefinite).
467
+
468
+ Raises:
469
+ Full: If ``block=False`` and the queue is full.
470
+ ValueError: If ``topic`` is not an exact topic.
471
+ """
472
+ ret_code = self._publish(topic, args, kwargs, block, max_spin, timeout)
473
+ if ret_code:
474
+ raise Full()
475
+
476
+ def register_hook(self, hook: EventHook) -> None:
477
+ """
478
+ Register an ``EventHook`` for its associated topic.
479
+
480
+ Args:
481
+ hook: The hook to register.
482
+
483
+ Raises:
484
+ KeyError: If a hook is already registered for the same topic (exact or generic).
485
+ """
486
+ self._register_hook(hook)
487
+
488
+ def unregister_hook(self, topic: PyTopic) -> EventHook:
489
+ """
490
+ Unregister and return the ``EventHook`` associated with a topic.
491
+
492
+ Args:
493
+ topic: The topic to unregister.
494
+
495
+ Returns:
496
+ The unregistered ``EventHook``.
497
+
498
+ Raises:
499
+ KeyError: If no hook is registered for the given topic.
500
+ """
501
+ return self._unregister_hook(topic)
502
+
503
+ def register_handler(self, topic: PyTopic, handler, deduplicate: bool = False) -> None:
504
+ """
505
+ Register a handler for a topic (creates hook if needed).
506
+
507
+ Args:
508
+ topic: Topic to register handler for
509
+ handler: Callable handler
510
+ deduplicate: Skip if handler already registered
511
+ """
512
+ self._register_handler(topic, handler, deduplicate)
513
+
514
+ def unregister_handler(self, topic: PyTopic, handler) -> None:
515
+ """
516
+ Unregister a handler from a topic.
517
+
518
+ Args:
519
+ topic: Topic to unregister handler from
520
+ handler: Callable handler to remove
521
+ """
522
+ self._unregister_handler(topic, handler)
523
+
524
+ def get_hook(self, topic: PyTopic) -> EventHook:
525
+ """
526
+ Retrieve the ``EventHook`` registered for a topic.
527
+
528
+ Args:
529
+ topic: Topic to look up.
530
+
531
+ Returns:
532
+ The registered ``EventHook``.
533
+
534
+ Raises:
535
+ KeyError: If no hook is registered for the given topic.
536
+ """
537
+ topic_str = topic.value
538
+ hook_map = self._exact_topic_hooks if topic.is_exact else self._generic_topic_hooks
539
+ if topic_str not in hook_map:
540
+ raise KeyError(f'No EventHook registered for {topic_str}')
541
+ return hook_map[topic_str]
542
+
543
+ def __getitem__(self, topic: PyTopic) -> list:
544
+ """
545
+ Return all hooks that would receive a message published for ``topic``:
546
+ the exact-topic hook (if registered) plus every matching generic hook.
547
+
548
+ Args:
549
+ topic: The topic to resolve hooks for.
550
+
551
+ Returns:
552
+ A list of ``EventHook`` instances (possibly empty).
553
+ """
554
+ out = []
555
+ exact = self._exact_topic_hooks.get(topic.value)
556
+ if exact:
557
+ out.append(exact)
558
+ for hook in self._generic_topic_hooks.values():
559
+ if hook.topic.match(topic).matched:
560
+ out.append(hook)
561
+ return out
562
+
563
+ def event_hooks(self):
564
+ """Iterate over all registered event hooks."""
565
+ yield from self._exact_topic_hooks.values()
566
+ yield from self._generic_topic_hooks.values()
567
+
568
+ def topics(self):
569
+ """Iterate over all registered topics."""
570
+ for hook in self.event_hooks():
571
+ yield hook.topic
572
+
573
+ def items(self):
574
+ """Iterate over (topic, hook) pairs."""
575
+ for hook in self.event_hooks():
576
+ yield (hook.topic, hook)
577
+
578
+ @property
579
+ def capacity(self) -> int:
580
+ """Get the queue capacity."""
581
+ return self._capacity
582
+
583
+ @property
584
+ def occupied(self) -> int:
585
+ """Get the current number of messages in the queue."""
586
+ with self._lock:
587
+ return len(self._queue)
588
+
589
+ @property
590
+ def seq_id(self) -> int:
591
+ """Get the sequence ID of the last published message (mirrors the Cython engine)."""
592
+ return self._seq_id
593
+
594
+ @property
595
+ def exact_topic_hook_map(self) -> dict:
596
+ """Get a copy of the exact topic hook mapping."""
597
+ return self._exact_topic_hooks.copy()
598
+
599
+ @property
600
+ def generic_topic_hook_map(self) -> dict:
601
+ """Get a copy of the generic topic hook mapping."""
602
+ return self._generic_topic_hooks.copy()
603
+
604
+
605
+ class EventEngineEx(EventEngine):
606
+ """
607
+ Extended EventEngine with timer support and statistics tracking.
608
+
609
+ Provides timer functionality for periodic event triggering and uses EventHookEx
610
+ for handler statistics.
611
+ """
612
+
613
+ __slots__ = ('timer',)
614
+
615
+ def __init__(self, capacity: int = DEFAULT_MQ_CAPACITY, logger: Optional[Logger] = None) -> None:
616
+ """
617
+ Initialize EventEngineEx.
618
+
619
+ Args:
620
+ capacity: Maximum capacity of the message queue
621
+ logger: Optional logger instance
622
+ """
623
+ super().__init__(capacity, logger)
624
+ self.timer: dict[float, Thread] = {}
625
+
626
+ def __repr__(self) -> str:
627
+ """String representation including active timers."""
628
+ status = "active" if self.active else "idle"
629
+ timer_intervals = list(self.timer.keys())
630
+ return f'<{self.__class__.__name__} {status}>(capacity={self.capacity}, timers={timer_intervals})'
631
+
632
+ def _timer_loop(self, interval: float, topic: PyTopic, activate_time: Optional[datetime]) -> None:
633
+ """
634
+ Timer loop for custom intervals.
635
+
636
+ Args:
637
+ interval: Interval in seconds
638
+ topic: Topic to publish on
639
+ activate_time: Optional activation time
640
+ """
641
+ if activate_time is None:
642
+ scheduled_time = datetime.now()
643
+ else:
644
+ scheduled_time = activate_time
645
+
646
+ kwargs = {'interval': interval, 'trigger_time': scheduled_time}
647
+
648
+ while self.active:
649
+ sleep_time = (scheduled_time - datetime.now()).total_seconds()
650
+
651
+ if sleep_time > 0:
652
+ sleep(sleep_time)
653
+
654
+ self._publish(topic, (), kwargs, True, DEFAULT_MQ_SPIN_LIMIT, 0.0)
655
+
656
+ while scheduled_time < datetime.now():
657
+ scheduled_time += timedelta(seconds=interval)
658
+ kwargs['trigger_time'] = scheduled_time
659
+
660
+ def _minute_timer_loop(self, topic: PyTopic) -> None:
661
+ """
662
+ Minute-aligned timer loop.
663
+
664
+ Args:
665
+ topic: Topic to publish on
666
+ """
667
+ kwargs = {'interval': 60}
668
+
669
+ while self.active:
670
+ t = time()
671
+ scheduled_time = t // 60 * 60
672
+ next_time = scheduled_time + 60
673
+ sleep_time = next_time - t
674
+ sleep(sleep_time)
675
+ kwargs['timestamp'] = scheduled_time
676
+ self._publish(topic, (), kwargs, True, DEFAULT_MQ_SPIN_LIMIT, 0.0)
677
+
678
+ def _second_timer_loop(self, topic: PyTopic) -> None:
679
+ """
680
+ Second-aligned timer loop.
681
+
682
+ Args:
683
+ topic: Topic to publish on
684
+ """
685
+ kwargs = {'interval': 1}
686
+
687
+ while self.active:
688
+ t = time()
689
+ scheduled_time = t // 1
690
+ next_time = scheduled_time + 1
691
+ sleep_time = next_time - t
692
+ sleep(sleep_time)
693
+ kwargs['timestamp'] = scheduled_time
694
+ self._publish(topic, (), kwargs, True, DEFAULT_MQ_SPIN_LIMIT, 0.0)
695
+
696
+ def run_timer(self, interval: float, topic: PyTopic, activate_time: Optional[datetime] = None) -> None:
697
+ """
698
+ Run a timer with custom interval (blocking call).
699
+
700
+ Args:
701
+ interval: Interval in seconds
702
+ topic: Topic to publish on
703
+ activate_time: Optional activation time
704
+
705
+ Raises:
706
+ RuntimeError: If the engine is not active
707
+ """
708
+ if not self.active:
709
+ raise RuntimeError('EventEngine must be started before getting timer!')
710
+ self._timer_loop(interval, topic, activate_time)
711
+
712
+ def minute_timer(self, topic: PyTopic) -> None:
713
+ """
714
+ Run minute-aligned timer (blocking call).
715
+
716
+ Args:
717
+ topic: Topic to publish on
718
+
719
+ Raises:
720
+ RuntimeError: If the engine is not active
721
+ """
722
+ if not self.active:
723
+ raise RuntimeError('EventEngine must be started before getting timer!')
724
+ self._minute_timer_loop(topic)
725
+
726
+ def second_timer(self, topic: PyTopic) -> None:
727
+ """
728
+ Run second-aligned timer (blocking call).
729
+
730
+ Args:
731
+ topic: Topic to publish on
732
+
733
+ Raises:
734
+ RuntimeError: If the engine is not active
735
+ """
736
+ if not self.active:
737
+ raise RuntimeError('EventEngine must be started before getting timer!')
738
+ self._second_timer_loop(topic)
739
+
740
+ def get_timer(self, interval: float, activate_time: Optional[datetime] = None) -> PyTopic:
741
+ """
742
+ Get or create a timer with the specified interval.
743
+
744
+ Args:
745
+ interval: Interval in seconds
746
+ activate_time: Optional activation time
747
+
748
+ Returns:
749
+ PyTopic for the timer
750
+
751
+ Raises:
752
+ RuntimeError: If the engine is not active
753
+ """
754
+ if not self.active:
755
+ raise RuntimeError('EventEngine must be started before getting timer!')
756
+
757
+ if interval == 1:
758
+ topic = PyTopic('EventEngine.Internal.Timer.Second')
759
+ timer = Thread(target=self.second_timer, kwargs={'topic': topic})
760
+ elif interval == 60:
761
+ topic = PyTopic('EventEngine.Internal.Timer.Minute')
762
+ timer = Thread(target=self.minute_timer, kwargs={'topic': topic})
763
+ else:
764
+ topic = PyTopic.join(['EventEngine', 'Internal', 'Timer', str(interval)])
765
+ timer = Thread(target=self.run_timer,
766
+ kwargs={'interval': interval, 'topic': topic, 'activate_time': activate_time})
767
+
768
+ if interval not in self.timer:
769
+ self.timer[interval] = timer
770
+ timer.start()
771
+ else:
772
+ if activate_time is not None:
773
+ self.logger.debug(
774
+ f'Timer thread with interval [{timedelta(seconds=interval)}] already initialized! '
775
+ f'Argument [activate_time] takes no effect!'
776
+ )
777
+
778
+ return topic
779
+
780
+ def stop(self) -> None:
781
+ """Stop the engine and all timers."""
782
+ super().stop()
783
+
784
+ for timer in self.timer.values():
785
+ timer.join()
786
+
787
+ def clear(self) -> None:
788
+ """Clear all hooks and timers."""
789
+ super().clear()
790
+
791
+ for t in self.timer.values():
792
+ t.join(timeout=0)
793
+ self.timer.clear()