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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.1.dist-info/METADATA +201 -0
- kstlib-1.0.1.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
- kstlib-1.0.1.dist-info/entry_points.txt +2 -0
- kstlib-1.0.1.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {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)
|