PyEventEngine 0.6.0.post3__cp314-cp314t-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.
- event_engine/__infra__.pxd +2 -0
- event_engine/__init__.pxd +2 -0
- event_engine/__init__.py +37 -0
- event_engine/base/__infra__.pxd +9 -0
- event_engine/base/__init__.pxd +9 -0
- event_engine/base/__init__.py +82 -0
- event_engine/base/c_allocator_protocol.c +10825 -0
- event_engine/base/c_allocator_protocol.cp314t-win_amd64.pyd +0 -0
- event_engine/base/c_allocator_protocol.h +23 -0
- event_engine/base/c_allocator_protocol.pxd +31 -0
- event_engine/base/c_allocator_protocol.pyx +99 -0
- event_engine/capi/__infra__.pxd +189 -0
- event_engine/capi/__init__.pxd +189 -0
- event_engine/capi/__init__.py +84 -0
- event_engine/capi/c_engine.c +28719 -0
- event_engine/capi/c_engine.cp314t-win_amd64.pyd +0 -0
- event_engine/capi/c_engine.h +393 -0
- event_engine/capi/c_engine.pxd +84 -0
- event_engine/capi/c_engine.pyi +424 -0
- event_engine/capi/c_engine.pyx +560 -0
- event_engine/capi/c_event.c +19929 -0
- event_engine/capi/c_event.cp314t-win_amd64.pyd +0 -0
- event_engine/capi/c_event.h +342 -0
- event_engine/capi/c_event.pxd +159 -0
- event_engine/capi/c_event.pyi +226 -0
- event_engine/capi/c_event.pyx +387 -0
- event_engine/capi/c_topic.c +30986 -0
- event_engine/capi/c_topic.cp314t-win_amd64.pyd +0 -0
- event_engine/capi/c_topic.h +1007 -0
- event_engine/capi/c_topic.pxd +147 -0
- event_engine/capi/c_topic.pyi +580 -0
- event_engine/capi/c_topic.pyx +725 -0
- event_engine/capi/fallback_engine.py +670 -0
- event_engine/includes/event_engine/base/c_allocator_protocol.c +10825 -0
- event_engine/includes/event_engine/base/c_allocator_protocol.h +23 -0
- event_engine/includes/event_engine/capi/c_engine.c +28719 -0
- event_engine/includes/event_engine/capi/c_engine.h +393 -0
- event_engine/includes/event_engine/capi/c_event.c +19929 -0
- event_engine/includes/event_engine/capi/c_event.h +342 -0
- event_engine/includes/event_engine/capi/c_topic.c +30986 -0
- event_engine/includes/event_engine/capi/c_topic.h +1007 -0
- event_engine/native/__init__.py +60 -0
- event_engine/native/_event.py +420 -0
- event_engine/native/_topic.py +171 -0
- event_engine/native/engine.py +746 -0
- event_engine/native/event.py +467 -0
- event_engine/native/topic.py +945 -0
- pyeventengine-0.6.0.post3.dist-info/METADATA +189 -0
- pyeventengine-0.6.0.post3.dist-info/RECORD +52 -0
- pyeventengine-0.6.0.post3.dist-info/WHEEL +5 -0
- pyeventengine-0.6.0.post3.dist-info/licenses/LICENSE +21 -0
- pyeventengine-0.6.0.post3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,746 @@
|
|
|
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
|
+
Args:
|
|
305
|
+
topic: Topic to unregister handler from
|
|
306
|
+
handler: Callable handler to remove
|
|
307
|
+
|
|
308
|
+
Raises:
|
|
309
|
+
KeyError: If no hook is registered for the given topic
|
|
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
|
+
raise KeyError(f'No EventHook registered for {topic_str}')
|
|
320
|
+
|
|
321
|
+
hook = hook_map[topic_str]
|
|
322
|
+
hook.remove_handler(handler)
|
|
323
|
+
|
|
324
|
+
# Remove hook if no handlers left
|
|
325
|
+
if len(hook) == 0:
|
|
326
|
+
del hook_map[topic_str]
|
|
327
|
+
|
|
328
|
+
def _clear(self) -> None:
|
|
329
|
+
"""Clear all hooks and handlers."""
|
|
330
|
+
# Clear all hooks
|
|
331
|
+
for hook in self._exact_topic_hooks.values():
|
|
332
|
+
hook.clear()
|
|
333
|
+
self._exact_topic_hooks.clear()
|
|
334
|
+
|
|
335
|
+
for hook in self._generic_topic_hooks.values():
|
|
336
|
+
hook.clear()
|
|
337
|
+
self._generic_topic_hooks.clear()
|
|
338
|
+
|
|
339
|
+
# --- Public API ---
|
|
340
|
+
|
|
341
|
+
def activate(self) -> None:
|
|
342
|
+
"""
|
|
343
|
+
Activate the event engine.
|
|
344
|
+
|
|
345
|
+
This method is called automatically when ``start`` is invoked.
|
|
346
|
+
It can also be called manually to prepare the engine for operation.
|
|
347
|
+
"""
|
|
348
|
+
self.active = True
|
|
349
|
+
|
|
350
|
+
def deactivate(self) -> None:
|
|
351
|
+
"""
|
|
352
|
+
Deactivate the event engine.
|
|
353
|
+
|
|
354
|
+
This method is called automatically when ``stop`` is invoked.
|
|
355
|
+
It can also be called manually to halt the engine's operation.
|
|
356
|
+
"""
|
|
357
|
+
self.active = False
|
|
358
|
+
|
|
359
|
+
def run(self) -> None:
|
|
360
|
+
"""
|
|
361
|
+
Run the event loop in the current thread (blocking).
|
|
362
|
+
"""
|
|
363
|
+
self._loop()
|
|
364
|
+
|
|
365
|
+
def start(self) -> None:
|
|
366
|
+
"""
|
|
367
|
+
Start the event loop in a dedicated background thread.
|
|
368
|
+
|
|
369
|
+
If the engine is already running, this method has no effect.
|
|
370
|
+
"""
|
|
371
|
+
if self.active:
|
|
372
|
+
self.logger.warning(f'{self} already started!')
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
self.active = True
|
|
376
|
+
self.engine = Thread(target=self.run, name='EventEngine')
|
|
377
|
+
self.engine.start()
|
|
378
|
+
self.logger.info(f'{self} started.')
|
|
379
|
+
|
|
380
|
+
def stop(self) -> None:
|
|
381
|
+
"""
|
|
382
|
+
Stop the event loop and wait for the background thread to terminate.
|
|
383
|
+
|
|
384
|
+
If the engine is already stopped, this method has no effect.
|
|
385
|
+
"""
|
|
386
|
+
if not self.active:
|
|
387
|
+
self.logger.warning('EventEngine already stopped!')
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
self.active = False
|
|
391
|
+
|
|
392
|
+
# Wake up the event loop if it's waiting
|
|
393
|
+
with self._not_empty:
|
|
394
|
+
self._not_empty.notify_all()
|
|
395
|
+
|
|
396
|
+
if self.engine:
|
|
397
|
+
self.engine.join()
|
|
398
|
+
self.engine = None
|
|
399
|
+
|
|
400
|
+
def clear(self) -> None:
|
|
401
|
+
"""
|
|
402
|
+
Unregister all event hooks.
|
|
403
|
+
|
|
404
|
+
Notes:
|
|
405
|
+
This method only works when the engine is stopped. If called while running,
|
|
406
|
+
an error is logged and no action is taken.
|
|
407
|
+
"""
|
|
408
|
+
if self.active:
|
|
409
|
+
self.logger.error('EventEngine must be stopped before cleared!')
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
self._clear()
|
|
413
|
+
|
|
414
|
+
def get(self, block: bool = True, max_spin: int = DEFAULT_MQ_SPIN_LIMIT, timeout: float = 0.0) -> PyMessagePayload:
|
|
415
|
+
"""
|
|
416
|
+
Retrieve an event from the internal queue.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
block: If ``True``, wait until an event is available.
|
|
420
|
+
max_spin: Maximum number of spin-loop iterations before blocking (ignored in pure Python).
|
|
421
|
+
timeout: Maximum wait time in seconds when blocking (``0.0`` means indefinite wait).
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
A ``PyMessagePayload`` instance.
|
|
425
|
+
|
|
426
|
+
Raises:
|
|
427
|
+
Empty: If ``block=False`` and the queue is empty.
|
|
428
|
+
"""
|
|
429
|
+
msg = self._get_message(block=block, timeout=timeout)
|
|
430
|
+
if msg is None:
|
|
431
|
+
raise Empty()
|
|
432
|
+
return msg
|
|
433
|
+
|
|
434
|
+
def put(self, topic: PyTopic, *args, block: bool = True, max_spin: int = DEFAULT_MQ_SPIN_LIMIT, timeout: float = 0.0, **kwargs) -> None:
|
|
435
|
+
"""
|
|
436
|
+
Publish an event to the queue (convenience alias for ``publish``).
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
topic: Must be an **exact** ``PyTopic`` (i.e., ``topic.is_exact`` must be ``True``).
|
|
440
|
+
*args: Positional arguments for the event.
|
|
441
|
+
block: If ``True``, wait if the queue is full.
|
|
442
|
+
max_spin: Spin count before blocking (ignored in pure Python).
|
|
443
|
+
timeout: Maximum wait time in seconds when blocking (``0.0`` = indefinite).
|
|
444
|
+
**kwargs: Keyword arguments for the event.
|
|
445
|
+
|
|
446
|
+
Raises:
|
|
447
|
+
Full: If ``block=False`` and the queue is full.
|
|
448
|
+
ValueError: If ``topic`` is not an exact topic.
|
|
449
|
+
"""
|
|
450
|
+
ret_code = self._publish(topic, args, kwargs, block, max_spin, timeout)
|
|
451
|
+
if ret_code:
|
|
452
|
+
raise Full()
|
|
453
|
+
|
|
454
|
+
def publish(self, topic: PyTopic, args: tuple, kwargs: dict, block: bool = True, timeout: float = 0.0) -> None:
|
|
455
|
+
"""
|
|
456
|
+
Publish an event to the queue.
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
topic: Must be an **exact** ``PyTopic`` (i.e., ``topic.is_exact`` must be ``True``).
|
|
460
|
+
args: Positional arguments for the event.
|
|
461
|
+
kwargs: Keyword arguments for the event.
|
|
462
|
+
block: If ``True``, wait if the queue is full.
|
|
463
|
+
timeout: Maximum wait time in seconds when blocking (``0.0`` = indefinite).
|
|
464
|
+
|
|
465
|
+
Raises:
|
|
466
|
+
Full: If ``block=False`` and the queue is full.
|
|
467
|
+
ValueError: If ``topic`` is not an exact topic.
|
|
468
|
+
"""
|
|
469
|
+
ret_code = self._publish(topic, args, kwargs, block, DEFAULT_MQ_SPIN_LIMIT, timeout)
|
|
470
|
+
if ret_code:
|
|
471
|
+
raise Full()
|
|
472
|
+
|
|
473
|
+
def register_hook(self, hook: EventHook) -> None:
|
|
474
|
+
"""
|
|
475
|
+
Register an ``EventHook`` for its associated topic.
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
hook: The hook to register.
|
|
479
|
+
|
|
480
|
+
Raises:
|
|
481
|
+
KeyError: If a hook is already registered for the same topic (exact or generic).
|
|
482
|
+
"""
|
|
483
|
+
self._register_hook(hook)
|
|
484
|
+
|
|
485
|
+
def unregister_hook(self, topic: PyTopic) -> EventHook:
|
|
486
|
+
"""
|
|
487
|
+
Unregister and return the ``EventHook`` associated with a topic.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
topic: The topic to unregister.
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
The unregistered ``EventHook``.
|
|
494
|
+
|
|
495
|
+
Raises:
|
|
496
|
+
KeyError: If no hook is registered for the given topic.
|
|
497
|
+
"""
|
|
498
|
+
return self._unregister_hook(topic)
|
|
499
|
+
|
|
500
|
+
def register_handler(self, topic: PyTopic, handler, deduplicate: bool = False) -> None:
|
|
501
|
+
"""
|
|
502
|
+
Register a handler for a topic (creates hook if needed).
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
topic: Topic to register handler for
|
|
506
|
+
handler: Callable handler
|
|
507
|
+
deduplicate: Skip if handler already registered
|
|
508
|
+
"""
|
|
509
|
+
self._register_handler(topic, handler, deduplicate)
|
|
510
|
+
|
|
511
|
+
def unregister_handler(self, topic: PyTopic, handler) -> None:
|
|
512
|
+
"""
|
|
513
|
+
Unregister a handler from a topic.
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
topic: Topic to unregister handler from
|
|
517
|
+
handler: Callable handler to remove
|
|
518
|
+
"""
|
|
519
|
+
self._unregister_handler(topic, handler)
|
|
520
|
+
|
|
521
|
+
def event_hooks(self):
|
|
522
|
+
"""Iterate over all registered event hooks."""
|
|
523
|
+
yield from self._exact_topic_hooks.values()
|
|
524
|
+
yield from self._generic_topic_hooks.values()
|
|
525
|
+
|
|
526
|
+
def topics(self):
|
|
527
|
+
"""Iterate over all registered topics."""
|
|
528
|
+
for hook in self.event_hooks():
|
|
529
|
+
yield hook.topic
|
|
530
|
+
|
|
531
|
+
def items(self):
|
|
532
|
+
"""Iterate over (topic, hook) pairs."""
|
|
533
|
+
for hook in self.event_hooks():
|
|
534
|
+
yield (hook.topic, hook)
|
|
535
|
+
|
|
536
|
+
@property
|
|
537
|
+
def capacity(self) -> int:
|
|
538
|
+
"""Get the queue capacity."""
|
|
539
|
+
return self._capacity
|
|
540
|
+
|
|
541
|
+
@property
|
|
542
|
+
def occupied(self) -> int:
|
|
543
|
+
"""Get the current number of messages in the queue."""
|
|
544
|
+
with self._lock:
|
|
545
|
+
return len(self._queue)
|
|
546
|
+
|
|
547
|
+
@property
|
|
548
|
+
def exact_topic_hook_map(self) -> dict:
|
|
549
|
+
"""Get a copy of the exact topic hook mapping."""
|
|
550
|
+
return self._exact_topic_hooks.copy()
|
|
551
|
+
|
|
552
|
+
@property
|
|
553
|
+
def generic_topic_hook_map(self) -> dict:
|
|
554
|
+
"""Get a copy of the generic topic hook mapping."""
|
|
555
|
+
return self._generic_topic_hooks.copy()
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
class EventEngineEx(EventEngine):
|
|
559
|
+
"""
|
|
560
|
+
Extended EventEngine with timer support and statistics tracking.
|
|
561
|
+
|
|
562
|
+
Provides timer functionality for periodic event triggering and uses EventHookEx
|
|
563
|
+
for handler statistics.
|
|
564
|
+
"""
|
|
565
|
+
|
|
566
|
+
__slots__ = ('timer',)
|
|
567
|
+
|
|
568
|
+
def __init__(self, capacity: int = DEFAULT_MQ_CAPACITY, logger: Optional[Logger] = None) -> None:
|
|
569
|
+
"""
|
|
570
|
+
Initialize EventEngineEx.
|
|
571
|
+
|
|
572
|
+
Args:
|
|
573
|
+
capacity: Maximum capacity of the message queue
|
|
574
|
+
logger: Optional logger instance
|
|
575
|
+
"""
|
|
576
|
+
super().__init__(capacity, logger)
|
|
577
|
+
self.timer: dict[float, Thread] = {}
|
|
578
|
+
|
|
579
|
+
def __repr__(self) -> str:
|
|
580
|
+
"""String representation including active timers."""
|
|
581
|
+
status = "active" if self.active else "idle"
|
|
582
|
+
timer_intervals = list(self.timer.keys())
|
|
583
|
+
return f'<{self.__class__.__name__} {status}>(capacity={self.capacity}, timers={timer_intervals})'
|
|
584
|
+
|
|
585
|
+
def _timer_loop(self, interval: float, topic: PyTopic, activate_time: Optional[datetime]) -> None:
|
|
586
|
+
"""
|
|
587
|
+
Timer loop for custom intervals.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
interval: Interval in seconds
|
|
591
|
+
topic: Topic to publish on
|
|
592
|
+
activate_time: Optional activation time
|
|
593
|
+
"""
|
|
594
|
+
if activate_time is None:
|
|
595
|
+
scheduled_time = datetime.now()
|
|
596
|
+
else:
|
|
597
|
+
scheduled_time = activate_time
|
|
598
|
+
|
|
599
|
+
kwargs = {'interval': interval, 'trigger_time': scheduled_time}
|
|
600
|
+
|
|
601
|
+
while self.active:
|
|
602
|
+
sleep_time = (scheduled_time - datetime.now()).total_seconds()
|
|
603
|
+
|
|
604
|
+
if sleep_time > 0:
|
|
605
|
+
sleep(sleep_time)
|
|
606
|
+
|
|
607
|
+
self._publish(topic, (), kwargs, True, DEFAULT_MQ_SPIN_LIMIT, 0.0)
|
|
608
|
+
|
|
609
|
+
while scheduled_time < datetime.now():
|
|
610
|
+
scheduled_time += timedelta(seconds=interval)
|
|
611
|
+
kwargs['trigger_time'] = scheduled_time
|
|
612
|
+
|
|
613
|
+
def _minute_timer_loop(self, topic: PyTopic) -> None:
|
|
614
|
+
"""
|
|
615
|
+
Minute-aligned timer loop.
|
|
616
|
+
|
|
617
|
+
Args:
|
|
618
|
+
topic: Topic to publish on
|
|
619
|
+
"""
|
|
620
|
+
kwargs = {'interval': 60}
|
|
621
|
+
|
|
622
|
+
while self.active:
|
|
623
|
+
t = time()
|
|
624
|
+
scheduled_time = t // 60 * 60
|
|
625
|
+
next_time = scheduled_time + 60
|
|
626
|
+
sleep_time = next_time - t
|
|
627
|
+
sleep(sleep_time)
|
|
628
|
+
kwargs['timestamp'] = scheduled_time
|
|
629
|
+
self._publish(topic, (), kwargs, True, DEFAULT_MQ_SPIN_LIMIT, 0.0)
|
|
630
|
+
|
|
631
|
+
def _second_timer_loop(self, topic: PyTopic) -> None:
|
|
632
|
+
"""
|
|
633
|
+
Second-aligned timer loop.
|
|
634
|
+
|
|
635
|
+
Args:
|
|
636
|
+
topic: Topic to publish on
|
|
637
|
+
"""
|
|
638
|
+
kwargs = {'interval': 1}
|
|
639
|
+
|
|
640
|
+
while self.active:
|
|
641
|
+
t = time()
|
|
642
|
+
scheduled_time = t // 1
|
|
643
|
+
next_time = scheduled_time + 1
|
|
644
|
+
sleep_time = next_time - t
|
|
645
|
+
sleep(sleep_time)
|
|
646
|
+
kwargs['timestamp'] = scheduled_time
|
|
647
|
+
self._publish(topic, (), kwargs, True, DEFAULT_MQ_SPIN_LIMIT, 0.0)
|
|
648
|
+
|
|
649
|
+
def run_timer(self, interval: float, topic: PyTopic, activate_time: Optional[datetime] = None) -> None:
|
|
650
|
+
"""
|
|
651
|
+
Run a timer with custom interval (blocking call).
|
|
652
|
+
|
|
653
|
+
Args:
|
|
654
|
+
interval: Interval in seconds
|
|
655
|
+
topic: Topic to publish on
|
|
656
|
+
activate_time: Optional activation time
|
|
657
|
+
|
|
658
|
+
Raises:
|
|
659
|
+
RuntimeError: If the engine is not active
|
|
660
|
+
"""
|
|
661
|
+
if not self.active:
|
|
662
|
+
raise RuntimeError('EventEngine must be started before getting timer!')
|
|
663
|
+
self._timer_loop(interval, topic, activate_time)
|
|
664
|
+
|
|
665
|
+
def minute_timer(self, topic: PyTopic) -> None:
|
|
666
|
+
"""
|
|
667
|
+
Run minute-aligned timer (blocking call).
|
|
668
|
+
|
|
669
|
+
Args:
|
|
670
|
+
topic: Topic to publish on
|
|
671
|
+
|
|
672
|
+
Raises:
|
|
673
|
+
RuntimeError: If the engine is not active
|
|
674
|
+
"""
|
|
675
|
+
if not self.active:
|
|
676
|
+
raise RuntimeError('EventEngine must be started before getting timer!')
|
|
677
|
+
self._minute_timer_loop(topic)
|
|
678
|
+
|
|
679
|
+
def second_timer(self, topic: PyTopic) -> None:
|
|
680
|
+
"""
|
|
681
|
+
Run second-aligned timer (blocking call).
|
|
682
|
+
|
|
683
|
+
Args:
|
|
684
|
+
topic: Topic to publish on
|
|
685
|
+
|
|
686
|
+
Raises:
|
|
687
|
+
RuntimeError: If the engine is not active
|
|
688
|
+
"""
|
|
689
|
+
if not self.active:
|
|
690
|
+
raise RuntimeError('EventEngine must be started before getting timer!')
|
|
691
|
+
self._second_timer_loop(topic)
|
|
692
|
+
|
|
693
|
+
def get_timer(self, interval: float, activate_time: Optional[datetime] = None) -> PyTopic:
|
|
694
|
+
"""
|
|
695
|
+
Get or create a timer with the specified interval.
|
|
696
|
+
|
|
697
|
+
Args:
|
|
698
|
+
interval: Interval in seconds
|
|
699
|
+
activate_time: Optional activation time
|
|
700
|
+
|
|
701
|
+
Returns:
|
|
702
|
+
PyTopic for the timer
|
|
703
|
+
|
|
704
|
+
Raises:
|
|
705
|
+
RuntimeError: If the engine is not active
|
|
706
|
+
"""
|
|
707
|
+
if not self.active:
|
|
708
|
+
raise RuntimeError('EventEngine must be started before getting timer!')
|
|
709
|
+
|
|
710
|
+
if interval == 1:
|
|
711
|
+
topic = PyTopic('EventEngine.Internal.Timer.Second')
|
|
712
|
+
timer = Thread(target=self.second_timer, kwargs={'topic': topic})
|
|
713
|
+
elif interval == 60:
|
|
714
|
+
topic = PyTopic('EventEngine.Internal.Timer.Minute')
|
|
715
|
+
timer = Thread(target=self.minute_timer, kwargs={'topic': topic})
|
|
716
|
+
else:
|
|
717
|
+
topic = PyTopic.join(['EventEngine', 'Internal', 'Timer', str(interval)])
|
|
718
|
+
timer = Thread(target=self.run_timer,
|
|
719
|
+
kwargs={'interval': interval, 'topic': topic, 'activate_time': activate_time})
|
|
720
|
+
|
|
721
|
+
if interval not in self.timer:
|
|
722
|
+
self.timer[interval] = timer
|
|
723
|
+
timer.start()
|
|
724
|
+
else:
|
|
725
|
+
if activate_time is not None:
|
|
726
|
+
self.logger.debug(
|
|
727
|
+
f'Timer thread with interval [{timedelta(seconds=interval)}] already initialized! '
|
|
728
|
+
f'Argument [activate_time] takes no effect!'
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
return topic
|
|
732
|
+
|
|
733
|
+
def stop(self) -> None:
|
|
734
|
+
"""Stop the engine and all timers."""
|
|
735
|
+
super().stop()
|
|
736
|
+
|
|
737
|
+
for timer in self.timer.values():
|
|
738
|
+
timer.join()
|
|
739
|
+
|
|
740
|
+
def clear(self) -> None:
|
|
741
|
+
"""Clear all hooks and timers."""
|
|
742
|
+
super().clear()
|
|
743
|
+
|
|
744
|
+
for t in self.timer.values():
|
|
745
|
+
t.join(timeout=0)
|
|
746
|
+
self.timer.clear()
|