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,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"]