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/token.py ADDED
@@ -0,0 +1,482 @@
1
+ """Token storage backends for the authentication module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from abc import ABC, abstractmethod
7
+ from contextlib import contextmanager
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from kstlib.auth.errors import TokenStorageError
12
+ from kstlib.auth.models import Token
13
+ from kstlib.logging import TRACE_LEVEL, get_logger
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Iterator
17
+
18
+ logger = get_logger(__name__)
19
+
20
+ # Deep defense: provider name limits
21
+ _MAX_PROVIDER_NAME_LENGTH = 128
22
+ _MIN_PROVIDER_NAME_LENGTH = 1
23
+
24
+
25
+ def _validate_provider_name(provider_name: str) -> None:
26
+ """Validate provider name for security.
27
+
28
+ Args:
29
+ provider_name: Provider identifier to validate.
30
+
31
+ Raises:
32
+ TokenStorageError: If provider name is invalid.
33
+ """
34
+ if not provider_name or len(provider_name) < _MIN_PROVIDER_NAME_LENGTH:
35
+ raise TokenStorageError("Provider name cannot be empty")
36
+ if len(provider_name) > _MAX_PROVIDER_NAME_LENGTH:
37
+ raise TokenStorageError(f"Provider name exceeds maximum length ({_MAX_PROVIDER_NAME_LENGTH})")
38
+
39
+
40
+ class AbstractTokenStorage(ABC):
41
+ """Abstract base class for token storage backends.
42
+
43
+ Implementations handle persisting and retrieving tokens, with optional
44
+ encryption (e.g., SOPS) for secure storage.
45
+ """
46
+
47
+ @abstractmethod
48
+ def save(self, provider_name: str, token: Token) -> None:
49
+ """Persist a token for a provider.
50
+
51
+ Args:
52
+ provider_name: Provider identifier.
53
+ token: Token to save.
54
+
55
+ Raises:
56
+ TokenStorageError: If save fails.
57
+ """
58
+
59
+ @abstractmethod
60
+ def load(self, provider_name: str) -> Token | None:
61
+ """Load a token for a provider.
62
+
63
+ Args:
64
+ provider_name: Provider identifier.
65
+
66
+ Returns:
67
+ Token if found, None otherwise.
68
+
69
+ Raises:
70
+ TokenStorageError: If load fails (not for missing tokens).
71
+ """
72
+
73
+ @abstractmethod
74
+ def delete(self, provider_name: str) -> bool:
75
+ """Delete a token for a provider.
76
+
77
+ Args:
78
+ provider_name: Provider identifier.
79
+
80
+ Returns:
81
+ True if token existed and was deleted.
82
+ """
83
+
84
+ @abstractmethod
85
+ def exists(self, provider_name: str) -> bool:
86
+ """Check if a token exists for a provider.
87
+
88
+ Args:
89
+ provider_name: Provider identifier.
90
+
91
+ Returns:
92
+ True if token exists.
93
+ """
94
+
95
+ @contextmanager
96
+ def sensitive_token(self, provider_name: str) -> Iterator[Token | None]:
97
+ """Context manager for secure token access.
98
+
99
+ Loads the token and yields it. On exit, clears the reference.
100
+ Subclasses may implement additional cleanup (e.g., memory scrubbing).
101
+
102
+ Args:
103
+ provider_name: Provider identifier.
104
+
105
+ Yields:
106
+ Token if available, None otherwise.
107
+
108
+ Example:
109
+ >>> with storage.sensitive_token("corporate") as token: # doctest: +SKIP
110
+ ... if token:
111
+ ... print(token.access_token)
112
+ ... # token reference cleared here
113
+ """
114
+ token = self.load(provider_name)
115
+ try:
116
+ yield token
117
+ finally:
118
+ del token
119
+
120
+
121
+ class MemoryTokenStorage(AbstractTokenStorage):
122
+ """In-memory token storage (for development/testing).
123
+
124
+ Tokens are stored in a dictionary and lost when the process exits.
125
+ No encryption or persistence.
126
+ """
127
+
128
+ def __init__(self) -> None:
129
+ """Initialize empty storage."""
130
+ self._tokens: dict[str, Token] = {}
131
+
132
+ def save(self, provider_name: str, token: Token) -> None:
133
+ """Store token in memory."""
134
+ _validate_provider_name(provider_name)
135
+ self._tokens[provider_name] = token
136
+ logger.debug("Token saved in memory for provider '%s'", provider_name)
137
+
138
+ def load(self, provider_name: str) -> Token | None:
139
+ """Retrieve token from memory."""
140
+ return self._tokens.get(provider_name)
141
+
142
+ def delete(self, provider_name: str) -> bool:
143
+ """Remove token from memory."""
144
+ if provider_name in self._tokens:
145
+ del self._tokens[provider_name]
146
+ logger.debug("Token deleted from memory for provider '%s'", provider_name)
147
+ return True
148
+ return False
149
+
150
+ def exists(self, provider_name: str) -> bool:
151
+ """Check if token exists in memory."""
152
+ return provider_name in self._tokens
153
+
154
+ def clear_all(self) -> None:
155
+ """Clear all tokens from memory."""
156
+ self._tokens.clear()
157
+
158
+
159
+ class FileTokenStorage(AbstractTokenStorage):
160
+ """Plain JSON file token storage.
161
+
162
+ Tokens are stored as unencrypted JSON files with restrictive permissions (600).
163
+ Suitable for development, testing, or environments where SOPS is unavailable.
164
+
165
+ Warning:
166
+ Tokens are stored in plaintext. Use SOPS storage for production environments
167
+ where token confidentiality is critical.
168
+ """
169
+
170
+ _warned: bool = False # Class-level flag for one-time warning
171
+
172
+ def __init__(
173
+ self,
174
+ directory: Path | str | None = None,
175
+ ) -> None:
176
+ """Initialize file storage.
177
+
178
+ Args:
179
+ directory: Directory to store token files.
180
+ Default: ~/.config/kstlib/auth/tokens
181
+ """
182
+ if directory is None:
183
+ directory = Path.home() / ".config" / "kstlib" / "auth" / "tokens"
184
+ self.directory = Path(directory)
185
+ self.directory.mkdir(parents=True, exist_ok=True, mode=0o700)
186
+
187
+ def _token_path(self, provider_name: str) -> Path:
188
+ """Get the file path for a provider's token."""
189
+ _validate_provider_name(provider_name)
190
+ safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in provider_name)
191
+ return self.directory / f"{safe_name}.token.json"
192
+
193
+ def save(self, provider_name: str, token: Token) -> None:
194
+ """Save token to JSON file with restrictive permissions."""
195
+ import stat
196
+
197
+ # One-time warning about unencrypted storage (only on save, not on read/delete)
198
+ if not FileTokenStorage._warned:
199
+ logger.warning(
200
+ "FileTokenStorage: Tokens will be stored UNENCRYPTED at %s. "
201
+ "Consider using 'sops' storage for sensitive environments.",
202
+ self.directory,
203
+ )
204
+ FileTokenStorage._warned = True
205
+
206
+ path = self._token_path(provider_name)
207
+
208
+ if logger.isEnabledFor(TRACE_LEVEL):
209
+ logger.log(TRACE_LEVEL, "[TOKEN] Saving to file: %s", path)
210
+ data = token.to_dict()
211
+
212
+ try:
213
+ # Write to file
214
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
215
+
216
+ # Set restrictive permissions (owner read/write only: 600)
217
+ path.chmod(stat.S_IRUSR | stat.S_IWUSR)
218
+
219
+ logger.debug("Token saved (plaintext) for provider '%s': %s", provider_name, path)
220
+ except OSError as e:
221
+ msg = f"Failed to save token for '{provider_name}': {e}"
222
+ raise TokenStorageError(msg) from e
223
+
224
+ def load(self, provider_name: str) -> Token | None:
225
+ """Load token from JSON file."""
226
+ path = self._token_path(provider_name)
227
+ if not path.exists():
228
+ if logger.isEnabledFor(TRACE_LEVEL):
229
+ logger.log(TRACE_LEVEL, "[TOKEN] File not found: %s", path)
230
+ return None
231
+
232
+ if logger.isEnabledFor(TRACE_LEVEL):
233
+ logger.log(TRACE_LEVEL, "[TOKEN] Loading from file: %s", path)
234
+
235
+ try:
236
+ data = json.loads(path.read_text(encoding="utf-8"))
237
+ return Token.from_dict(data)
238
+ except (json.JSONDecodeError, KeyError) as e:
239
+ logger.warning("Failed to parse token file for '%s': %s", provider_name, e)
240
+ return None
241
+ except OSError as e:
242
+ logger.warning("Failed to read token file for '%s': %s", provider_name, e)
243
+ return None
244
+
245
+ def delete(self, provider_name: str) -> bool:
246
+ """Delete token file."""
247
+ path = self._token_path(provider_name)
248
+ if path.exists():
249
+ path.unlink()
250
+ logger.debug("Token file deleted for provider '%s'", provider_name)
251
+ return True
252
+ return False
253
+
254
+ def exists(self, provider_name: str) -> bool:
255
+ """Check if token file exists."""
256
+ return self._token_path(provider_name).exists()
257
+
258
+
259
+ class SOPSTokenStorage(AbstractTokenStorage):
260
+ """SOPS-encrypted token storage.
261
+
262
+ Tokens are encrypted using SOPS before being written to disk.
263
+ Uses the SOPS CLI directly for encryption/decryption operations.
264
+ """
265
+
266
+ def __init__(
267
+ self,
268
+ directory: Path | str,
269
+ *,
270
+ sops_binary: str = "sops",
271
+ age_recipients: list[str] | None = None,
272
+ ) -> None:
273
+ """Initialize SOPS storage.
274
+
275
+ Args:
276
+ directory: Directory to store encrypted token files.
277
+ sops_binary: Path to sops binary (default: "sops").
278
+ age_recipients: Age public keys for encryption.
279
+ If not provided, relies on .sops.yaml or environment.
280
+ """
281
+ import shutil
282
+
283
+ self.directory = Path(directory)
284
+ self.directory.mkdir(parents=True, exist_ok=True, mode=0o700)
285
+ self.sops_binary = shutil.which(sops_binary) or sops_binary
286
+ self.age_recipients = age_recipients
287
+
288
+ def _token_path(self, provider_name: str) -> Path:
289
+ """Get the file path for a provider's encrypted token."""
290
+ _validate_provider_name(provider_name)
291
+ safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in provider_name)
292
+ return self.directory / f"{safe_name}.token.sops.json"
293
+
294
+ def _run_sops(
295
+ self,
296
+ args: list[str],
297
+ *,
298
+ input_data: str | None = None,
299
+ ) -> str:
300
+ """Run SOPS command and return output."""
301
+ import subprocess
302
+
303
+ cmd = [self.sops_binary, *args]
304
+
305
+ if logger.isEnabledFor(TRACE_LEVEL):
306
+ # Log command without sensitive data
307
+ safe_args = [a for a in args if not a.startswith("/")] # Redact paths
308
+ logger.log(TRACE_LEVEL, "[SOPS] Running: sops %s", " ".join(safe_args[:3]))
309
+
310
+ try:
311
+ result = subprocess.run(
312
+ cmd,
313
+ input=input_data,
314
+ capture_output=True,
315
+ text=True,
316
+ check=True,
317
+ )
318
+
319
+ if logger.isEnabledFor(TRACE_LEVEL):
320
+ logger.log(TRACE_LEVEL, "[SOPS] Command succeeded")
321
+
322
+ return result.stdout
323
+ except subprocess.CalledProcessError as e:
324
+ # Redact potentially sensitive output
325
+ stderr = e.stderr or ""
326
+ if "could not decrypt" in stderr.lower():
327
+ stderr = "Decryption failed (credentials/keys may be missing)"
328
+ msg = f"SOPS command failed: {stderr}"
329
+ raise TokenStorageError(msg) from e
330
+ except FileNotFoundError as e:
331
+ msg = f"SOPS binary not found at '{self.sops_binary}'"
332
+ raise TokenStorageError(msg) from e
333
+
334
+ def save(self, provider_name: str, token: Token) -> None:
335
+ """Save token encrypted with SOPS."""
336
+ import tempfile
337
+
338
+ path = self._token_path(provider_name)
339
+
340
+ if logger.isEnabledFor(TRACE_LEVEL):
341
+ logger.log(TRACE_LEVEL, "[TOKEN] Encrypting and saving to: %s", path)
342
+
343
+ data = token.to_dict()
344
+ json_data = json.dumps(data, indent=2)
345
+
346
+ try:
347
+ # Write plaintext to temp file, then encrypt to target
348
+ with tempfile.NamedTemporaryFile(
349
+ mode="w",
350
+ suffix=".json",
351
+ delete=False,
352
+ encoding="utf-8",
353
+ ) as tmp:
354
+ tmp.write(json_data)
355
+ tmp_path = Path(tmp.name)
356
+
357
+ try:
358
+ from kstlib.secure.permissions import FilePermissions
359
+
360
+ # Remove existing file (READONLY can't be overwritten)
361
+ if path.exists():
362
+ path.chmod(FilePermissions.OWNER_RW) # Unlock for deletion
363
+ path.unlink()
364
+
365
+ # Build SOPS encrypt command
366
+ args = ["--encrypt", "--output", str(path)]
367
+
368
+ # Add age recipients if specified
369
+ if self.age_recipients:
370
+ args.extend(["--age", ",".join(self.age_recipients)])
371
+
372
+ args.append(str(tmp_path))
373
+ self._run_sops(args)
374
+
375
+ # Read-only: token files are immutable once written
376
+ path.chmod(FilePermissions.READONLY)
377
+ logger.debug("Token saved (SOPS encrypted) for provider '%s': %s", provider_name, path)
378
+ finally:
379
+ # Clean up temp file
380
+ tmp_path.unlink(missing_ok=True)
381
+
382
+ except Exception as e:
383
+ if isinstance(e, TokenStorageError):
384
+ raise
385
+ msg = f"Failed to save encrypted token for '{provider_name}': {e}"
386
+ raise TokenStorageError(msg) from e
387
+
388
+ def load(self, provider_name: str) -> Token | None:
389
+ """Load and decrypt token from SOPS file."""
390
+ path = self._token_path(provider_name)
391
+ if not path.exists():
392
+ if logger.isEnabledFor(TRACE_LEVEL):
393
+ logger.log(TRACE_LEVEL, "[TOKEN] Encrypted file not found: %s", path)
394
+ return None
395
+
396
+ if logger.isEnabledFor(TRACE_LEVEL):
397
+ logger.log(TRACE_LEVEL, "[TOKEN] Decrypting from: %s", path)
398
+
399
+ try:
400
+ decrypted = self._run_sops(["--decrypt", str(path)])
401
+ data = json.loads(decrypted)
402
+ return Token.from_dict(data)
403
+ except TokenStorageError:
404
+ logger.warning("Failed to decrypt token for '%s'", provider_name)
405
+ return None
406
+ except (json.JSONDecodeError, KeyError) as e:
407
+ logger.warning("Failed to parse decrypted token for '%s': %s", provider_name, e)
408
+ return None
409
+
410
+ def delete(self, provider_name: str) -> bool:
411
+ """Delete encrypted token file."""
412
+ from kstlib.secure.permissions import FilePermissions
413
+
414
+ path = self._token_path(provider_name)
415
+ if path.exists():
416
+ path.chmod(FilePermissions.OWNER_RW) # Unlock READONLY file
417
+ path.unlink()
418
+ logger.debug("Encrypted token file deleted for provider '%s'", provider_name)
419
+ return True
420
+ return False
421
+
422
+ def exists(self, provider_name: str) -> bool:
423
+ """Check if encrypted token file exists."""
424
+ return self._token_path(provider_name).exists()
425
+
426
+ @contextmanager
427
+ def sensitive_token(self, provider_name: str) -> Iterator[Token | None]:
428
+ """Context manager for secure token access with cleanup."""
429
+ token = self.load(provider_name)
430
+ try:
431
+ yield token
432
+ finally:
433
+ # Clear reference (Python GC will handle the rest)
434
+ del token
435
+
436
+
437
+ def get_token_storage(
438
+ storage_type: str = "memory",
439
+ *,
440
+ directory: Path | str | None = None,
441
+ **kwargs: Any,
442
+ ) -> AbstractTokenStorage:
443
+ """Factory function to create a token storage backend.
444
+
445
+ Args:
446
+ storage_type: Type of storage ("memory", "file", or "sops").
447
+ directory: Directory for file/SOPS storage (default: ~/.config/kstlib/auth/tokens).
448
+ **kwargs: Additional arguments for SOPS storage (e.g., age_recipients).
449
+
450
+ Returns:
451
+ Token storage instance.
452
+
453
+ Raises:
454
+ ValueError: If storage_type is unknown.
455
+
456
+ Example:
457
+ >>> storage = get_token_storage("memory")
458
+ >>> storage = get_token_storage("file", directory="/tmp/tokens") # doctest: +SKIP
459
+ >>> storage = get_token_storage("sops", directory="/tmp/tokens") # doctest: +SKIP
460
+ """
461
+ if storage_type == "memory":
462
+ return MemoryTokenStorage()
463
+
464
+ if storage_type == "file":
465
+ return FileTokenStorage(directory)
466
+
467
+ if storage_type == "sops":
468
+ if directory is None:
469
+ directory = Path.home() / ".config" / "kstlib" / "auth" / "tokens"
470
+ return SOPSTokenStorage(directory, **kwargs)
471
+
472
+ msg = f"Unknown storage type: {storage_type}. Use 'memory', 'file', or 'sops'."
473
+ raise ValueError(msg)
474
+
475
+
476
+ __all__ = [
477
+ "AbstractTokenStorage",
478
+ "FileTokenStorage",
479
+ "MemoryTokenStorage",
480
+ "SOPSTokenStorage",
481
+ "get_token_storage",
482
+ ]
@@ -0,0 +1,50 @@
1
+ """Cache module for kstlib.
2
+
3
+ Provides flexible caching decorators with multiple strategies:
4
+ - TTL (Time-To-Live) based caching
5
+ - LRU (Least Recently Used) caching
6
+ - File-based caching with mtime invalidation
7
+ - Full async/await support
8
+
9
+ Examples:
10
+ Basic usage with default TTL strategy::
11
+
12
+ from kstlib.cache import cache
13
+
14
+ @cache
15
+ def expensive_function(x: int) -> int:
16
+ return x * 2
17
+
18
+ Async function caching::
19
+
20
+ @cache(ttl=60)
21
+ async def fetch_data(url: str) -> dict:
22
+ # Automatically detects async and handles appropriately
23
+ return await http_get(url)
24
+
25
+ LRU strategy::
26
+
27
+ @cache(strategy="lru", maxsize=256)
28
+ def compute_fibonacci(n: int) -> int:
29
+ if n < 2:
30
+ return n
31
+ return compute_fibonacci(n-1) + compute_fibonacci(n-2)
32
+
33
+ File-based caching with mtime checking::
34
+
35
+ @cache(strategy="file", check_mtime=True)
36
+ def load_config(path: str) -> dict:
37
+ # Cache invalidates automatically if file modified
38
+ return parse_yaml(path)
39
+ """
40
+
41
+ from kstlib.cache.decorator import cache
42
+ from kstlib.cache.strategies import CacheStrategy, FileCacheStrategy, LRUCacheStrategy, TTLCacheStrategy
43
+
44
+ __all__ = [
45
+ "CacheStrategy",
46
+ "FileCacheStrategy",
47
+ "LRUCacheStrategy",
48
+ "TTLCacheStrategy",
49
+ "cache",
50
+ ]