PyEventEngine 0.6.2__cp315-cp315t-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.cp315t-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.cp315t-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.cp315t-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.cp315t-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.cp315t-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,707 @@
1
+ """
2
+ Pure Python fallback implementation of EventEngine for cross-platform compatibility.
3
+
4
+ This module provides Python-native implementations of EventEngine and EventEngineEx
5
+ that work on Windows and other platforms without requiring Cython/C extensions.
6
+
7
+ Uses:
8
+ - threading.Lock, threading.Condition for synchronization
9
+ - dict for topic-to-hook mappings (instead of ByteMap)
10
+ - Topic, MessagePayload, EventHook from c_topic and c_event modules
11
+ """
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 .c_event import EventHook, EventHookEx, MessagePayload
22
+ from .c_topic import Topic
23
+ from ..base import LOGGER
24
+
25
+ LOGGER = LOGGER.getChild('Event')
26
+
27
+ # Default constants (matching c_engine defaults)
28
+ DEFAULT_MQ_CAPACITY = 0x0fff
29
+ DEFAULT_MQ_SPIN_LIMIT = 0xffff
30
+ DEFAULT_MQ_TIMEOUT_SECONDS = 1.0
31
+
32
+
33
+ class Full(Exception):
34
+ """Exception raised when the message queue is full."""
35
+ pass
36
+
37
+
38
+ class Empty(Exception):
39
+ """Exception raised when the message queue is empty."""
40
+ pass
41
+
42
+
43
+ class EventEngine:
44
+ """
45
+ Pure Python implementation of EventEngine.
46
+
47
+ Uses dict-based topic-to-hook mappings and threading primitives for synchronization.
48
+ Compatible with Windows and all platforms supporting the threading module.
49
+ """
50
+
51
+ def __init__(self, capacity: int = DEFAULT_MQ_CAPACITY, logger: Optional[Logger] = None):
52
+ """
53
+ Initialize EventEngine.
54
+
55
+ Args:
56
+ capacity: Maximum capacity of the message queue
57
+ logger: Optional logger instance
58
+ """
59
+ self.logger = LOGGER.getChild('EventEngine') if logger is None else logger
60
+
61
+ # Message queue using deque with threading primitives
62
+ self._capacity = capacity
63
+ self._queue = deque(maxlen=capacity)
64
+ self._lock = threading.Lock()
65
+ self._not_empty = threading.Condition(self._lock)
66
+ self._not_full = threading.Condition(self._lock)
67
+
68
+ # Topic-to-hook mappings using dict (key: topic.value string)
69
+ self._exact_topic_hooks: dict[str, EventHook] = {}
70
+ self._generic_topic_hooks: dict[str, EventHook] = {}
71
+
72
+ # Sequence ID counter
73
+ self._seq_id = 0
74
+
75
+ # Engine state
76
+ self.active = False
77
+ self.engine: Optional[Thread] = None
78
+
79
+ def __len__(self) -> int:
80
+ """Return the total number of registered hooks."""
81
+ return len(self._exact_topic_hooks) + len(self._generic_topic_hooks)
82
+
83
+ def __repr__(self) -> str:
84
+ """String representation of the engine."""
85
+ return f'<{self.__class__.__name__} {"active" if self.active else "idle"}>(capacity={self.capacity})'
86
+
87
+ def _loop(self):
88
+ """Main event loop - consumes and processes messages."""
89
+ while self.active:
90
+ msg = self._get_message(block=True, timeout=DEFAULT_MQ_TIMEOUT_SECONDS)
91
+ if msg is not None:
92
+ self._trigger(msg)
93
+
94
+ def _get_message(self, block: bool = True, timeout: float = 0.0) -> Optional[MessagePayload]:
95
+ """
96
+ Get a message from the queue.
97
+
98
+ Args:
99
+ block: Whether to block if queue is empty
100
+ timeout: Timeout in seconds (0 means no timeout)
101
+
102
+ Returns:
103
+ MessagePayload or None if timeout/non-blocking and queue is empty
104
+ """
105
+ with self._not_empty:
106
+ if not block:
107
+ if len(self._queue) == 0:
108
+ return None
109
+ return self._queue.popleft()
110
+
111
+ # Blocking mode
112
+ if timeout > 0:
113
+ end_time = time() + timeout
114
+ while len(self._queue) == 0 and self.active:
115
+ remaining = end_time - time()
116
+ if remaining <= 0:
117
+ return None
118
+ self._not_empty.wait(timeout=remaining)
119
+ else:
120
+ # No timeout - wait indefinitely
121
+ while len(self._queue) == 0 and self.active:
122
+ self._not_empty.wait(timeout=DEFAULT_MQ_TIMEOUT_SECONDS)
123
+
124
+ if len(self._queue) == 0:
125
+ return None
126
+
127
+ msg = self._queue.popleft()
128
+ self._not_full.notify()
129
+ return msg
130
+
131
+ def _publish(self, topic: Topic, args: tuple, kwargs: dict, block: bool = True, max_spin: int = DEFAULT_MQ_SPIN_LIMIT, timeout: float = 0.0) -> int:
132
+ """
133
+ Publish a message to the queue.
134
+
135
+ Args:
136
+ topic: Topic for the message
137
+ args: Positional arguments
138
+ kwargs: Keyword arguments
139
+ block: Whether to block if queue is full
140
+ max_spin: Spin limit (ignored in pure Python implementation)
141
+ timeout: Timeout in seconds
142
+
143
+ Returns:
144
+ 0 on success, non-zero on failure
145
+ """
146
+ if not topic.is_exact:
147
+ raise ValueError('Topic must be all of exact parts')
148
+
149
+ # Create payload (the Cython MessagePayload ctor expects topic, args, kwargs)
150
+ payload = MessagePayload(topic, args, kwargs)
151
+ payload.seq_id = self._seq_id
152
+
153
+ with self._not_full:
154
+ if not block:
155
+ if len(self._queue) >= self._capacity:
156
+ return 1 # Queue full
157
+ self._queue.append(payload)
158
+ self._seq_id += 1
159
+ self._not_empty.notify()
160
+ return 0
161
+
162
+ # Blocking mode
163
+ if timeout > 0:
164
+ end_time = time() + timeout
165
+ while len(self._queue) >= self._capacity:
166
+ remaining = end_time - time()
167
+ if remaining <= 0:
168
+ return 1 # Timeout
169
+ self._not_full.wait(timeout=remaining)
170
+ else:
171
+ # No timeout - wait indefinitely
172
+ while len(self._queue) >= self._capacity:
173
+ self._not_full.wait(timeout=DEFAULT_MQ_TIMEOUT_SECONDS)
174
+
175
+ if len(self._queue) >= self._capacity:
176
+ return 1 # Failed
177
+
178
+ self._queue.append(payload)
179
+ self._seq_id += 1
180
+ self._not_empty.notify()
181
+ return 0
182
+
183
+ def _trigger(self, msg: MessagePayload):
184
+ """
185
+ Trigger event hooks matching the message topic.
186
+
187
+ Args:
188
+ msg: Message payload to dispatch
189
+ """
190
+ msg_topic = msg.topic
191
+
192
+ # Step 1: Match exact topic hooks
193
+ hook = self._exact_topic_hooks.get(msg_topic.value)
194
+ if hook:
195
+ hook.trigger(msg)
196
+
197
+ # Step 2: Match generic topic hooks (wildcards, patterns, etc.)
198
+ for hook_topic_str, hook in self._generic_topic_hooks.items():
199
+ # Use topic matching from Topic
200
+ if hook.topic.match(msg_topic).matched:
201
+ hook.trigger(msg)
202
+
203
+ def _register_hook(self, hook: EventHook):
204
+ """
205
+ Register an event hook.
206
+
207
+ Args:
208
+ hook: EventHook to register
209
+ """
210
+ topic = hook.topic
211
+ topic_str = topic.value
212
+
213
+ if topic.is_exact:
214
+ if topic_str in self._exact_topic_hooks and self._exact_topic_hooks[topic_str] is not hook:
215
+ raise KeyError(f'Another EventHook already registered for {topic_str}')
216
+ self._exact_topic_hooks[topic_str] = hook
217
+ else:
218
+ if topic_str in self._generic_topic_hooks and self._generic_topic_hooks[topic_str] is not hook:
219
+ raise KeyError(f'Another EventHook already registered for {topic_str}')
220
+ self._generic_topic_hooks[topic_str] = hook
221
+
222
+ def _unregister_hook(self, topic: Topic) -> EventHook:
223
+ """
224
+ Unregister an event hook by topic.
225
+
226
+ Args:
227
+ topic: Topic of the hook to unregister
228
+
229
+ Returns:
230
+ The unregistered EventHook
231
+ """
232
+ topic_str = topic.value
233
+
234
+ if topic.is_exact:
235
+ if topic_str not in self._exact_topic_hooks:
236
+ raise KeyError(f'No EventHook registered for {topic_str}')
237
+ return self._exact_topic_hooks.pop(topic_str)
238
+ else:
239
+ if topic_str not in self._generic_topic_hooks:
240
+ raise KeyError(f'No EventHook registered for {topic_str}')
241
+ return self._generic_topic_hooks.pop(topic_str)
242
+
243
+ def _register_handler(self, topic: Topic, handler, deduplicate: bool = False):
244
+ """
245
+ Register a handler for a topic (creates hook if needed).
246
+
247
+ Args:
248
+ topic: Topic to register handler for
249
+ handler: Callable handler
250
+ deduplicate: Whether to skip if handler already registered
251
+ """
252
+ topic_str = topic.value
253
+
254
+ if topic.is_exact:
255
+ hook_map = self._exact_topic_hooks
256
+ else:
257
+ hook_map = self._generic_topic_hooks
258
+
259
+ if topic_str not in hook_map:
260
+ hook = EventHook(topic, self.logger)
261
+ hook_map[topic_str] = hook
262
+ else:
263
+ hook = hook_map[topic_str]
264
+
265
+ hook.add_handler(handler, deduplicate=deduplicate)
266
+
267
+ def _unregister_handler(self, topic: Topic, handler):
268
+ """
269
+ Unregister a handler from a topic.
270
+
271
+ Mirrors the Cython engine: when no hook is registered for the topic,
272
+ an error is logged and the call is a no-op (no exception raised).
273
+
274
+ Args:
275
+ topic: Topic to unregister handler from
276
+ handler: Callable handler to remove
277
+ """
278
+ topic_str = topic.value
279
+
280
+ if topic.is_exact:
281
+ hook_map = self._exact_topic_hooks
282
+ else:
283
+ hook_map = self._generic_topic_hooks
284
+
285
+ if topic_str not in hook_map:
286
+ LOGGER.error(f'No EventHook registered for "{topic_str}"')
287
+ return
288
+
289
+ hook = hook_map[topic_str]
290
+ hook.remove_handler(handler)
291
+
292
+ # Remove hook if no handlers left
293
+ if len(hook) == 0:
294
+ del hook_map[topic_str]
295
+
296
+ def _clear(self):
297
+ """Clear all hooks and handlers."""
298
+ # Clear all hooks
299
+ for hook in self._exact_topic_hooks.values():
300
+ hook.clear()
301
+ self._exact_topic_hooks.clear()
302
+
303
+ for hook in self._generic_topic_hooks.values():
304
+ hook.clear()
305
+ self._generic_topic_hooks.clear()
306
+
307
+ # --- Public API ---
308
+
309
+ def activate(self):
310
+ """Activate the engine (sets active flag to True)."""
311
+ self.active = True
312
+
313
+ def deactivate(self):
314
+ """Deactivate the engine (sets active flag to False)."""
315
+ self.active = False
316
+
317
+ def run(self):
318
+ """Run the event loop (blocking call)."""
319
+ self._loop()
320
+
321
+ def start(self):
322
+ """Start the engine in a background thread."""
323
+ if self.active:
324
+ self.logger.warning(f'{self} already started!')
325
+ return
326
+ self.active = True
327
+ self.engine = Thread(target=self.run, name='EventEngine')
328
+ self.engine.start()
329
+ self.logger.info(f'{self} started.')
330
+
331
+ def stop(self):
332
+ """Stop the engine and wait for the background thread to finish."""
333
+ if not self.active:
334
+ self.logger.warning('EventEngine already stopped!')
335
+ return
336
+
337
+ self.active = False
338
+
339
+ # Wake up the event loop if it's waiting
340
+ with self._not_empty:
341
+ self._not_empty.notify_all()
342
+
343
+ if self.engine:
344
+ self.engine.join()
345
+
346
+ def clear(self):
347
+ """Clear all hooks and handlers (engine must be stopped first)."""
348
+ if self.active:
349
+ self.logger.error('EventEngine must be stopped before cleared!')
350
+ return
351
+
352
+ self._clear()
353
+
354
+ def get(self, block: bool = True, max_spin: int = DEFAULT_MQ_SPIN_LIMIT, timeout: float = 0.0) -> MessagePayload:
355
+ """
356
+ Get a message from the queue.
357
+
358
+ Args:
359
+ block: Whether to block if queue is empty
360
+ max_spin: Spin limit (ignored in pure Python)
361
+ timeout: Timeout in seconds
362
+
363
+ Returns:
364
+ MessagePayload
365
+
366
+ Raises:
367
+ Empty: If no message available
368
+ """
369
+ msg = self._get_message(block=block, timeout=timeout)
370
+ if msg is None:
371
+ raise Empty()
372
+ return msg
373
+
374
+ def put(self, topic: Topic, *args, block: bool = True, max_spin: int = DEFAULT_MQ_SPIN_LIMIT, timeout: float = 0.0, **kwargs):
375
+ """
376
+ Put a message into the queue.
377
+
378
+ Args:
379
+ topic: Topic for the message
380
+ *args: Positional arguments
381
+ block: Whether to block if queue is full
382
+ max_spin: Spin limit (ignored)
383
+ timeout: Timeout in seconds
384
+ **kwargs: Keyword arguments
385
+
386
+ Raises:
387
+ Full: If queue is full
388
+ """
389
+ ret_code = self._publish(topic, args, kwargs, block, max_spin, timeout)
390
+ if ret_code:
391
+ raise Full()
392
+
393
+ def publish(self, topic: Topic, args: tuple, kwargs: dict, block: bool = True, max_spin: int = DEFAULT_MQ_SPIN_LIMIT, timeout: float = 0.0):
394
+ """
395
+ Publish a message to the queue.
396
+
397
+ Args:
398
+ topic: Topic for the message
399
+ args: Positional arguments tuple
400
+ kwargs: Keyword arguments dict
401
+ block: Whether to block if queue is full
402
+ max_spin: Spin limit (ignored)
403
+ timeout: Timeout in seconds
404
+
405
+ Raises:
406
+ Full: If queue is full
407
+ """
408
+ ret_code = self._publish(topic, args, kwargs, block, max_spin, timeout)
409
+ if ret_code:
410
+ raise Full()
411
+
412
+ def register_hook(self, hook: EventHook):
413
+ """
414
+ Register an event hook.
415
+
416
+ Args:
417
+ hook: EventHook instance to register
418
+ """
419
+ self._register_hook(hook)
420
+
421
+ def unregister_hook(self, topic: Topic) -> EventHook:
422
+ """
423
+ Unregister a hook by topic.
424
+
425
+ Args:
426
+ topic: Topic of the hook to unregister
427
+
428
+ Returns:
429
+ The unregistered EventHook
430
+ """
431
+ return self._unregister_hook(topic)
432
+
433
+ def register_handler(self, topic: Topic, handler, deduplicate: bool = False):
434
+ """
435
+ Register a handler for a topic.
436
+
437
+ Args:
438
+ topic: Topic to register handler for
439
+ handler: Callable handler
440
+ deduplicate: Skip if handler already registered
441
+ """
442
+ self._register_handler(topic, handler, deduplicate)
443
+
444
+ def unregister_handler(self, topic: Topic, handler):
445
+ """
446
+ Unregister a handler from a topic.
447
+
448
+ Args:
449
+ topic: Topic to unregister handler from
450
+ handler: Callable handler to remove
451
+ """
452
+ self._unregister_handler(topic, handler)
453
+
454
+ def get_hook(self, topic: Topic) -> EventHook:
455
+ """
456
+ Retrieve the ``EventHook`` registered for a topic.
457
+
458
+ Args:
459
+ topic: Topic to look up.
460
+
461
+ Returns:
462
+ The registered ``EventHook``.
463
+
464
+ Raises:
465
+ KeyError: If no hook is registered for the given topic.
466
+ """
467
+ topic_str = topic.value
468
+ hook_map = self._exact_topic_hooks if topic.is_exact else self._generic_topic_hooks
469
+ if topic_str not in hook_map:
470
+ raise KeyError(f'No EventHook registered for {topic_str}')
471
+ return hook_map[topic_str]
472
+
473
+ def __getitem__(self, topic: Topic) -> list:
474
+ """
475
+ Return all hooks that would receive a message published for ``topic``:
476
+ the exact-topic hook (if registered) plus every matching generic hook.
477
+
478
+ Args:
479
+ topic: The topic to resolve hooks for.
480
+
481
+ Returns:
482
+ A list of ``EventHook`` instances (possibly empty).
483
+ """
484
+ out = []
485
+ exact = self._exact_topic_hooks.get(topic.value)
486
+ if exact:
487
+ out.append(exact)
488
+ for hook in self._generic_topic_hooks.values():
489
+ if hook.topic.match(topic).matched:
490
+ out.append(hook)
491
+ return out
492
+
493
+ def event_hooks(self):
494
+ """Iterate over all registered event hooks."""
495
+ yield from self._exact_topic_hooks.values()
496
+ yield from self._generic_topic_hooks.values()
497
+
498
+ def topics(self):
499
+ """Iterate over all registered topics."""
500
+ for hook in self.event_hooks():
501
+ yield hook.topic
502
+
503
+ def items(self):
504
+ """Iterate over (topic, hook) pairs."""
505
+ for hook in self.event_hooks():
506
+ yield (hook.topic, hook)
507
+
508
+ @property
509
+ def capacity(self) -> int:
510
+ """Get the queue capacity."""
511
+ return self._capacity
512
+
513
+ @property
514
+ def occupied(self) -> int:
515
+ """Get the current number of messages in the queue."""
516
+ with self._lock:
517
+ return len(self._queue)
518
+
519
+ @property
520
+ def seq_id(self) -> int:
521
+ """Get the sequence ID of the last published message (mirrors the Cython engine)."""
522
+ return self._seq_id
523
+
524
+ @property
525
+ def exact_topic_hook_map(self) -> dict:
526
+ """Get the exact topic hook mapping."""
527
+ return self._exact_topic_hooks.copy()
528
+
529
+ @property
530
+ def generic_topic_hook_map(self) -> dict:
531
+ """Get the generic topic hook mapping."""
532
+ return self._generic_topic_hooks.copy()
533
+
534
+
535
+ class EventEngineEx(EventEngine):
536
+ """
537
+ Extended EventEngine with timer support.
538
+
539
+ Provides timer functionality for periodic event triggering.
540
+ """
541
+
542
+ def __init__(self, capacity: int = DEFAULT_MQ_CAPACITY, logger: Optional[Logger] = None):
543
+ """
544
+ Initialize EventEngineEx.
545
+
546
+ Args:
547
+ capacity: Maximum capacity of the message queue
548
+ logger: Optional logger instance
549
+ """
550
+ super().__init__(capacity, logger)
551
+ self.timer: dict[float, Thread] = {}
552
+
553
+ def __repr__(self) -> str:
554
+ """String representation including active timers."""
555
+ return (f'<{self.__class__.__name__} {"active" if self.active else "idle"}>'
556
+ f'(capacity={self.capacity}, timers={list(self.timer.keys())})')
557
+
558
+ def _timer_loop(self, interval: float, topic: Topic, activate_time: Optional[datetime]):
559
+ """
560
+ Timer loop for custom intervals.
561
+
562
+ Args:
563
+ interval: Interval in seconds
564
+ topic: Topic to publish on
565
+ activate_time: Optional activation time
566
+ """
567
+ if activate_time is None:
568
+ scheduled_time = datetime.now()
569
+ else:
570
+ scheduled_time = activate_time
571
+
572
+ kwargs = {'interval': interval, 'trigger_time': scheduled_time}
573
+
574
+ while self.active:
575
+ sleep_time = (scheduled_time - datetime.now()).total_seconds()
576
+
577
+ if sleep_time > 0:
578
+ sleep(sleep_time)
579
+
580
+ self._publish(topic, (), kwargs, True, DEFAULT_MQ_SPIN_LIMIT, 0.0)
581
+
582
+ while scheduled_time < datetime.now():
583
+ scheduled_time += timedelta(seconds=interval)
584
+ kwargs['trigger_time'] = scheduled_time
585
+
586
+ def _minute_timer_loop(self, topic: Topic):
587
+ """
588
+ Minute-aligned timer loop.
589
+
590
+ Args:
591
+ topic: Topic to publish on
592
+ """
593
+ kwargs = {'interval': 60}
594
+
595
+ while self.active:
596
+ t = time()
597
+ scheduled_time = t // 60 * 60
598
+ next_time = scheduled_time + 60
599
+ sleep_time = next_time - t
600
+ sleep(sleep_time)
601
+ kwargs['timestamp'] = scheduled_time
602
+ self._publish(topic, (), kwargs, True, DEFAULT_MQ_SPIN_LIMIT, 0.0)
603
+
604
+ def _second_timer_loop(self, topic: Topic):
605
+ """
606
+ Second-aligned timer loop.
607
+
608
+ Args:
609
+ topic: Topic to publish on
610
+ """
611
+ kwargs = {'interval': 1}
612
+
613
+ while self.active:
614
+ t = time()
615
+ scheduled_time = t // 1
616
+ next_time = scheduled_time + 1
617
+ sleep_time = next_time - t
618
+ sleep(sleep_time)
619
+ kwargs['timestamp'] = scheduled_time
620
+ self._publish(topic, (), kwargs, True, DEFAULT_MQ_SPIN_LIMIT, 0.0)
621
+
622
+ def run_timer(self, interval: float, topic: Topic, activate_time: Optional[datetime] = None):
623
+ """
624
+ Run a timer with custom interval (blocking call).
625
+
626
+ Args:
627
+ interval: Interval in seconds
628
+ topic: Topic to publish on
629
+ activate_time: Optional activation time
630
+ """
631
+ if not self.active:
632
+ raise RuntimeError('EventEngine must be started before getting timer!')
633
+ self._timer_loop(interval, topic, activate_time)
634
+
635
+ def minute_timer(self, topic: Topic):
636
+ """
637
+ Run minute-aligned timer (blocking call).
638
+
639
+ Args:
640
+ topic: Topic to publish on
641
+ """
642
+ if not self.active:
643
+ raise RuntimeError('EventEngine must be started before getting timer!')
644
+ self._minute_timer_loop(topic)
645
+
646
+ def second_timer(self, topic: Topic):
647
+ """
648
+ Run second-aligned timer (blocking call).
649
+
650
+ Args:
651
+ topic: Topic to publish on
652
+ """
653
+ if not self.active:
654
+ raise RuntimeError('EventEngine must be started before getting timer!')
655
+ self._second_timer_loop(topic)
656
+
657
+ def get_timer(self, interval: float, activate_time: Optional[datetime] = None) -> Topic:
658
+ """
659
+ Get or create a timer with the specified interval.
660
+
661
+ Args:
662
+ interval: Interval in seconds
663
+ activate_time: Optional activation time
664
+
665
+ Returns:
666
+ Topic for the timer
667
+ """
668
+ if not self.active:
669
+ raise RuntimeError('EventEngine must be started before getting timer!')
670
+
671
+ if interval == 1:
672
+ topic = Topic('EventEngine.Internal.Timer.Second')
673
+ timer = Thread(target=self.second_timer, kwargs={'topic': topic})
674
+ elif interval == 60:
675
+ topic = Topic('EventEngine.Internal.Timer.Minute')
676
+ timer = Thread(target=self.minute_timer, kwargs={'topic': topic})
677
+ else:
678
+ topic = Topic.join(['EventEngine', 'Internal', 'Timer', str(interval)])
679
+ timer = Thread(target=self.run_timer,
680
+ kwargs={'interval': interval, 'topic': topic, 'activate_time': activate_time})
681
+
682
+ if interval not in self.timer:
683
+ self.timer[interval] = timer
684
+ timer.start()
685
+ else:
686
+ if activate_time is not None:
687
+ self.logger.debug(
688
+ f'Timer thread with interval [{timedelta(seconds=interval)}] already initialized! '
689
+ f'Argument [activate_time] takes no effect!'
690
+ )
691
+
692
+ return topic
693
+
694
+ def stop(self):
695
+ """Stop the engine and all timers."""
696
+ super().stop()
697
+
698
+ for timer in self.timer.values():
699
+ timer.join()
700
+
701
+ def clear(self):
702
+ """Clear all hooks and timers."""
703
+ super().clear()
704
+
705
+ for t in self.timer.values():
706
+ t.join(timeout=0)
707
+ self.timer.clear()