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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.0.dist-info/METADATA +201 -0
- kstlib-1.0.0.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
- kstlib-1.0.0.dist-info/entry_points.txt +2 -0
- kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {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()
|