kstlib 0.0.1a0__py3-none-any.whl → 1.0.0__py3-none-any.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 (166) hide show
  1. kstlib/__init__.py +266 -1
  2. kstlib/__main__.py +16 -0
  3. kstlib/alerts/__init__.py +110 -0
  4. kstlib/alerts/channels/__init__.py +36 -0
  5. kstlib/alerts/channels/base.py +197 -0
  6. kstlib/alerts/channels/email.py +227 -0
  7. kstlib/alerts/channels/slack.py +389 -0
  8. kstlib/alerts/exceptions.py +72 -0
  9. kstlib/alerts/manager.py +651 -0
  10. kstlib/alerts/models.py +142 -0
  11. kstlib/alerts/throttle.py +263 -0
  12. kstlib/auth/__init__.py +139 -0
  13. kstlib/auth/callback.py +399 -0
  14. kstlib/auth/config.py +502 -0
  15. kstlib/auth/errors.py +127 -0
  16. kstlib/auth/models.py +316 -0
  17. kstlib/auth/providers/__init__.py +14 -0
  18. kstlib/auth/providers/base.py +393 -0
  19. kstlib/auth/providers/oauth2.py +645 -0
  20. kstlib/auth/providers/oidc.py +821 -0
  21. kstlib/auth/session.py +338 -0
  22. kstlib/auth/token.py +482 -0
  23. kstlib/cache/__init__.py +50 -0
  24. kstlib/cache/decorator.py +261 -0
  25. kstlib/cache/strategies.py +516 -0
  26. kstlib/cli/__init__.py +8 -0
  27. kstlib/cli/app.py +195 -0
  28. kstlib/cli/commands/__init__.py +5 -0
  29. kstlib/cli/commands/auth/__init__.py +39 -0
  30. kstlib/cli/commands/auth/common.py +122 -0
  31. kstlib/cli/commands/auth/login.py +325 -0
  32. kstlib/cli/commands/auth/logout.py +74 -0
  33. kstlib/cli/commands/auth/providers.py +57 -0
  34. kstlib/cli/commands/auth/status.py +291 -0
  35. kstlib/cli/commands/auth/token.py +199 -0
  36. kstlib/cli/commands/auth/whoami.py +106 -0
  37. kstlib/cli/commands/config.py +89 -0
  38. kstlib/cli/commands/ops/__init__.py +39 -0
  39. kstlib/cli/commands/ops/attach.py +49 -0
  40. kstlib/cli/commands/ops/common.py +269 -0
  41. kstlib/cli/commands/ops/list_sessions.py +252 -0
  42. kstlib/cli/commands/ops/logs.py +49 -0
  43. kstlib/cli/commands/ops/start.py +98 -0
  44. kstlib/cli/commands/ops/status.py +138 -0
  45. kstlib/cli/commands/ops/stop.py +60 -0
  46. kstlib/cli/commands/rapi/__init__.py +60 -0
  47. kstlib/cli/commands/rapi/call.py +341 -0
  48. kstlib/cli/commands/rapi/list.py +99 -0
  49. kstlib/cli/commands/rapi/show.py +206 -0
  50. kstlib/cli/commands/secrets/__init__.py +35 -0
  51. kstlib/cli/commands/secrets/common.py +425 -0
  52. kstlib/cli/commands/secrets/decrypt.py +88 -0
  53. kstlib/cli/commands/secrets/doctor.py +743 -0
  54. kstlib/cli/commands/secrets/encrypt.py +242 -0
  55. kstlib/cli/commands/secrets/shred.py +96 -0
  56. kstlib/cli/common.py +86 -0
  57. kstlib/config/__init__.py +76 -0
  58. kstlib/config/exceptions.py +110 -0
  59. kstlib/config/export.py +225 -0
  60. kstlib/config/loader.py +963 -0
  61. kstlib/config/sops.py +287 -0
  62. kstlib/db/__init__.py +54 -0
  63. kstlib/db/aiosqlcipher.py +137 -0
  64. kstlib/db/cipher.py +112 -0
  65. kstlib/db/database.py +367 -0
  66. kstlib/db/exceptions.py +25 -0
  67. kstlib/db/pool.py +302 -0
  68. kstlib/helpers/__init__.py +35 -0
  69. kstlib/helpers/exceptions.py +11 -0
  70. kstlib/helpers/time_trigger.py +396 -0
  71. kstlib/kstlib.conf.yml +890 -0
  72. kstlib/limits.py +963 -0
  73. kstlib/logging/__init__.py +108 -0
  74. kstlib/logging/manager.py +633 -0
  75. kstlib/mail/__init__.py +42 -0
  76. kstlib/mail/builder.py +626 -0
  77. kstlib/mail/exceptions.py +27 -0
  78. kstlib/mail/filesystem.py +248 -0
  79. kstlib/mail/transport.py +224 -0
  80. kstlib/mail/transports/__init__.py +19 -0
  81. kstlib/mail/transports/gmail.py +268 -0
  82. kstlib/mail/transports/resend.py +324 -0
  83. kstlib/mail/transports/smtp.py +326 -0
  84. kstlib/meta.py +72 -0
  85. kstlib/metrics/__init__.py +88 -0
  86. kstlib/metrics/decorators.py +1090 -0
  87. kstlib/metrics/exceptions.py +14 -0
  88. kstlib/monitoring/__init__.py +116 -0
  89. kstlib/monitoring/_styles.py +163 -0
  90. kstlib/monitoring/cell.py +57 -0
  91. kstlib/monitoring/config.py +424 -0
  92. kstlib/monitoring/delivery.py +579 -0
  93. kstlib/monitoring/exceptions.py +63 -0
  94. kstlib/monitoring/image.py +220 -0
  95. kstlib/monitoring/kv.py +79 -0
  96. kstlib/monitoring/list.py +69 -0
  97. kstlib/monitoring/metric.py +88 -0
  98. kstlib/monitoring/monitoring.py +341 -0
  99. kstlib/monitoring/renderer.py +139 -0
  100. kstlib/monitoring/service.py +392 -0
  101. kstlib/monitoring/table.py +129 -0
  102. kstlib/monitoring/types.py +56 -0
  103. kstlib/ops/__init__.py +86 -0
  104. kstlib/ops/base.py +148 -0
  105. kstlib/ops/container.py +577 -0
  106. kstlib/ops/exceptions.py +209 -0
  107. kstlib/ops/manager.py +407 -0
  108. kstlib/ops/models.py +176 -0
  109. kstlib/ops/tmux.py +372 -0
  110. kstlib/ops/validators.py +287 -0
  111. kstlib/py.typed +0 -0
  112. kstlib/rapi/__init__.py +118 -0
  113. kstlib/rapi/client.py +875 -0
  114. kstlib/rapi/config.py +861 -0
  115. kstlib/rapi/credentials.py +887 -0
  116. kstlib/rapi/exceptions.py +213 -0
  117. kstlib/resilience/__init__.py +101 -0
  118. kstlib/resilience/circuit_breaker.py +440 -0
  119. kstlib/resilience/exceptions.py +95 -0
  120. kstlib/resilience/heartbeat.py +491 -0
  121. kstlib/resilience/rate_limiter.py +506 -0
  122. kstlib/resilience/shutdown.py +417 -0
  123. kstlib/resilience/watchdog.py +637 -0
  124. kstlib/secrets/__init__.py +29 -0
  125. kstlib/secrets/exceptions.py +19 -0
  126. kstlib/secrets/models.py +62 -0
  127. kstlib/secrets/providers/__init__.py +79 -0
  128. kstlib/secrets/providers/base.py +58 -0
  129. kstlib/secrets/providers/environment.py +66 -0
  130. kstlib/secrets/providers/keyring.py +107 -0
  131. kstlib/secrets/providers/kms.py +223 -0
  132. kstlib/secrets/providers/kwargs.py +101 -0
  133. kstlib/secrets/providers/sops.py +209 -0
  134. kstlib/secrets/resolver.py +221 -0
  135. kstlib/secrets/sensitive.py +130 -0
  136. kstlib/secure/__init__.py +23 -0
  137. kstlib/secure/fs.py +194 -0
  138. kstlib/secure/permissions.py +70 -0
  139. kstlib/ssl.py +347 -0
  140. kstlib/ui/__init__.py +23 -0
  141. kstlib/ui/exceptions.py +26 -0
  142. kstlib/ui/panels.py +484 -0
  143. kstlib/ui/spinner.py +864 -0
  144. kstlib/ui/tables.py +382 -0
  145. kstlib/utils/__init__.py +48 -0
  146. kstlib/utils/dict.py +36 -0
  147. kstlib/utils/formatting.py +338 -0
  148. kstlib/utils/http_trace.py +237 -0
  149. kstlib/utils/lazy.py +49 -0
  150. kstlib/utils/secure_delete.py +205 -0
  151. kstlib/utils/serialization.py +247 -0
  152. kstlib/utils/text.py +56 -0
  153. kstlib/utils/validators.py +124 -0
  154. kstlib/websocket/__init__.py +97 -0
  155. kstlib/websocket/exceptions.py +214 -0
  156. kstlib/websocket/manager.py +1102 -0
  157. kstlib/websocket/models.py +361 -0
  158. kstlib-1.0.0.dist-info/METADATA +201 -0
  159. kstlib-1.0.0.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.0.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
  163. kstlib-0.0.1a0.dist-info/METADATA +0 -29
  164. kstlib-0.0.1a0.dist-info/RECORD +0 -6
  165. kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
  166. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,35 @@
1
+ """Helper utilities for time-based operations.
2
+
3
+ This module provides utilities for time-based triggering and scheduling:
4
+
5
+ - **TimeTrigger**: Detect time boundaries (modulo-based) for periodic operations
6
+
7
+ Examples:
8
+ Check if current time is at a 4-hour boundary:
9
+
10
+ >>> from kstlib.helpers import TimeTrigger
11
+ >>> trigger = TimeTrigger("4h")
12
+ >>> trigger.is_at_boundary() # doctest: +SKIP
13
+ True # If current time is 00:00, 04:00, 08:00, etc.
14
+
15
+ Get time until next boundary:
16
+
17
+ >>> trigger.time_until_next() # doctest: +SKIP
18
+ 3542.5 # Seconds until next 4-hour mark
19
+
20
+ Use with WebSocket for periodic restart:
21
+
22
+ >>> trigger = TimeTrigger("8h")
23
+ >>> if trigger.should_trigger(margin=60): # doctest: +SKIP
24
+ ... await ws.shutdown()
25
+ ... await ws.connect()
26
+ """
27
+
28
+ from kstlib.helpers.exceptions import InvalidModuloError, TimeTriggerError
29
+ from kstlib.helpers.time_trigger import TimeTrigger
30
+
31
+ __all__ = [
32
+ "InvalidModuloError",
33
+ "TimeTrigger",
34
+ "TimeTriggerError",
35
+ ]
@@ -0,0 +1,11 @@
1
+ """Exceptions for the helpers module."""
2
+
3
+ from kstlib.config.exceptions import KstlibError
4
+
5
+
6
+ class TimeTriggerError(KstlibError):
7
+ """Base exception for TimeTrigger errors."""
8
+
9
+
10
+ class InvalidModuloError(TimeTriggerError):
11
+ """Raised when modulo string is invalid or out of bounds."""
@@ -0,0 +1,396 @@
1
+ """Time-based trigger for periodic operations.
2
+
3
+ This module provides a TimeTrigger class for detecting time boundaries
4
+ and scheduling periodic operations based on modulo intervals.
5
+
6
+ Typical use cases:
7
+ - Restart WebSocket connections at market boundaries (4h, 8h candles)
8
+ - Execute periodic tasks at fixed intervals aligned to clock time
9
+ - Coordinate operations with exchange candlestick closes
10
+
11
+ Examples:
12
+ Basic boundary detection:
13
+
14
+ >>> from kstlib.helpers import TimeTrigger
15
+ >>> trigger = TimeTrigger("4h")
16
+ >>> trigger.time_until_next() # doctest: +SKIP
17
+ 3542.5
18
+
19
+ With callback for async operations:
20
+
21
+ >>> import asyncio
22
+ >>> async def restart_ws(): # doctest: +SKIP
23
+ ... print("Restarting WebSocket...")
24
+ >>> trigger = TimeTrigger("8h")
25
+ >>> await trigger.wait_for_boundary() # doctest: +SKIP
26
+ >>> await restart_ws() # doctest: +SKIP
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import re
33
+ import threading
34
+ from dataclasses import dataclass
35
+ from typing import TYPE_CHECKING
36
+
37
+ import pendulum
38
+ from typing_extensions import Self
39
+
40
+ from kstlib.helpers.exceptions import InvalidModuloError
41
+
42
+ if TYPE_CHECKING:
43
+ from collections.abc import Awaitable, Callable
44
+
45
+ __all__ = ["TimeTrigger", "TimeTriggerStats"]
46
+
47
+ # Regex for parsing modulo strings like "30m", "4h", "1d"
48
+ MODULO_PATTERN = re.compile(r"^(\d+)\s*(s|m|h|d)$", re.IGNORECASE)
49
+
50
+ # Unit multipliers to seconds
51
+ UNIT_SECONDS = {
52
+ "s": 1,
53
+ "m": 60,
54
+ "h": 3600,
55
+ "d": 86400,
56
+ }
57
+
58
+ # Hard limits for modulo (deep defense)
59
+ HARD_MIN_MODULO_SECONDS = 60 # Minimum 1 minute
60
+ HARD_MAX_MODULO_SECONDS = 86400 * 7 # Maximum 1 week
61
+
62
+
63
+ def _parse_modulo(modulo: str) -> int:
64
+ """Parse modulo string to seconds.
65
+
66
+ Args:
67
+ modulo: Duration string like "30m", "4h", "1d".
68
+
69
+ Returns:
70
+ Duration in seconds.
71
+
72
+ Raises:
73
+ InvalidModuloError: If format is invalid or out of bounds.
74
+ """
75
+ match = MODULO_PATTERN.match(modulo.strip())
76
+ if not match:
77
+ msg = (
78
+ f"Invalid modulo format: '{modulo}'. "
79
+ "Expected format: <number><unit> where unit is s, m, h, or d. "
80
+ "Examples: '30m', '4h', '1d'"
81
+ )
82
+ raise InvalidModuloError(msg)
83
+
84
+ value = int(match.group(1))
85
+ unit = match.group(2).lower()
86
+ seconds = value * UNIT_SECONDS[unit]
87
+
88
+ if seconds < HARD_MIN_MODULO_SECONDS:
89
+ msg = f"Modulo too small: {seconds}s < {HARD_MIN_MODULO_SECONDS}s minimum"
90
+ raise InvalidModuloError(msg)
91
+
92
+ if seconds > HARD_MAX_MODULO_SECONDS:
93
+ msg = f"Modulo too large: {seconds}s > {HARD_MAX_MODULO_SECONDS}s maximum"
94
+ raise InvalidModuloError(msg)
95
+
96
+ return seconds
97
+
98
+
99
+ @dataclass
100
+ class TimeTriggerStats:
101
+ """Statistics for TimeTrigger operations.
102
+
103
+ Attributes:
104
+ triggers_fired: Number of times boundary was triggered.
105
+ callbacks_invoked: Number of callback invocations.
106
+ last_trigger_at: ISO timestamp of last trigger.
107
+ """
108
+
109
+ triggers_fired: int = 0
110
+ callbacks_invoked: int = 0
111
+ last_trigger_at: str | None = None
112
+
113
+ def record_trigger(self) -> None:
114
+ """Record a trigger event."""
115
+ self.triggers_fired += 1
116
+ self.last_trigger_at = pendulum.now("UTC").to_iso8601_string()
117
+
118
+ def record_callback(self) -> None:
119
+ """Record a callback invocation."""
120
+ self.callbacks_invoked += 1
121
+
122
+
123
+ class TimeTrigger:
124
+ """Time-based trigger for detecting modulo boundaries.
125
+
126
+ Detects when current time aligns with periodic intervals (boundaries).
127
+ Useful for coordinating operations with market candle closes or
128
+ scheduling periodic restarts.
129
+
130
+ Attributes:
131
+ modulo: Original modulo string (e.g., "4h").
132
+ modulo_seconds: Modulo duration in seconds.
133
+ stats: Trigger statistics.
134
+
135
+ Args:
136
+ modulo: Duration string for the interval (e.g., "30m", "4h", "8h", "1d").
137
+ timezone: Timezone for calculations (default: UTC).
138
+
139
+ Raises:
140
+ InvalidModuloError: If modulo format is invalid or out of bounds.
141
+
142
+ Examples:
143
+ Create a 4-hour trigger:
144
+
145
+ >>> trigger = TimeTrigger("4h")
146
+ >>> trigger.modulo_seconds
147
+ 14400
148
+
149
+ Check boundary status:
150
+
151
+ >>> trigger.is_at_boundary() # doctest: +SKIP
152
+ False
153
+ >>> trigger.time_until_next() # doctest: +SKIP
154
+ 1234.5
155
+
156
+ Create with different timezone:
157
+
158
+ >>> trigger = TimeTrigger("1d", timezone="Europe/Paris")
159
+ >>> trigger.timezone
160
+ 'Europe/Paris'
161
+ """
162
+
163
+ def __init__(
164
+ self,
165
+ modulo: str,
166
+ *,
167
+ timezone: str = "UTC",
168
+ ) -> None:
169
+ """Initialize TimeTrigger.
170
+
171
+ Args:
172
+ modulo: Duration string (e.g., "30m", "4h", "1d").
173
+ timezone: Timezone for boundary calculations.
174
+ """
175
+ self._modulo_str = modulo
176
+ self._modulo_seconds = _parse_modulo(modulo)
177
+ self._timezone = timezone
178
+ self._stats = TimeTriggerStats()
179
+
180
+ # Async loop state
181
+ self._running = False
182
+ self._stop_event = threading.Event()
183
+ self._async_task: asyncio.Task[None] | None = None
184
+
185
+ @property
186
+ def modulo(self) -> str:
187
+ """Return the original modulo string."""
188
+ return self._modulo_str
189
+
190
+ @property
191
+ def modulo_seconds(self) -> int:
192
+ """Return the modulo duration in seconds."""
193
+ return self._modulo_seconds
194
+
195
+ @property
196
+ def timezone(self) -> str:
197
+ """Return the timezone used for calculations."""
198
+ return self._timezone
199
+
200
+ @property
201
+ def stats(self) -> TimeTriggerStats:
202
+ """Return trigger statistics."""
203
+ return self._stats
204
+
205
+ def _get_current_timestamp(self) -> float:
206
+ """Get current Unix timestamp."""
207
+ return pendulum.now(self._timezone).timestamp()
208
+
209
+ def _seconds_into_period(self) -> float:
210
+ """Get seconds elapsed since last boundary."""
211
+ return self._get_current_timestamp() % self._modulo_seconds
212
+
213
+ def time_until_next(self) -> float:
214
+ """Calculate seconds until next boundary.
215
+
216
+ Returns:
217
+ Seconds remaining until the next modulo boundary.
218
+
219
+ Examples:
220
+ >>> trigger = TimeTrigger("4h")
221
+ >>> remaining = trigger.time_until_next() # doctest: +SKIP
222
+ >>> 0 <= remaining <= 14400 # doctest: +SKIP
223
+ True
224
+ """
225
+ elapsed = self._seconds_into_period()
226
+ if elapsed == 0:
227
+ return 0.0
228
+ return self._modulo_seconds - elapsed
229
+
230
+ def is_at_boundary(self, margin: float = 1.0) -> bool:
231
+ """Check if current time is at a boundary.
232
+
233
+ A boundary is when the timestamp is divisible by the modulo.
234
+ The margin allows for slight timing imprecision.
235
+
236
+ Args:
237
+ margin: Tolerance in seconds around the boundary (default: 1.0).
238
+
239
+ Returns:
240
+ True if within margin seconds of a boundary.
241
+
242
+ Examples:
243
+ >>> trigger = TimeTrigger("4h")
244
+ >>> trigger.is_at_boundary() # doctest: +SKIP
245
+ True # If time is 00:00:00, 04:00:00, etc.
246
+ >>> trigger.is_at_boundary(margin=5.0) # doctest: +SKIP
247
+ True # If time is within 5 seconds of boundary
248
+ """
249
+ elapsed = self._seconds_into_period()
250
+ # Check if we're near 0 (just passed) or near modulo (about to hit)
251
+ return elapsed <= margin or (self._modulo_seconds - elapsed) <= margin
252
+
253
+ def should_trigger(self, margin: float = 30.0) -> bool:
254
+ """Check if trigger should fire (boundary approaching).
255
+
256
+ Use this to prepare for an upcoming boundary (e.g., start shutdown
257
+ sequence before the boundary hits).
258
+
259
+ Args:
260
+ margin: Seconds before boundary to trigger (default: 30.0).
261
+
262
+ Returns:
263
+ True if boundary is within margin seconds.
264
+
265
+ Examples:
266
+ >>> trigger = TimeTrigger("4h")
267
+ >>> if trigger.should_trigger(margin=60): # doctest: +SKIP
268
+ ... print("Boundary in less than 60 seconds!")
269
+ """
270
+ remaining = self.time_until_next()
271
+ return remaining <= margin
272
+
273
+ def next_boundary(self) -> pendulum.DateTime:
274
+ """Get the datetime of the next boundary.
275
+
276
+ Returns:
277
+ Pendulum DateTime of the next boundary.
278
+
279
+ Examples:
280
+ >>> trigger = TimeTrigger("4h")
281
+ >>> next_time = trigger.next_boundary() # doctest: +SKIP
282
+ >>> print(next_time.to_iso8601_string()) # doctest: +SKIP
283
+ '2024-01-15T08:00:00+00:00'
284
+ """
285
+ now = pendulum.now(self._timezone)
286
+ seconds_until = self.time_until_next()
287
+ return now.add(seconds=seconds_until)
288
+
289
+ def previous_boundary(self) -> pendulum.DateTime:
290
+ """Get the datetime of the previous boundary.
291
+
292
+ Returns:
293
+ Pendulum DateTime of the previous boundary.
294
+
295
+ Examples:
296
+ >>> trigger = TimeTrigger("4h")
297
+ >>> prev_time = trigger.previous_boundary() # doctest: +SKIP
298
+ >>> print(prev_time.to_iso8601_string()) # doctest: +SKIP
299
+ '2024-01-15T04:00:00+00:00'
300
+ """
301
+ now = pendulum.now(self._timezone)
302
+ elapsed = self._seconds_into_period()
303
+ return now.subtract(seconds=elapsed)
304
+
305
+ async def wait_for_boundary(self, margin: float = 0.0) -> None:
306
+ """Wait until the next boundary (async).
307
+
308
+ Sleeps until the next boundary minus the margin.
309
+
310
+ Args:
311
+ margin: Seconds before boundary to wake up (default: 0.0).
312
+
313
+ Examples:
314
+ >>> import asyncio
315
+ >>> trigger = TimeTrigger("30m")
316
+ >>> await trigger.wait_for_boundary() # doctest: +SKIP
317
+ >>> print("Boundary reached!") # doctest: +SKIP
318
+ """
319
+ remaining = self.time_until_next() - margin
320
+ if remaining > 0:
321
+ await asyncio.sleep(remaining)
322
+ self._stats.record_trigger()
323
+
324
+ async def run_on_boundary(
325
+ self,
326
+ callback: Callable[[], None] | Callable[[], Awaitable[None]],
327
+ *,
328
+ margin: float = 0.0,
329
+ run_immediately: bool = False,
330
+ ) -> None:
331
+ """Run callback at each boundary (async loop).
332
+
333
+ Continuously waits for boundaries and invokes the callback.
334
+ Call stop() to terminate the loop.
335
+
336
+ Args:
337
+ callback: Function to call at each boundary (sync or async).
338
+ margin: Seconds before boundary to invoke callback.
339
+ run_immediately: If True, run callback immediately before first wait.
340
+
341
+ Examples:
342
+ >>> import asyncio
343
+ >>> async def restart(): # doctest: +SKIP
344
+ ... print("Restarting...")
345
+ >>> trigger = TimeTrigger("4h")
346
+ >>> task = asyncio.create_task( # doctest: +SKIP
347
+ ... trigger.run_on_boundary(restart, margin=30)
348
+ ... )
349
+ >>> # Later: trigger.stop()
350
+ """
351
+ self._running = True
352
+ self._stop_event.clear()
353
+
354
+ if run_immediately:
355
+ await self._invoke_callback(callback)
356
+
357
+ while self._running:
358
+ await self.wait_for_boundary(margin=margin)
359
+ # Re-check after await since stop() may have been called concurrently
360
+ if self._stop_event.is_set():
361
+ break
362
+ await self._invoke_callback(callback)
363
+
364
+ async def _invoke_callback(
365
+ self,
366
+ callback: Callable[[], None] | Callable[[], Awaitable[None]],
367
+ ) -> None:
368
+ """Invoke callback (sync or async)."""
369
+ self._stats.record_callback()
370
+ result = callback()
371
+ if asyncio.iscoroutine(result):
372
+ await result
373
+
374
+ def stop(self) -> None:
375
+ """Stop the boundary loop."""
376
+ self._running = False
377
+ self._stop_event.set()
378
+ if self._async_task is not None and not self._async_task.done():
379
+ self._async_task.cancel()
380
+
381
+ def __repr__(self) -> str:
382
+ """Return string representation."""
383
+ return f"TimeTrigger(modulo={self._modulo_str!r}, timezone={self._timezone!r})"
384
+
385
+ async def __aenter__(self) -> Self:
386
+ """Async context manager entry."""
387
+ return self
388
+
389
+ async def __aexit__(
390
+ self,
391
+ exc_type: type[BaseException] | None,
392
+ exc_val: BaseException | None,
393
+ exc_tb: object,
394
+ ) -> None:
395
+ """Async context manager exit."""
396
+ self.stop()