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,417 @@
|
|
|
1
|
+
"""Graceful shutdown handler with prioritized cleanup callbacks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import inspect
|
|
8
|
+
import logging
|
|
9
|
+
import signal
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
from collections.abc import Callable, Coroutine
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
15
|
+
|
|
16
|
+
from typing_extensions import Self
|
|
17
|
+
|
|
18
|
+
from kstlib.limits import (
|
|
19
|
+
HARD_MAX_SHUTDOWN_TIMEOUT,
|
|
20
|
+
HARD_MIN_SHUTDOWN_TIMEOUT,
|
|
21
|
+
get_resilience_limits,
|
|
22
|
+
)
|
|
23
|
+
from kstlib.resilience.exceptions import ShutdownError
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
import types
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# Type aliases for callbacks
|
|
31
|
+
SyncCallback = Callable[[], None]
|
|
32
|
+
AsyncCallback = Callable[[], Coroutine[Any, Any, None]]
|
|
33
|
+
Callback = SyncCallback | AsyncCallback
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class CleanupCallback:
|
|
38
|
+
"""Registered cleanup callback with metadata.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
name: Unique identifier for the callback.
|
|
42
|
+
callback: The cleanup function (sync or async).
|
|
43
|
+
priority: Execution order (lower runs first, default 100).
|
|
44
|
+
timeout: Per-callback timeout in seconds (None = use global).
|
|
45
|
+
is_async: Whether the callback is async.
|
|
46
|
+
|
|
47
|
+
Examples:
|
|
48
|
+
>>> cb = CleanupCallback(
|
|
49
|
+
... name="db_close",
|
|
50
|
+
... callback=lambda: None,
|
|
51
|
+
... priority=50,
|
|
52
|
+
... timeout=5.0,
|
|
53
|
+
... is_async=False,
|
|
54
|
+
... )
|
|
55
|
+
>>> (cb.name, cb.priority, cb.timeout)
|
|
56
|
+
('db_close', 50, 5.0)
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
name: str
|
|
60
|
+
callback: Callback
|
|
61
|
+
priority: int = 100
|
|
62
|
+
timeout: float | None = None
|
|
63
|
+
is_async: bool = False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class GracefulShutdown:
|
|
67
|
+
"""Graceful shutdown handler with prioritized cleanup callbacks.
|
|
68
|
+
|
|
69
|
+
Manages orderly shutdown on SIGTERM/SIGINT with timeout enforcement.
|
|
70
|
+
Callbacks execute in priority order (lower = first).
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
timeout: Total timeout for all callbacks (default from config).
|
|
74
|
+
signals: Signals to handle (default: SIGTERM, SIGINT).
|
|
75
|
+
force_exit_code: Exit code when timeout exceeded (default: 1).
|
|
76
|
+
|
|
77
|
+
Examples:
|
|
78
|
+
Register callbacks with priority ordering:
|
|
79
|
+
|
|
80
|
+
>>> shutdown = GracefulShutdown(timeout=30)
|
|
81
|
+
>>> shutdown.register("cache", lambda: None, priority=20)
|
|
82
|
+
>>> shutdown.register("db", lambda: None, priority=10)
|
|
83
|
+
>>> [cb.name for cb in shutdown._get_sorted_callbacks()]
|
|
84
|
+
['db', 'cache']
|
|
85
|
+
|
|
86
|
+
Context manager usage (with signals):
|
|
87
|
+
|
|
88
|
+
>>> with GracefulShutdown() as shutdown: # doctest: +SKIP
|
|
89
|
+
... shutdown.register("cleanup", close_resources)
|
|
90
|
+
... run_application()
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
# Signals not available on Windows
|
|
94
|
+
_UNIX_SIGNALS: tuple[signal.Signals, ...] = (signal.SIGTERM, signal.SIGINT)
|
|
95
|
+
_WINDOWS_SIGNALS: tuple[signal.Signals, ...] = (signal.SIGINT,)
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
*,
|
|
100
|
+
timeout: float | None = None,
|
|
101
|
+
signals: tuple[signal.Signals, ...] | None = None,
|
|
102
|
+
force_exit_code: int = 1,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Initialize graceful shutdown handler.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
timeout: Total timeout for all callbacks. Uses config if None.
|
|
108
|
+
signals: Signals to handle. Auto-detects platform if None.
|
|
109
|
+
force_exit_code: Exit code when timeout exceeded.
|
|
110
|
+
"""
|
|
111
|
+
# Load timeout from config if not provided
|
|
112
|
+
if timeout is None:
|
|
113
|
+
limits = get_resilience_limits()
|
|
114
|
+
self._timeout = limits.shutdown_timeout
|
|
115
|
+
else:
|
|
116
|
+
# Clamp to hard limits
|
|
117
|
+
self._timeout = max(
|
|
118
|
+
HARD_MIN_SHUTDOWN_TIMEOUT,
|
|
119
|
+
min(timeout, HARD_MAX_SHUTDOWN_TIMEOUT),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Auto-detect signals based on platform
|
|
123
|
+
if signals is None:
|
|
124
|
+
self._signals = self._WINDOWS_SIGNALS if sys.platform == "win32" else self._UNIX_SIGNALS
|
|
125
|
+
else:
|
|
126
|
+
self._signals = signals
|
|
127
|
+
|
|
128
|
+
self._force_exit_code = force_exit_code
|
|
129
|
+
|
|
130
|
+
# Callback registry
|
|
131
|
+
self._callbacks: dict[str, CleanupCallback] = {}
|
|
132
|
+
self._lock = threading.Lock()
|
|
133
|
+
|
|
134
|
+
# Shutdown state
|
|
135
|
+
self._shutting_down = False
|
|
136
|
+
self._shutdown_event = threading.Event()
|
|
137
|
+
|
|
138
|
+
# Original signal handlers (for restoration)
|
|
139
|
+
self._original_handlers: dict[signal.Signals, Any] = {}
|
|
140
|
+
self._installed = False
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def timeout(self) -> float:
|
|
144
|
+
"""Return the total shutdown timeout in seconds."""
|
|
145
|
+
return self._timeout
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def is_shutting_down(self) -> bool:
|
|
149
|
+
"""Return True if shutdown is in progress."""
|
|
150
|
+
return self._shutting_down
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def is_installed(self) -> bool:
|
|
154
|
+
"""Return True if signal handlers are installed."""
|
|
155
|
+
return self._installed
|
|
156
|
+
|
|
157
|
+
def register(
|
|
158
|
+
self,
|
|
159
|
+
name: str,
|
|
160
|
+
callback: Callback,
|
|
161
|
+
*,
|
|
162
|
+
priority: int = 100,
|
|
163
|
+
timeout: float | None = None,
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Register a cleanup callback.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
name: Unique identifier for the callback.
|
|
169
|
+
callback: Cleanup function (sync or async).
|
|
170
|
+
priority: Execution order (lower runs first, default 100).
|
|
171
|
+
timeout: Per-callback timeout (None = use global).
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
ShutdownError: If name already registered or shutdown in progress.
|
|
175
|
+
|
|
176
|
+
Examples:
|
|
177
|
+
>>> shutdown = GracefulShutdown()
|
|
178
|
+
>>> shutdown.register("db", lambda: print("closing db"), priority=10)
|
|
179
|
+
>>> "db" in [cb.name for cb in shutdown._callbacks.values()]
|
|
180
|
+
True
|
|
181
|
+
"""
|
|
182
|
+
with self._lock:
|
|
183
|
+
if self._shutting_down:
|
|
184
|
+
raise ShutdownError("Cannot register callback during shutdown")
|
|
185
|
+
if name in self._callbacks:
|
|
186
|
+
raise ShutdownError(f"Callback '{name}' already registered")
|
|
187
|
+
|
|
188
|
+
is_async = inspect.iscoroutinefunction(callback)
|
|
189
|
+
self._callbacks[name] = CleanupCallback(
|
|
190
|
+
name=name,
|
|
191
|
+
callback=callback,
|
|
192
|
+
priority=priority,
|
|
193
|
+
timeout=timeout,
|
|
194
|
+
is_async=is_async,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def unregister(self, name: str) -> bool:
|
|
198
|
+
"""Unregister a cleanup callback.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
name: Identifier of callback to remove.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
True if callback was removed, False if not found.
|
|
205
|
+
|
|
206
|
+
Examples:
|
|
207
|
+
>>> shutdown = GracefulShutdown()
|
|
208
|
+
>>> shutdown.register("test", lambda: None)
|
|
209
|
+
>>> shutdown.unregister("test")
|
|
210
|
+
True
|
|
211
|
+
>>> shutdown.unregister("nonexistent")
|
|
212
|
+
False
|
|
213
|
+
"""
|
|
214
|
+
with self._lock:
|
|
215
|
+
if name in self._callbacks:
|
|
216
|
+
del self._callbacks[name]
|
|
217
|
+
return True
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
def install(self) -> None:
|
|
221
|
+
"""Install signal handlers.
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
ShutdownError: If handlers already installed.
|
|
225
|
+
"""
|
|
226
|
+
with self._lock:
|
|
227
|
+
if self._installed:
|
|
228
|
+
raise ShutdownError("Signal handlers already installed")
|
|
229
|
+
|
|
230
|
+
for sig in self._signals:
|
|
231
|
+
with contextlib.suppress(OSError, ValueError):
|
|
232
|
+
self._original_handlers[sig] = signal.signal(sig, self._signal_handler)
|
|
233
|
+
|
|
234
|
+
self._installed = True
|
|
235
|
+
|
|
236
|
+
def uninstall(self) -> None:
|
|
237
|
+
"""Restore original signal handlers."""
|
|
238
|
+
with self._lock:
|
|
239
|
+
if not self._installed:
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
for sig, handler in self._original_handlers.items():
|
|
243
|
+
with contextlib.suppress(OSError, ValueError):
|
|
244
|
+
signal.signal(sig, handler)
|
|
245
|
+
|
|
246
|
+
self._original_handlers.clear()
|
|
247
|
+
self._installed = False
|
|
248
|
+
|
|
249
|
+
def _signal_handler(self, _signum: int, _frame: types.FrameType | None) -> None:
|
|
250
|
+
"""Handle incoming signal."""
|
|
251
|
+
self.trigger()
|
|
252
|
+
|
|
253
|
+
def trigger(self) -> None:
|
|
254
|
+
"""Trigger shutdown programmatically.
|
|
255
|
+
|
|
256
|
+
Useful for testing or triggering shutdown from code.
|
|
257
|
+
Runs callbacks synchronously in priority order.
|
|
258
|
+
"""
|
|
259
|
+
with self._lock:
|
|
260
|
+
if self._shutting_down:
|
|
261
|
+
return
|
|
262
|
+
self._shutting_down = True
|
|
263
|
+
|
|
264
|
+
log.info("Shutdown requested")
|
|
265
|
+
self._shutdown_event.set()
|
|
266
|
+
self._run_callbacks_sync()
|
|
267
|
+
|
|
268
|
+
async def atrigger(self) -> None:
|
|
269
|
+
"""Trigger shutdown programmatically (async version).
|
|
270
|
+
|
|
271
|
+
Runs callbacks asynchronously in priority order.
|
|
272
|
+
"""
|
|
273
|
+
with self._lock:
|
|
274
|
+
if self._shutting_down:
|
|
275
|
+
return
|
|
276
|
+
self._shutting_down = True
|
|
277
|
+
|
|
278
|
+
log.info("Shutdown requested")
|
|
279
|
+
self._shutdown_event.set()
|
|
280
|
+
await self._run_callbacks_async()
|
|
281
|
+
|
|
282
|
+
def _get_sorted_callbacks(self) -> list[CleanupCallback]:
|
|
283
|
+
"""Return callbacks sorted by priority (ascending)."""
|
|
284
|
+
with self._lock:
|
|
285
|
+
return sorted(self._callbacks.values(), key=lambda cb: cb.priority)
|
|
286
|
+
|
|
287
|
+
def _run_callbacks_sync(self) -> None:
|
|
288
|
+
"""Run all callbacks synchronously with timeout."""
|
|
289
|
+
callbacks = self._get_sorted_callbacks()
|
|
290
|
+
|
|
291
|
+
for cb in callbacks:
|
|
292
|
+
cb_timeout = cb.timeout if cb.timeout is not None else self._timeout
|
|
293
|
+
|
|
294
|
+
if cb.is_async:
|
|
295
|
+
# Run async callback in new event loop
|
|
296
|
+
async_callback = cast("AsyncCallback", cb.callback)
|
|
297
|
+
try:
|
|
298
|
+
loop = asyncio.new_event_loop()
|
|
299
|
+
try:
|
|
300
|
+
loop.run_until_complete(asyncio.wait_for(async_callback(), timeout=cb_timeout))
|
|
301
|
+
finally:
|
|
302
|
+
loop.close()
|
|
303
|
+
except asyncio.TimeoutError:
|
|
304
|
+
log.warning("Shutdown callback '%s' timed out", cb.name)
|
|
305
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
306
|
+
# Intentional: shutdown must continue even if callback fails
|
|
307
|
+
log.warning("Shutdown callback '%s' failed", cb.name, exc_info=True)
|
|
308
|
+
else:
|
|
309
|
+
# Run sync callback with timeout via thread
|
|
310
|
+
# Wrap in suppress to prevent unhandled thread exceptions
|
|
311
|
+
def safe_callback(fn: SyncCallback) -> None:
|
|
312
|
+
with contextlib.suppress(Exception):
|
|
313
|
+
fn()
|
|
314
|
+
|
|
315
|
+
sync_callback = cast("SyncCallback", cb.callback)
|
|
316
|
+
thread = threading.Thread(target=safe_callback, args=(sync_callback,))
|
|
317
|
+
thread.start()
|
|
318
|
+
thread.join(timeout=cb_timeout)
|
|
319
|
+
# If thread still running after timeout, we continue anyway
|
|
320
|
+
|
|
321
|
+
async def _run_callbacks_async(self) -> None:
|
|
322
|
+
"""Run all callbacks asynchronously with timeout."""
|
|
323
|
+
callbacks = self._get_sorted_callbacks()
|
|
324
|
+
|
|
325
|
+
for cb in callbacks:
|
|
326
|
+
cb_timeout = cb.timeout if cb.timeout is not None else self._timeout
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
if cb.is_async:
|
|
330
|
+
async_cb = cast("AsyncCallback", cb.callback)
|
|
331
|
+
await asyncio.wait_for(async_cb(), timeout=cb_timeout)
|
|
332
|
+
else:
|
|
333
|
+
# Run sync callback in executor
|
|
334
|
+
loop = asyncio.get_running_loop()
|
|
335
|
+
await asyncio.wait_for(
|
|
336
|
+
loop.run_in_executor(None, cb.callback),
|
|
337
|
+
timeout=cb_timeout,
|
|
338
|
+
)
|
|
339
|
+
except asyncio.TimeoutError:
|
|
340
|
+
log.warning("Shutdown callback '%s' timed out", cb.name)
|
|
341
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
342
|
+
# Intentional: shutdown must continue even if callback fails
|
|
343
|
+
log.warning("Shutdown callback '%s' failed", cb.name, exc_info=True)
|
|
344
|
+
|
|
345
|
+
def wait(self, timeout: float | None = None) -> bool:
|
|
346
|
+
"""Wait for shutdown signal.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
timeout: Maximum time to wait (None = wait forever).
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
True if shutdown was triggered, False if timeout.
|
|
353
|
+
"""
|
|
354
|
+
return self._shutdown_event.wait(timeout=timeout)
|
|
355
|
+
|
|
356
|
+
async def await_shutdown(self, timeout: float | None = None) -> bool:
|
|
357
|
+
"""Wait for shutdown signal (async version).
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
timeout: Maximum time to wait (None = wait forever).
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
True if shutdown was triggered, False if timeout.
|
|
364
|
+
"""
|
|
365
|
+
# Use polling to avoid executor thread cleanup issues
|
|
366
|
+
# Note: We poll a threading.Event, not asyncio.Event, hence the loop
|
|
367
|
+
# The threading.Event is used to support both sync and async contexts
|
|
368
|
+
poll_interval = 0.05 # 50ms polling
|
|
369
|
+
if timeout is None:
|
|
370
|
+
# Wait forever (poll threading.Event from async context)
|
|
371
|
+
while not self._shutdown_event.is_set():
|
|
372
|
+
await asyncio.sleep(poll_interval)
|
|
373
|
+
return True
|
|
374
|
+
|
|
375
|
+
loop = asyncio.get_running_loop()
|
|
376
|
+
deadline = loop.time() + timeout
|
|
377
|
+
while not self._shutdown_event.is_set():
|
|
378
|
+
remaining = deadline - loop.time()
|
|
379
|
+
if remaining <= 0:
|
|
380
|
+
return False
|
|
381
|
+
await asyncio.sleep(min(poll_interval, remaining))
|
|
382
|
+
return True
|
|
383
|
+
|
|
384
|
+
def __enter__(self) -> Self:
|
|
385
|
+
"""Enter sync context manager."""
|
|
386
|
+
self.install()
|
|
387
|
+
return self
|
|
388
|
+
|
|
389
|
+
def __exit__(
|
|
390
|
+
self,
|
|
391
|
+
exc_type: type[BaseException] | None,
|
|
392
|
+
exc_val: BaseException | None,
|
|
393
|
+
exc_tb: types.TracebackType | None,
|
|
394
|
+
) -> None:
|
|
395
|
+
"""Exit sync context manager."""
|
|
396
|
+
self.uninstall()
|
|
397
|
+
if not self._shutting_down:
|
|
398
|
+
self.trigger()
|
|
399
|
+
|
|
400
|
+
async def __aenter__(self) -> Self:
|
|
401
|
+
"""Enter async context manager."""
|
|
402
|
+
self.install()
|
|
403
|
+
return self
|
|
404
|
+
|
|
405
|
+
async def __aexit__(
|
|
406
|
+
self,
|
|
407
|
+
exc_type: type[BaseException] | None,
|
|
408
|
+
exc_val: BaseException | None,
|
|
409
|
+
exc_tb: types.TracebackType | None,
|
|
410
|
+
) -> None:
|
|
411
|
+
"""Exit async context manager."""
|
|
412
|
+
self.uninstall()
|
|
413
|
+
if not self._shutting_down:
|
|
414
|
+
await self.atrigger()
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
__all__ = ["CleanupCallback", "GracefulShutdown"]
|