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,399 @@
1
+ """Local callback server for OAuth2 authorization code flow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import secrets
7
+ import socket
8
+ import threading
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from http.server import BaseHTTPRequestHandler, HTTPServer
12
+ from typing import TYPE_CHECKING, Any
13
+ from urllib.parse import parse_qs, urlparse
14
+
15
+ from typing_extensions import Self
16
+
17
+ from kstlib.auth.errors import AuthorizationError, CallbackServerError
18
+ from kstlib.logging import TRACE_LEVEL, get_logger
19
+
20
+ if TYPE_CHECKING:
21
+ import types
22
+
23
+ logger = get_logger(__name__)
24
+
25
+ # Defense in depth: maximum timeout regardless of config (10 minutes)
26
+ _CALLBACK_TIMEOUT_HARD_LIMIT = 600
27
+
28
+ # HTML templates for callback responses
29
+ SUCCESS_HTML = """<!DOCTYPE html>
30
+ <html>
31
+ <head>
32
+ <title>Authentication Successful</title>
33
+ <style>
34
+ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
35
+ display: flex; justify-content: center; align-items: center; height: 100vh;
36
+ margin: 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }}
37
+ .container {{ background: white; padding: 40px 60px; border-radius: 12px;
38
+ box-shadow: 0 10px 40px rgba(0,0,0,0.2); text-align: center; }}
39
+ h1 {{ color: #22c55e; margin-bottom: 10px; }}
40
+ p {{ color: #666; }}
41
+ .icon {{ font-size: 48px; margin-bottom: 20px; }}
42
+ </style>
43
+ </head>
44
+ <body>
45
+ <div class="container">
46
+ <div class="icon">✓</div>
47
+ <h1>Authentication Successful</h1>
48
+ <p>You can close this window and return to your application.</p>
49
+ </div>
50
+ </body>
51
+ </html>"""
52
+
53
+ ERROR_HTML = """<!DOCTYPE html>
54
+ <html>
55
+ <head>
56
+ <title>Authentication Failed</title>
57
+ <style>
58
+ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
59
+ display: flex; justify-content: center; align-items: center; height: 100vh;
60
+ margin: 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }}
61
+ .container {{ background: white; padding: 40px 60px; border-radius: 12px;
62
+ box-shadow: 0 10px 40px rgba(0,0,0,0.2); text-align: center; }}
63
+ h1 {{ color: #ef4444; margin-bottom: 10px; }}
64
+ p {{ color: #666; }}
65
+ .error {{ color: #999; font-size: 12px; margin-top: 20px; font-family: monospace; }}
66
+ .icon {{ font-size: 48px; margin-bottom: 20px; }}
67
+ </style>
68
+ </head>
69
+ <body>
70
+ <div class="container">
71
+ <div class="icon">✗</div>
72
+ <h1>Authentication Failed</h1>
73
+ <p>{error_description}</p>
74
+ <p class="error">{error_code}</p>
75
+ </div>
76
+ </body>
77
+ </html>"""
78
+
79
+
80
+ @dataclass
81
+ class CallbackResult:
82
+ """Result from the OAuth2 callback.
83
+
84
+ Attributes:
85
+ code: Authorization code (on success).
86
+ state: State parameter for CSRF validation.
87
+ error: OAuth2 error code (on failure).
88
+ error_description: Human-readable error description.
89
+ raw_params: All query parameters from callback.
90
+ """
91
+
92
+ code: str | None = None
93
+ state: str | None = None
94
+ error: str | None = None
95
+ error_description: str | None = None
96
+ raw_params: dict[str, list[str]] = field(default_factory=dict)
97
+
98
+ @property
99
+ def success(self) -> bool:
100
+ """Check if callback was successful."""
101
+ return self.code is not None and self.error is None
102
+
103
+
104
+ class CallbackHandler(BaseHTTPRequestHandler):
105
+ """HTTP request handler for OAuth2 callback."""
106
+
107
+ # Class-level storage for result (set by server)
108
+ callback_result: CallbackResult | None = None
109
+ callback_path: str = "/callback"
110
+ expected_state: str | None = None
111
+
112
+ def log_message(self, fmt: str, *args: Any) -> None: # pylint: disable=arguments-differ
113
+ """Suppress default HTTP logging."""
114
+ logger.debug("Callback server: %s", fmt % args)
115
+
116
+ def do_GET(self) -> None:
117
+ """Handle GET request (OAuth2 callback)."""
118
+ parsed = urlparse(self.path)
119
+
120
+ # Only handle the callback path
121
+ if not parsed.path.rstrip("/").endswith(self.callback_path.rstrip("/")):
122
+ self.send_error(404, "Not Found")
123
+ return
124
+
125
+ # Parse query parameters
126
+ params = parse_qs(parsed.query)
127
+
128
+ # Extract OAuth2 parameters
129
+ code = params.get("code", [None])[0]
130
+ state = params.get("state", [None])[0]
131
+ error = params.get("error", [None])[0]
132
+ error_description = params.get("error_description", ["Unknown error"])[0]
133
+
134
+ if logger.isEnabledFor(TRACE_LEVEL):
135
+ # Redact code for security
136
+ redacted_code = f"{code[:8]}...{code[-4:]}" if code and len(code) > 12 else "[short]"
137
+ logger.log(
138
+ TRACE_LEVEL,
139
+ "[CALLBACK] Received: code=%s | state=%s | error=%s",
140
+ redacted_code if code else None,
141
+ state,
142
+ error,
143
+ )
144
+
145
+ # Store result
146
+ CallbackHandler.callback_result = CallbackResult(
147
+ code=code,
148
+ state=state,
149
+ error=error,
150
+ error_description=error_description if error else None,
151
+ raw_params=params,
152
+ )
153
+
154
+ # Send response
155
+ if error:
156
+ self._send_error_response(error, error_description)
157
+ elif code:
158
+ self._send_success_response()
159
+ else:
160
+ self._send_error_response("missing_code", "No authorization code received")
161
+
162
+ def _send_success_response(self) -> None:
163
+ """Send success HTML response."""
164
+ self.send_response(200)
165
+ self.send_header("Content-Type", "text/html; charset=utf-8")
166
+ self.end_headers()
167
+ self.wfile.write(SUCCESS_HTML.encode("utf-8"))
168
+
169
+ def _send_error_response(self, error: str, description: str) -> None:
170
+ """Send error HTML response."""
171
+ self.send_response(400)
172
+ self.send_header("Content-Type", "text/html; charset=utf-8")
173
+ self.end_headers()
174
+ # Escape user-controlled values to prevent XSS
175
+ safe_html = ERROR_HTML.format(
176
+ error_code=html.escape(error),
177
+ error_description=html.escape(description),
178
+ )
179
+ self.wfile.write(safe_html.encode("utf-8"))
180
+
181
+
182
+ class CallbackServer: # pylint: disable=too-many-instance-attributes
183
+ """Local HTTP server to receive OAuth2 authorization callbacks.
184
+
185
+ The server runs in a background thread and waits for the IdP to redirect
186
+ the user back with an authorization code.
187
+
188
+ Example:
189
+ >>> server = CallbackServer(port=8400) # doctest: +SKIP
190
+ >>> server.start() # doctest: +SKIP
191
+ >>> # User completes authentication in browser
192
+ >>> result = server.wait_for_callback(timeout=120) # doctest: +SKIP
193
+ >>> if result.success: # doctest: +SKIP
194
+ ... print(f"Got code: {result.code}")
195
+ >>> server.stop() # doctest: +SKIP
196
+ """
197
+
198
+ def __init__(
199
+ self,
200
+ host: str = "127.0.0.1",
201
+ port: int = 8400,
202
+ *,
203
+ path: str = "/callback",
204
+ port_range: tuple[int, int] | None = None,
205
+ ) -> None:
206
+ """Initialize the callback server.
207
+
208
+ Args:
209
+ host: Host to bind to.
210
+ port: Port to listen on.
211
+ path: URL path for callback endpoint.
212
+ port_range: Optional (min, max) port range to try if port is busy.
213
+ """
214
+ self.host = host
215
+ self.port = port
216
+ self.path = path
217
+ self.port_range = port_range
218
+ self._server: HTTPServer | None = None
219
+ self._thread: threading.Thread | None = None
220
+ self._state: str | None = None
221
+ self._stop_flag: bool = False
222
+
223
+ @property
224
+ def redirect_uri(self) -> str:
225
+ """Get the full redirect URI for OAuth2 configuration."""
226
+ return f"http://{self.host}:{self.port}{self.path}"
227
+
228
+ def generate_state(self) -> str:
229
+ """Generate a cryptographically secure state parameter."""
230
+ self._state = secrets.token_urlsafe(32)
231
+ return self._state
232
+
233
+ def _find_available_port(self) -> int:
234
+ """Find an available port within the configured range.
235
+
236
+ Raises:
237
+ CallbackServerError: If no port is available in the configured range.
238
+ """
239
+ if self.port_range:
240
+ min_port, max_port = self.port_range
241
+ if logger.isEnabledFor(TRACE_LEVEL):
242
+ logger.log(TRACE_LEVEL, "[CALLBACK] Scanning port range %d-%d", min_port, max_port)
243
+ for port in range(min_port, max_port + 1):
244
+ if self._is_port_available(port):
245
+ if logger.isEnabledFor(TRACE_LEVEL):
246
+ logger.log(TRACE_LEVEL, "[CALLBACK] Found available port %d", port)
247
+ return port
248
+ msg = f"No available port in range {min_port}-{max_port}"
249
+ raise CallbackServerError(msg)
250
+
251
+ if self._is_port_available(self.port):
252
+ if logger.isEnabledFor(TRACE_LEVEL):
253
+ logger.log(TRACE_LEVEL, "[CALLBACK] Port %d is available", self.port)
254
+ return self.port
255
+
256
+ msg = f"Port {self.port} is not available"
257
+ raise CallbackServerError(msg, port=self.port)
258
+
259
+ def _is_port_available(self, port: int) -> bool:
260
+ """Check if a port is available for binding."""
261
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
262
+ try:
263
+ sock.bind((self.host, port))
264
+ return True
265
+ except OSError:
266
+ return False
267
+
268
+ def start(self) -> None:
269
+ """Start the callback server in a background thread.
270
+
271
+ Raises:
272
+ CallbackServerError: If server fails to start.
273
+ """
274
+ if self._server is not None:
275
+ return # Already running
276
+
277
+ # Reset state
278
+ self._stop_flag = False
279
+ CallbackHandler.callback_result = None
280
+ CallbackHandler.callback_path = self.path
281
+ CallbackHandler.expected_state = self._state
282
+
283
+ # Find available port
284
+ self.port = self._find_available_port()
285
+
286
+ if logger.isEnabledFor(TRACE_LEVEL):
287
+ logger.log(TRACE_LEVEL, "[CALLBACK] Binding server to %s:%d", self.host, self.port)
288
+
289
+ try:
290
+ self._server = HTTPServer((self.host, self.port), CallbackHandler)
291
+ self._server.timeout = 0.5 # Short timeout for responsive shutdown
292
+ # Update port to actual assigned port (important when port=0)
293
+ self.port = self._server.server_address[1]
294
+
295
+ if logger.isEnabledFor(TRACE_LEVEL):
296
+ logger.log(TRACE_LEVEL, "[CALLBACK] Server bound successfully to port %d", self.port)
297
+ except OSError as e:
298
+ msg = f"Failed to start callback server on {self.host}:{self.port}"
299
+ raise CallbackServerError(msg, port=self.port) from e
300
+
301
+ self._thread = threading.Thread(target=self._serve, daemon=True)
302
+ self._thread.start()
303
+ logger.info("Callback server started at %s", self.redirect_uri)
304
+
305
+ def _serve(self) -> None:
306
+ """Server loop running in background thread."""
307
+ if self._server is None:
308
+ return
309
+ while not self._stop_flag and self._server:
310
+ try:
311
+ self._server.handle_request()
312
+ except Exception: # pylint: disable=broad-exception-caught
313
+ if not self._stop_flag:
314
+ logger.exception("Error handling callback request")
315
+ break
316
+
317
+ def stop(self) -> None:
318
+ """Stop the callback server."""
319
+ self._stop_flag = True
320
+ if self._server is not None:
321
+ self._server.server_close()
322
+ self._server = None
323
+ if self._thread is not None:
324
+ self._thread.join(timeout=2)
325
+ self._thread = None
326
+ logger.debug("Callback server stopped")
327
+
328
+ def wait_for_callback(self, timeout: float = 120.0) -> CallbackResult:
329
+ """Wait for the OAuth2 callback.
330
+
331
+ Args:
332
+ timeout: Maximum time to wait in seconds (capped at 600s).
333
+
334
+ Returns:
335
+ CallbackResult with authorization code or error.
336
+
337
+ Raises:
338
+ CallbackServerError: If timeout expires without callback.
339
+ AuthorizationError: If callback contains an error.
340
+ """
341
+ # Defense in depth: cap timeout regardless of config
342
+ timeout = min(timeout, _CALLBACK_TIMEOUT_HARD_LIMIT)
343
+ start_time = time.time()
344
+ while time.time() - start_time < timeout:
345
+ if CallbackHandler.callback_result is not None:
346
+ result = CallbackHandler.callback_result
347
+ CallbackHandler.callback_result = None # Clear for next use
348
+
349
+ # Validate state if we generated one
350
+ if self._state and result.state != self._state:
351
+ if logger.isEnabledFor(TRACE_LEVEL):
352
+ logger.log(
353
+ TRACE_LEVEL,
354
+ "[CALLBACK] State mismatch: expected=%s | received=%s",
355
+ self._state,
356
+ result.state,
357
+ )
358
+ raise AuthorizationError(
359
+ "State mismatch - possible CSRF attack",
360
+ error_code="state_mismatch",
361
+ )
362
+
363
+ if logger.isEnabledFor(TRACE_LEVEL):
364
+ logger.log(TRACE_LEVEL, "[CALLBACK] State validated successfully")
365
+
366
+ if result.error:
367
+ raise AuthorizationError(
368
+ result.error_description or result.error,
369
+ error_code=result.error,
370
+ error_description=result.error_description,
371
+ )
372
+
373
+ return result
374
+
375
+ time.sleep(0.1)
376
+
377
+ msg = f"Timeout waiting for OAuth2 callback after {timeout}s"
378
+ raise CallbackServerError(msg)
379
+
380
+ def __enter__(self) -> Self:
381
+ """Context manager entry - start server."""
382
+ self.start()
383
+ return self
384
+
385
+ def __exit__(
386
+ self,
387
+ exc_type: type[BaseException] | None,
388
+ exc_val: BaseException | None,
389
+ exc_tb: types.TracebackType | None,
390
+ ) -> None:
391
+ """Context manager exit - stop server."""
392
+ self.stop()
393
+
394
+
395
+ __all__ = [
396
+ "CallbackHandler",
397
+ "CallbackResult",
398
+ "CallbackServer",
399
+ ]