kstlib 0.0.1a0__py3-none-any.whl → 1.0.1__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.1.dist-info/METADATA +201 -0
  159. kstlib-1.0.1.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.1.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.1.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.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,516 @@
1
+ """Cache strategy implementations.
2
+
3
+ Provides different caching strategies with unified interface:
4
+ - TTL (Time-To-Live): Cache with expiration time
5
+ - LRU (Least Recently Used): Cache with size limit
6
+ - File: Cache with file modification time tracking (JSON-backed by default)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import inspect
13
+ import io
14
+ import json
15
+ import logging
16
+ import pickle
17
+ import time
18
+ import warnings
19
+ from abc import ABC, abstractmethod
20
+ from collections import OrderedDict
21
+ from collections.abc import Callable
22
+ from pathlib import Path
23
+ from typing import Any, TypeVar, cast
24
+
25
+ from kstlib.limits import CacheLimits, get_cache_limits
26
+ from kstlib.utils.formatting import format_bytes
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ _CACHE_FORMAT_VERSION = "kstlib:file-cache:v1"
31
+ _SUPPORTED_SERIALIZERS: set[str] = {"json", "pickle", "auto"}
32
+ _PICKLE_SAFE_BUILTINS: set[str] = {
33
+ "dict",
34
+ "list",
35
+ "set",
36
+ "tuple",
37
+ "str",
38
+ "int",
39
+ "float",
40
+ "bool",
41
+ "bytes",
42
+ "NoneType",
43
+ }
44
+
45
+
46
+ class _RestrictedUnpickler(pickle.Unpickler):
47
+ """Unpickler that only allows basic builtins used by legacy cache entries."""
48
+
49
+ def find_class(self, module: str, name: str) -> Any:
50
+ if module == "builtins" and name in _PICKLE_SAFE_BUILTINS:
51
+ return super().find_class(module, name)
52
+ raise ValueError(f"Disallowed pickle global: {module}.{name}")
53
+
54
+
55
+ F = TypeVar("F", bound=Callable[..., Any])
56
+
57
+
58
+ class CacheStrategy(ABC):
59
+ """Abstract base class for cache strategies.
60
+
61
+ All cache strategies must implement get() and set() methods
62
+ to store and retrieve cached values.
63
+ """
64
+
65
+ @abstractmethod
66
+ def get(self, key: str) -> Any | None:
67
+ """Retrieve value from cache.
68
+
69
+ Args:
70
+ key: Cache key
71
+
72
+ Returns:
73
+ Cached value or None if not found/expired
74
+ """
75
+
76
+ @abstractmethod
77
+ def set(self, key: str, value: Any) -> None:
78
+ """Store value in cache.
79
+
80
+ Args:
81
+ key: Cache key
82
+ value: Value to cache
83
+ """
84
+
85
+ @abstractmethod
86
+ def clear(self) -> None:
87
+ """Clear all cached values."""
88
+
89
+ @staticmethod
90
+ def make_key(func: Callable[..., Any], args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
91
+ """Generate cache key from function and arguments.
92
+
93
+ Normalizes function calls by binding arguments to signature,
94
+ ensuring that process(1, 2) and process(1, 2, c=0) produce
95
+ the same cache key when c has default value 0.
96
+
97
+ Args:
98
+ func: Function being cached
99
+ args: Positional arguments
100
+ kwargs: Keyword arguments
101
+
102
+ Returns:
103
+ Hash-based cache key
104
+ """
105
+ # Include function module and name
106
+ key_parts = [func.__module__, func.__qualname__]
107
+
108
+ # Bind arguments to function signature to normalize
109
+ try:
110
+ sig = inspect.signature(func)
111
+ bound = sig.bind(*args, **kwargs)
112
+ bound.apply_defaults() # Apply default values
113
+
114
+ # Use normalized bound arguments
115
+ for name, value in bound.arguments.items():
116
+ key_parts.append(f"{name}={value}")
117
+
118
+ except (TypeError, ValueError): # Binding can fail for some callables
119
+ # Fallback to simple key generation if binding fails
120
+ key_parts.extend(f"arg:{arg}" for arg in args)
121
+ key_parts.extend(f"{k}={v}" for k, v in sorted(kwargs.items()))
122
+
123
+ # Generate hash for consistent key
124
+ key_str = "|".join(str(part) for part in key_parts)
125
+ return hashlib.sha256(key_str.encode()).hexdigest()
126
+
127
+
128
+ class TTLCacheStrategy(CacheStrategy):
129
+ """Time-To-Live cache strategy.
130
+
131
+ Caches values with expiration time. Expired entries are removed
132
+ automatically during cleanup or access.
133
+
134
+ Args:
135
+ ttl: Time to live in seconds
136
+ max_entries: Maximum number of cache entries
137
+ cleanup_interval: Seconds between cleanup runs
138
+
139
+ Examples:
140
+ >>> cache = TTLCacheStrategy(ttl=300, max_entries=1000)
141
+ >>> cache.set("key1", "value1")
142
+ >>> cache.get("key1")
143
+ 'value1'
144
+ """
145
+
146
+ def __init__(
147
+ self,
148
+ ttl: int = 300,
149
+ max_entries: int = 1000,
150
+ cleanup_interval: int = 60,
151
+ ) -> None:
152
+ """Initialize TTL cache strategy."""
153
+ self.ttl = ttl
154
+ self.max_entries = max_entries
155
+ self.cleanup_interval = cleanup_interval
156
+ self._cache: OrderedDict[str, tuple[Any, float]] = OrderedDict()
157
+ self._last_cleanup = time.time()
158
+
159
+ def get(self, key: str) -> Any | None:
160
+ """Retrieve value from cache if not expired.
161
+
162
+ Args:
163
+ key: Cache key
164
+
165
+ Returns:
166
+ Cached value or None if expired/not found
167
+ """
168
+ self._maybe_cleanup()
169
+
170
+ if key not in self._cache:
171
+ return None
172
+
173
+ value, expiry = self._cache[key]
174
+
175
+ # Check expiration
176
+ if time.time() > expiry:
177
+ del self._cache[key]
178
+ return None
179
+
180
+ return value
181
+
182
+ def set(self, key: str, value: Any) -> None:
183
+ """Store value in cache with TTL.
184
+
185
+ Args:
186
+ key: Cache key
187
+ value: Value to cache
188
+ """
189
+ # If key exists, remove it first to update order
190
+ if key in self._cache:
191
+ del self._cache[key]
192
+
193
+ # Enforce max entries with O(1) FIFO eviction
194
+ while len(self._cache) >= self.max_entries:
195
+ self._cache.popitem(last=False)
196
+
197
+ expiry = time.time() + self.ttl
198
+ self._cache[key] = (value, expiry)
199
+
200
+ def clear(self) -> None:
201
+ """Clear all cached values."""
202
+ self._cache.clear()
203
+ self._last_cleanup = time.time()
204
+
205
+ def _maybe_cleanup(self) -> None:
206
+ """Run cleanup if interval exceeded."""
207
+ now = time.time()
208
+ if now - self._last_cleanup > self.cleanup_interval:
209
+ self._cleanup()
210
+ self._last_cleanup = now
211
+
212
+ def _cleanup(self) -> None:
213
+ """Remove expired entries."""
214
+ now = time.time()
215
+ expired_keys = [key for key, (_, expiry) in self._cache.items() if now > expiry]
216
+ for key in expired_keys:
217
+ del self._cache[key]
218
+
219
+
220
+ class LRUCacheStrategy(CacheStrategy):
221
+ """Least Recently Used cache strategy.
222
+
223
+ Wraps functools.lru_cache for compatibility with CacheStrategy interface.
224
+
225
+ Args:
226
+ maxsize: Maximum cache size
227
+ typed: If True, cache different types separately
228
+
229
+ Examples:
230
+ >>> cache = LRUCacheStrategy(maxsize=128)
231
+ >>> cache.set("key1", "value1")
232
+ >>> cache.get("key1")
233
+ 'value1'
234
+ """
235
+
236
+ def __init__(self, maxsize: int = 128, typed: bool = False) -> None:
237
+ """Initialize LRU cache strategy."""
238
+ self.maxsize = maxsize
239
+ self.typed = typed
240
+ self._store: OrderedDict[str, Any] = OrderedDict()
241
+
242
+ def get(self, key: str) -> Any | None:
243
+ """Retrieve value from cache and update access order.
244
+
245
+ Args:
246
+ key: Cache key
247
+
248
+ Returns:
249
+ Cached value or None if not found
250
+ """
251
+ if key not in self._store:
252
+ return None
253
+
254
+ self._store.move_to_end(key)
255
+ return self._store[key]
256
+
257
+ def set(self, key: str, value: Any) -> None:
258
+ """Store value in cache with LRU eviction.
259
+
260
+ Args:
261
+ key: Cache key
262
+ value: Value to cache
263
+ """
264
+ # If key exists, update and move to end
265
+ if key in self._store:
266
+ self._store[key] = value
267
+ self._store.move_to_end(key)
268
+ return
269
+
270
+ # Evict LRU if at maxsize
271
+ if len(self._store) >= self.maxsize:
272
+ self._store.popitem(last=False)
273
+
274
+ # Add new entry
275
+ self._store[key] = value
276
+
277
+ def clear(self) -> None:
278
+ """Clear all cached values."""
279
+ self._store.clear()
280
+
281
+
282
+ class FileCacheStrategy(CacheStrategy):
283
+ """File-based cache with mtime checking.
284
+
285
+ Caches function results based on file modification time and persists
286
+ values on disk using JSON serialization by default. A pickle-based
287
+ fallback can be enabled explicitly for trusted environments or
288
+ automatically by using the ``"auto"`` serializer.
289
+
290
+ Args:
291
+ cache_dir: Directory for cache files.
292
+ check_mtime: If True, invalidate cache on file modification.
293
+ serializer: Serialization format (``"json"`` | ``"pickle"`` | ``"auto"``).
294
+ memory_max_entries: Max entries to retain in memory cache.
295
+ limits: Optional CacheLimits for config-driven size limits.
296
+
297
+ Examples:
298
+ >>> cache = FileCacheStrategy(cache_dir=".cache", check_mtime=True)
299
+ >>> # Cache will invalidate if the source file changes
300
+ """
301
+
302
+ #: Default maximum entries for in-memory cache layer.
303
+ DEFAULT_MEMORY_MAX_ENTRIES = 256
304
+
305
+ # pylint: disable=too-many-arguments,too-many-positional-arguments
306
+ def __init__(
307
+ self,
308
+ cache_dir: str = ".cache",
309
+ check_mtime: bool = True,
310
+ serializer: str = "json",
311
+ memory_max_entries: int | None = DEFAULT_MEMORY_MAX_ENTRIES,
312
+ limits: CacheLimits | None = None,
313
+ ) -> None:
314
+ """Initialize file cache strategy."""
315
+ self.cache_dir = Path(cache_dir)
316
+ self.check_mtime = check_mtime
317
+ if serializer not in _SUPPORTED_SERIALIZERS:
318
+ raise ValueError(f"Unsupported serializer '{serializer}'. Supported: {_SUPPORTED_SERIALIZERS}.")
319
+ if serializer == "pickle":
320
+ warnings.warn(
321
+ "pickle serializer is deprecated since v1.56.0 due to security concerns. "
322
+ "Use 'json' (default) or 'auto' for legacy compatibility. "
323
+ "pickle support will be removed in v2.0.0.",
324
+ DeprecationWarning,
325
+ stacklevel=2,
326
+ )
327
+ self.serializer = serializer
328
+ # None means unbounded memory cache; explicit values must be >= 1
329
+ if memory_max_entries is not None and memory_max_entries < 1:
330
+ raise ValueError("memory_max_entries must be at least 1")
331
+ self.memory_max_entries = memory_max_entries
332
+ self._limits = limits or get_cache_limits()
333
+ self._memory_cache: OrderedDict[str, tuple[Any, float]] = OrderedDict()
334
+
335
+ # Create cache directory with proper permissions
336
+ self.cache_dir.mkdir(parents=True, exist_ok=True, mode=0o755)
337
+
338
+ def get(self, key: str) -> Any | None:
339
+ """Retrieve value from cache.
340
+
341
+ Args:
342
+ key: Cache key
343
+
344
+ Returns:
345
+ Cached value or None if not found/invalid
346
+ """
347
+ self._validate_key(key)
348
+ # Check file cache for mtime validation
349
+ cache_file = self.cache_dir / f"{key}.cache"
350
+ if not cache_file.exists():
351
+ # Not in file cache, check memory cache
352
+ if key in self._memory_cache:
353
+ value, _ = self._memory_cache.pop(key)
354
+ self._store_in_memory(key, value)
355
+ return value
356
+ return None
357
+
358
+ try:
359
+ # Validate file size before reading to prevent OOM
360
+ file_size = cache_file.stat().st_size
361
+ if file_size > self._limits.max_file_size:
362
+ logger.warning(
363
+ "Cache file %s exceeds size limit (%s > %s)",
364
+ key,
365
+ format_bytes(file_size),
366
+ self._limits.max_file_size_display,
367
+ )
368
+ cache_file.unlink(missing_ok=True)
369
+ return None
370
+ raw_data = cache_file.read_bytes()
371
+ cached_data = self._deserialize_payload(raw_data)
372
+ except (
373
+ FileNotFoundError,
374
+ OSError,
375
+ ValueError,
376
+ pickle.UnpicklingError,
377
+ json.JSONDecodeError,
378
+ KeyError,
379
+ EOFError,
380
+ ):
381
+ # Corrupted or missing cache file, remove it
382
+ cache_file.unlink(missing_ok=True)
383
+ self._memory_cache.pop(key, None)
384
+ return None
385
+ value = cached_data["value"]
386
+
387
+ # Check mtime if enabled
388
+ if self.check_mtime and "source_mtime" in cached_data:
389
+ source_path = Path(cached_data.get("source_path", ""))
390
+ if source_path.exists():
391
+ current_mtime = source_path.stat().st_mtime
392
+ if current_mtime > cached_data["source_mtime"]:
393
+ # Source modified, invalidate both caches
394
+ cache_file.unlink()
395
+ self._memory_cache.pop(key, None)
396
+ return None
397
+ # Store in memory cache for faster subsequent access
398
+ self._store_in_memory(key, value)
399
+ return value
400
+
401
+ def set(self, key: str, value: Any, source_path: Path | None = None) -> None:
402
+ """Store value in cache.
403
+
404
+ Args:
405
+ key: Cache key
406
+ value: Value to cache
407
+ source_path: Optional source file path for mtime tracking
408
+ """
409
+ self._validate_key(key)
410
+ # Store in memory cache
411
+ self._store_in_memory(key, value)
412
+
413
+ # Store in file cache
414
+ cache_file = self.cache_dir / f"{key}.cache"
415
+
416
+ cached_data: dict[str, Any] = {"value": value}
417
+
418
+ # Add mtime if source path provided
419
+ if source_path and source_path.exists():
420
+ cached_data["source_path"] = str(source_path)
421
+ cached_data["source_mtime"] = source_path.stat().st_mtime
422
+
423
+ try:
424
+ encoded = self._serialize_payload(cached_data)
425
+ except (pickle.PicklingError, TypeError, ValueError) as exc:
426
+ cache_file.unlink(missing_ok=True)
427
+ if self.serializer == "json":
428
+ logger.debug(
429
+ "Skipping disk cache for key %s: value not JSON serializable (%s)",
430
+ key,
431
+ exc,
432
+ )
433
+ return
434
+ return
435
+
436
+ try:
437
+ cache_file.write_bytes(encoded)
438
+ except (OSError, pickle.PicklingError):
439
+ # Failed to write cache, continue without it (disk full, permission error, etc.)
440
+ cache_file.unlink(missing_ok=True)
441
+
442
+ def clear(self) -> None:
443
+ """Clear all cached values."""
444
+ self._memory_cache.clear()
445
+
446
+ # Remove cache files
447
+ for cache_file in self.cache_dir.glob("*.cache"):
448
+ cache_file.unlink(missing_ok=True)
449
+
450
+ def _store_in_memory(self, key: str, value: Any) -> None:
451
+ """Write a value to the in-memory cache with LRU eviction."""
452
+ self._memory_cache[key] = (value, time.time())
453
+ self._memory_cache.move_to_end(key)
454
+ # Skip eviction when memory_max_entries is None (unbounded)
455
+ if self.memory_max_entries is not None:
456
+ while len(self._memory_cache) > self.memory_max_entries:
457
+ self._memory_cache.popitem(last=False)
458
+
459
+ def _serialize_payload(self, payload: dict[str, Any]) -> bytes:
460
+ """Serialize cached payload according to the configured serializer."""
461
+ if self.serializer == "json":
462
+ return self._serialize_json(payload)
463
+ if self.serializer == "pickle":
464
+ return pickle.dumps(payload)
465
+ if self.serializer == "auto":
466
+ try:
467
+ return self._serialize_json(payload)
468
+ except (TypeError, ValueError):
469
+ return pickle.dumps(payload)
470
+ raise ValueError(f"Unknown serializer '{self.serializer}'")
471
+
472
+ def _serialize_json(self, payload: dict[str, Any]) -> bytes:
473
+ wrapped = {"_format": _CACHE_FORMAT_VERSION, "payload": payload}
474
+ return json.dumps(wrapped, default=self._json_default).encode("utf-8")
475
+
476
+ @staticmethod
477
+ def _validate_key(key: str) -> None:
478
+ """Ensure the cache key cannot escape the cache directory."""
479
+ if (".." in key) or ("/" in key) or ("\\" in key):
480
+ raise ValueError(f"Invalid cache key contains path traversal characters: {key!r}")
481
+
482
+ @staticmethod
483
+ def _json_default(value: Any) -> Any:
484
+ if isinstance(value, Path):
485
+ return str(value)
486
+ raise TypeError(f"Object of type {type(value)!r} is not JSON serializable")
487
+
488
+ def _deserialize_payload(self, data: bytes) -> dict[str, Any]:
489
+ """Deserialize payload, attempting JSON first and falling back to pickle."""
490
+ if not data:
491
+ raise ValueError("Empty cache payload")
492
+
493
+ try:
494
+ text = data.decode("utf-8")
495
+ except UnicodeDecodeError:
496
+ return self._load_legacy_pickle(data)
497
+
498
+ try:
499
+ payload: Any = json.loads(text)
500
+ except json.JSONDecodeError:
501
+ return self._load_legacy_pickle(data)
502
+
503
+ if isinstance(payload, dict) and payload.get("_format") == _CACHE_FORMAT_VERSION:
504
+ payload = payload["payload"]
505
+
506
+ if not isinstance(payload, dict):
507
+ raise TypeError("Invalid cache payload structure")
508
+
509
+ return payload
510
+
511
+ @staticmethod
512
+ def _load_legacy_pickle(data: bytes) -> dict[str, Any]:
513
+ """Load trusted legacy pickle payloads used before JSON became default."""
514
+ buffer = io.BytesIO(data)
515
+ payload = _RestrictedUnpickler(buffer).load()
516
+ return cast("dict[str, Any]", payload)
kstlib/cli/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Command-line interface module.
2
+
3
+ This module provides CLI commands using Typer and Rich.
4
+ """
5
+
6
+ from kstlib.cli.app import app
7
+
8
+ __all__ = ["app"]