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
kstlib/auth/session.py ADDED
@@ -0,0 +1,338 @@
1
+ """Authenticated HTTP session wrapper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from http import HTTPStatus
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ import httpx
10
+ from typing_extensions import Self
11
+
12
+ from kstlib.auth.errors import AuthError, TokenExpiredError
13
+ from kstlib.logging import TRACE_LEVEL, get_logger
14
+ from kstlib.ssl import build_ssl_context
15
+
16
+ if TYPE_CHECKING:
17
+ import types
18
+
19
+ from kstlib.auth.providers.base import AbstractAuthProvider
20
+
21
+ logger = get_logger(__name__)
22
+
23
+ # Default timeout for HTTP requests
24
+ DEFAULT_TIMEOUT = 30.0
25
+
26
+
27
+ class AuthSession:
28
+ """HTTP session with automatic token injection and refresh.
29
+
30
+ Wraps httpx.Client (sync) or httpx.AsyncClient (async) to automatically:
31
+ - Inject Bearer token in Authorization header
32
+ - Refresh expired tokens before making requests
33
+ - Handle 401 responses by refreshing and retrying
34
+
35
+ Example (sync):
36
+ >>> from kstlib.auth import AuthSession, get_provider # doctest: +SKIP
37
+ >>> provider = get_provider("corporate") # doctest: +SKIP
38
+ >>> with AuthSession(provider) as session: # doctest: +SKIP
39
+ ... response = session.get("https://api.example.com/users/me")
40
+ ... print(response.json())
41
+
42
+ Example (async):
43
+ >>> async with AuthSession(provider) as session: # doctest: +SKIP
44
+ ... response = await session.get("https://api.example.com/users/me")
45
+ ... print(response.json())
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ provider: AbstractAuthProvider,
51
+ *,
52
+ timeout: float = DEFAULT_TIMEOUT,
53
+ auto_refresh: bool = True,
54
+ retry_on_401: bool = True,
55
+ ssl_verify: bool | None = None,
56
+ ssl_ca_bundle: str | None = None,
57
+ ) -> None:
58
+ """Initialize authenticated session.
59
+
60
+ Args:
61
+ provider: Authentication provider to use for tokens.
62
+ timeout: Default request timeout in seconds.
63
+ auto_refresh: Automatically refresh expired tokens before requests.
64
+ retry_on_401: Retry request after token refresh on 401 response.
65
+ ssl_verify: Override SSL verification (True/False).
66
+ If None, uses provider's SSL config or global config.
67
+ ssl_ca_bundle: Override CA bundle path.
68
+ If None, uses provider's SSL config or global config.
69
+ """
70
+ self.provider = provider
71
+ self.timeout = timeout
72
+ self.auto_refresh = auto_refresh
73
+ self.retry_on_401 = retry_on_401
74
+
75
+ # Build SSL context: kwargs > provider config > global config
76
+ if ssl_verify is None and ssl_ca_bundle is None and hasattr(provider, "config"):
77
+ # Use provider's SSL settings if available
78
+ # Check for actual bool/str values (not MagicMock from tests)
79
+ provider_config = getattr(provider, "config", None)
80
+ provider_ssl_verify: bool | None = None
81
+ provider_ca_bundle: str | None = None
82
+
83
+ if provider_config is not None:
84
+ ssl_verify_attr = getattr(provider_config, "ssl_verify", None)
85
+ if isinstance(ssl_verify_attr, bool):
86
+ provider_ssl_verify = ssl_verify_attr
87
+
88
+ ca_bundle_attr = getattr(provider_config, "ssl_ca_bundle", None)
89
+ if isinstance(ca_bundle_attr, str):
90
+ provider_ca_bundle = ca_bundle_attr
91
+
92
+ self._ssl_context = build_ssl_context(
93
+ ssl_verify=provider_ssl_verify,
94
+ ssl_ca_bundle=provider_ca_bundle,
95
+ )
96
+ else:
97
+ # Use explicit kwargs or fall back to global config
98
+ self._ssl_context = build_ssl_context(
99
+ ssl_verify=ssl_verify,
100
+ ssl_ca_bundle=ssl_ca_bundle,
101
+ )
102
+
103
+ self._sync_client: httpx.Client | None = None
104
+ self._async_client: httpx.AsyncClient | None = None
105
+
106
+ # ─────────────────────────────────────────────────────────────────────────
107
+ # Context managers
108
+ # ─────────────────────────────────────────────────────────────────────────
109
+
110
+ def __enter__(self) -> Self:
111
+ """Enter sync context manager."""
112
+ self._sync_client = httpx.Client(timeout=self.timeout, verify=self._ssl_context)
113
+ return self
114
+
115
+ def __exit__(
116
+ self,
117
+ exc_type: type[BaseException] | None,
118
+ exc_val: BaseException | None,
119
+ exc_tb: types.TracebackType | None,
120
+ ) -> None:
121
+ """Exit sync context manager."""
122
+ if self._sync_client:
123
+ self._sync_client.close()
124
+ self._sync_client = None
125
+
126
+ async def __aenter__(self) -> Self:
127
+ """Enter async context manager."""
128
+ self._async_client = httpx.AsyncClient(timeout=self.timeout, verify=self._ssl_context)
129
+ return self
130
+
131
+ async def __aexit__(
132
+ self,
133
+ exc_type: type[BaseException] | None,
134
+ exc_val: BaseException | None,
135
+ exc_tb: types.TracebackType | None,
136
+ ) -> None:
137
+ """Exit async context manager."""
138
+ if self._async_client:
139
+ await self._async_client.aclose()
140
+ self._async_client = None
141
+
142
+ # ─────────────────────────────────────────────────────────────────────────
143
+ # Token handling
144
+ # ─────────────────────────────────────────────────────────────────────────
145
+
146
+ def _get_auth_header(self) -> dict[str, str]:
147
+ """Get Authorization header with current token.
148
+
149
+ Returns:
150
+ Dict with Authorization header.
151
+
152
+ Raises:
153
+ TokenExpiredError: If no valid token is available.
154
+ """
155
+ token = self.provider.get_token(auto_refresh=self.auto_refresh)
156
+
157
+ if token is None:
158
+ raise TokenExpiredError("No token available - authentication required")
159
+
160
+ if token.is_expired and not token.is_refreshable:
161
+ raise TokenExpiredError("Token expired and cannot be refreshed")
162
+
163
+ # Extract string value from TokenType enum or use as-is if already a string
164
+ token_type = token.token_type.value if isinstance(token.token_type, Enum) else token.token_type
165
+
166
+ return {"Authorization": f"{token_type} {token.access_token}"}
167
+
168
+ def _should_retry(self, response: httpx.Response, retried: bool) -> bool:
169
+ """Check if request should be retried after 401."""
170
+ return (
171
+ self.retry_on_401
172
+ and not retried
173
+ and response.status_code == HTTPStatus.UNAUTHORIZED
174
+ and self.provider.get_token(auto_refresh=False) is not None
175
+ )
176
+
177
+ # ─────────────────────────────────────────────────────────────────────────
178
+ # Sync HTTP methods
179
+ # ─────────────────────────────────────────────────────────────────────────
180
+
181
+ def _request(
182
+ self,
183
+ method: str,
184
+ url: str,
185
+ *,
186
+ _retried: bool = False,
187
+ **kwargs: Any,
188
+ ) -> httpx.Response:
189
+ """Make an authenticated HTTP request (sync).
190
+
191
+ Args:
192
+ method: HTTP method.
193
+ url: Request URL.
194
+ _retried: Internal flag to prevent infinite retry.
195
+ **kwargs: Additional arguments for httpx.
196
+
197
+ Returns:
198
+ HTTP response.
199
+ """
200
+ if self._sync_client is None:
201
+ msg = "Session not initialized - use 'with AuthSession(...) as session:'"
202
+ raise AuthError(msg)
203
+
204
+ # Merge auth header with any existing headers
205
+ headers = kwargs.pop("headers", {})
206
+ headers.update(self._get_auth_header())
207
+ kwargs["headers"] = headers
208
+
209
+ if logger.isEnabledFor(TRACE_LEVEL):
210
+ logger.log(TRACE_LEVEL, "[SESSION] %s %s", method, url)
211
+
212
+ response = self._sync_client.request(method, url, **kwargs)
213
+
214
+ if logger.isEnabledFor(TRACE_LEVEL):
215
+ logger.log(TRACE_LEVEL, "[SESSION] Response: %d %s", response.status_code, response.reason_phrase)
216
+
217
+ # Retry on 401 if configured
218
+ if self._should_retry(response, _retried):
219
+ logger.debug("Got 401, attempting token refresh and retry")
220
+ try:
221
+ self.provider.refresh()
222
+ return self._request(method, url, _retried=True, **kwargs)
223
+ except Exception: # pylint: disable=broad-exception-caught
224
+ # Intentional catch-all for best-effort refresh
225
+ logger.warning("Token refresh failed, returning original 401 response")
226
+
227
+ return response
228
+
229
+ def get(self, url: str, **kwargs: Any) -> httpx.Response:
230
+ """Make authenticated GET request."""
231
+ return self._request("GET", url, **kwargs)
232
+
233
+ def post(self, url: str, **kwargs: Any) -> httpx.Response:
234
+ """Make authenticated POST request."""
235
+ return self._request("POST", url, **kwargs)
236
+
237
+ def put(self, url: str, **kwargs: Any) -> httpx.Response:
238
+ """Make authenticated PUT request."""
239
+ return self._request("PUT", url, **kwargs)
240
+
241
+ def patch(self, url: str, **kwargs: Any) -> httpx.Response:
242
+ """Make authenticated PATCH request."""
243
+ return self._request("PATCH", url, **kwargs)
244
+
245
+ def delete(self, url: str, **kwargs: Any) -> httpx.Response:
246
+ """Make authenticated DELETE request."""
247
+ return self._request("DELETE", url, **kwargs)
248
+
249
+ def head(self, url: str, **kwargs: Any) -> httpx.Response:
250
+ """Make authenticated HEAD request."""
251
+ return self._request("HEAD", url, **kwargs)
252
+
253
+ def options(self, url: str, **kwargs: Any) -> httpx.Response:
254
+ """Make authenticated OPTIONS request."""
255
+ return self._request("OPTIONS", url, **kwargs)
256
+
257
+ # ─────────────────────────────────────────────────────────────────────────
258
+ # Async HTTP methods
259
+ # ─────────────────────────────────────────────────────────────────────────
260
+
261
+ async def _arequest(
262
+ self,
263
+ method: str,
264
+ url: str,
265
+ *,
266
+ _retried: bool = False,
267
+ **kwargs: Any,
268
+ ) -> httpx.Response:
269
+ """Make an authenticated HTTP request (async).
270
+
271
+ Args:
272
+ method: HTTP method.
273
+ url: Request URL.
274
+ _retried: Internal flag to prevent infinite retry.
275
+ **kwargs: Additional arguments for httpx.
276
+
277
+ Returns:
278
+ HTTP response.
279
+ """
280
+ if self._async_client is None:
281
+ msg = "Session not initialized - use 'async with AuthSession(...) as session:'"
282
+ raise AuthError(msg)
283
+
284
+ # Merge auth header with any existing headers
285
+ headers = kwargs.pop("headers", {})
286
+ headers.update(self._get_auth_header())
287
+ kwargs["headers"] = headers
288
+
289
+ if logger.isEnabledFor(TRACE_LEVEL):
290
+ logger.log(TRACE_LEVEL, "[SESSION] %s %s (async)", method, url)
291
+
292
+ response = await self._async_client.request(method, url, **kwargs)
293
+
294
+ if logger.isEnabledFor(TRACE_LEVEL):
295
+ logger.log(TRACE_LEVEL, "[SESSION] Response: %d %s", response.status_code, response.reason_phrase)
296
+
297
+ # Retry on 401 if configured
298
+ if self._should_retry(response, _retried):
299
+ logger.debug("Got 401, attempting token refresh and retry")
300
+ try:
301
+ self.provider.refresh()
302
+ return await self._arequest(method, url, _retried=True, **kwargs)
303
+ except Exception: # pylint: disable=broad-exception-caught
304
+ # Intentional catch-all for best-effort refresh
305
+ logger.warning("Token refresh failed, returning original 401 response")
306
+
307
+ return response
308
+
309
+ async def aget(self, url: str, **kwargs: Any) -> httpx.Response:
310
+ """Make authenticated async GET request."""
311
+ return await self._arequest("GET", url, **kwargs)
312
+
313
+ async def apost(self, url: str, **kwargs: Any) -> httpx.Response:
314
+ """Make authenticated async POST request."""
315
+ return await self._arequest("POST", url, **kwargs)
316
+
317
+ async def aput(self, url: str, **kwargs: Any) -> httpx.Response:
318
+ """Make authenticated async PUT request."""
319
+ return await self._arequest("PUT", url, **kwargs)
320
+
321
+ async def apatch(self, url: str, **kwargs: Any) -> httpx.Response:
322
+ """Make authenticated async PATCH request."""
323
+ return await self._arequest("PATCH", url, **kwargs)
324
+
325
+ async def adelete(self, url: str, **kwargs: Any) -> httpx.Response:
326
+ """Make authenticated async DELETE request."""
327
+ return await self._arequest("DELETE", url, **kwargs)
328
+
329
+ async def ahead(self, url: str, **kwargs: Any) -> httpx.Response:
330
+ """Make authenticated async HEAD request."""
331
+ return await self._arequest("HEAD", url, **kwargs)
332
+
333
+ async def aoptions(self, url: str, **kwargs: Any) -> httpx.Response:
334
+ """Make authenticated async OPTIONS request."""
335
+ return await self._arequest("OPTIONS", url, **kwargs)
336
+
337
+
338
+ __all__ = ["AuthSession"]