advalcache 0.1.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.
advalcache/__init__.py ADDED
@@ -0,0 +1,320 @@
1
+ """advalcache/__init__.py — Public API surface.
2
+
3
+ from advalcache import Cache
4
+ cache = Cache(capacity_mb=512)
5
+
6
+ @cache.cached(regen_ms=800, regen_usd=0.015, change_interval_s=60)
7
+ def get_stock_price(ticker: str) -> dict:
8
+ return api.fetch(ticker)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import threading
14
+ import time
15
+ from typing import Any, Callable, Optional, Tuple
16
+
17
+ from advalcache.config import CacheConfig
18
+ from advalcache.stats import CacheStats
19
+ from advalcache._engine import AdValEngine, AsyncAdValEngine, CacheEntry
20
+ from advalcache.decorators import make_cached_decorator
21
+
22
+ __version__ = "0.1.0"
23
+ __all__ = [
24
+ "Cache",
25
+ "AsyncCache",
26
+ "CacheConfig",
27
+ "CacheStats",
28
+ "CacheEntry",
29
+ "__version__",
30
+ ]
31
+
32
+
33
+ class Cache:
34
+ """AdVal in-memory cache — synchronous.
35
+
36
+ The simplest usage::
37
+
38
+ from advalcache import Cache
39
+
40
+ cache = Cache(capacity_mb=256)
41
+
42
+ @cache.cached(regen_ms=800, regen_usd=0.015, change_interval_s=60)
43
+ def fetch_quote(ticker: str) -> dict:
44
+ return live_api.get(ticker)
45
+
46
+ For full configuration, pass a ``CacheConfig``::
47
+
48
+ cache = Cache(config=CacheConfig(capacity_mb=512, obs_port=9999))
49
+
50
+ Can also be used as a context manager for automatic shutdown::
51
+
52
+ with Cache(capacity_mb=64) as cache:
53
+ ...
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ capacity_mb: float = 256.0,
59
+ *,
60
+ config: Optional[CacheConfig] = None,
61
+ ) -> None:
62
+ if config is None:
63
+ config = CacheConfig(capacity_mb=capacity_mb)
64
+ self._config = config
65
+ self._engine = AdValEngine(**config.engine_kwargs())
66
+ self.cached = make_cached_decorator(
67
+ self._engine, key_prefix=config.key_prefix, is_async=False
68
+ )
69
+ self._obs_server: Any = None
70
+ if config.obs_port is not None:
71
+ self._start_obs_server(config.obs_port)
72
+
73
+ # ------------------------------------------------------------------
74
+ # Manual get / put / invalidate
75
+ # ------------------------------------------------------------------
76
+
77
+ def get(
78
+ self,
79
+ key: str,
80
+ refresh_callback: Optional[Callable[[], Tuple[Any, float, int]]] = None,
81
+ ) -> Optional[Tuple[Any, bool, float, float]]:
82
+ """Look up a key.
83
+
84
+ Returns ``(value, is_fresh, freshness, score)`` on a hit,
85
+ or ``None`` on a miss.
86
+
87
+ Args:
88
+ key: The cache key.
89
+ refresh_callback: Optional callable that returns ``(data, latency_ms,
90
+ size_bytes)``. If the entry is stale but high-value, AdVal calls
91
+ this in a background thread to re-sync the entry.
92
+ """
93
+ return self._engine.get(key, refresh_callback)
94
+
95
+ def put(
96
+ self,
97
+ key: str,
98
+ value: Any,
99
+ *,
100
+ regen_ms: float = 100.0,
101
+ regen_usd: float = 0.0,
102
+ change_interval_s: float = 0.0,
103
+ cls: str = "generic",
104
+ size_bytes: Optional[int] = None,
105
+ ) -> bool:
106
+ """Admit or update an entry.
107
+
108
+ Returns True if the entry was admitted, False if declined by
109
+ admission control (e.g. too volatile and too cheap).
110
+
111
+ Args:
112
+ key: Cache key.
113
+ value: The value to cache.
114
+ regen_ms: Regeneration latency in milliseconds.
115
+ regen_usd: Regeneration cost in USD.
116
+ change_interval_s: How often this object changes (seconds). 0 = immutable.
117
+ cls: Object class label for telemetry (informational only).
118
+ size_bytes: Override the estimated memory footprint. If None, estimated
119
+ via ``sys.getsizeof(value)``.
120
+ """
121
+ return self._engine.put(
122
+ key=key, data=value, cls=cls,
123
+ size_bytes=size_bytes,
124
+ regen_ms=regen_ms, regen_usd=regen_usd,
125
+ change_interval_s=change_interval_s,
126
+ )
127
+
128
+ def invalidate(self, key: str) -> bool:
129
+ """Remove a key from the cache. Returns True if it was resident."""
130
+ return self._engine.invalidate(key)
131
+
132
+ def invalidate_prefix(self, prefix: str) -> int:
133
+ """Remove all keys that start with ``prefix``. Returns count removed."""
134
+ return self._engine.invalidate_prefix(prefix)
135
+
136
+ def clear(self) -> None:
137
+ """Flush all entries and reset all metrics."""
138
+ self._engine.clear()
139
+
140
+ # ------------------------------------------------------------------
141
+ # Telemetry helpers
142
+ # ------------------------------------------------------------------
143
+
144
+ def record_ram_latency(self, ms: float) -> None:
145
+ """Record a measured RAM retrieval latency for stats reporting."""
146
+ self._engine.record_ram_latency(ms)
147
+
148
+ def record_origin_latency(self, ms: float) -> None:
149
+ """Record a measured origin (DB / API) latency for stats reporting."""
150
+ self._engine.record_origin_latency(ms)
151
+
152
+ # ------------------------------------------------------------------
153
+ # Stats
154
+ # ------------------------------------------------------------------
155
+
156
+ def get_stats(self) -> CacheStats:
157
+ """Return a frozen snapshot of current telemetry."""
158
+ return CacheStats.from_snapshot(self._engine.snapshot())
159
+
160
+ # ------------------------------------------------------------------
161
+ # Properties
162
+ # ------------------------------------------------------------------
163
+
164
+ @property
165
+ def count(self) -> int:
166
+ """Number of entries currently in the cache."""
167
+ return self._engine.count
168
+
169
+ @property
170
+ def resident_mb(self) -> float:
171
+ """Current resident memory footprint in MB."""
172
+ return self._engine.resident_mb
173
+
174
+ @property
175
+ def capacity_mb(self) -> float:
176
+ """Configured maximum capacity in MB."""
177
+ return self._engine.capacity_mb
178
+
179
+ @property
180
+ def config(self) -> CacheConfig:
181
+ return self._config
182
+
183
+ # ------------------------------------------------------------------
184
+ # Context manager
185
+ # ------------------------------------------------------------------
186
+
187
+ def __enter__(self) -> "Cache":
188
+ return self
189
+
190
+ def __exit__(self, *_: Any) -> None:
191
+ self.shutdown()
192
+
193
+ def shutdown(self, wait: bool = True) -> None:
194
+ """Gracefully stop background refresh threads."""
195
+ self._engine.shutdown(wait)
196
+ if self._obs_server is not None:
197
+ try:
198
+ self._obs_server.shutdown()
199
+ except Exception:
200
+ pass
201
+
202
+ # ------------------------------------------------------------------
203
+ # Built-in observability server (optional)
204
+ # ------------------------------------------------------------------
205
+
206
+ def _start_obs_server(self, port: int) -> None:
207
+ """Start the built-in FastAPI observability server on a background thread."""
208
+ try:
209
+ from advalcache.server import ObsServer
210
+ self._obs_server = ObsServer(self._engine, port=port)
211
+ t = threading.Thread(target=self._obs_server.run, daemon=True)
212
+ t.start()
213
+ except ImportError:
214
+ import warnings
215
+ warnings.warn(
216
+ "advalcache: obs_port requires 'advalcache[fastapi]' extra. "
217
+ "Install with: pip install advalcache[fastapi]",
218
+ stacklevel=3,
219
+ )
220
+
221
+ def __repr__(self) -> str:
222
+ return (
223
+ f"Cache(capacity_mb={self.capacity_mb}, "
224
+ f"resident={self.resident_mb:.2f}MB, "
225
+ f"count={self.count})"
226
+ )
227
+
228
+
229
+ class AsyncCache:
230
+ """AdVal in-memory cache — async version.
231
+
232
+ Drop-in replacement for ``Cache`` when your functions are ``async def``.
233
+ Uses ``asyncio.Lock`` internally — safe in any event loop without blocking.
234
+
235
+ Usage::
236
+
237
+ from advalcache import AsyncCache
238
+
239
+ cache = AsyncCache(capacity_mb=256)
240
+
241
+ @cache.cached(regen_ms=800, regen_usd=0.015, change_interval_s=60)
242
+ async def fetch_quote(ticker: str) -> dict:
243
+ return await live_api.get_async(ticker)
244
+ """
245
+
246
+ def __init__(
247
+ self,
248
+ capacity_mb: float = 256.0,
249
+ *,
250
+ config: Optional[CacheConfig] = None,
251
+ ) -> None:
252
+ if config is None:
253
+ config = CacheConfig(capacity_mb=capacity_mb)
254
+ self._config = config
255
+ self._engine = AsyncAdValEngine(**config.engine_kwargs())
256
+ self.cached = make_cached_decorator(
257
+ self._engine, key_prefix=config.key_prefix, is_async=True
258
+ )
259
+
260
+ async def get(
261
+ self,
262
+ key: str,
263
+ refresh_callback: Optional[Callable] = None,
264
+ ) -> Optional[Tuple[Any, bool, float, float]]:
265
+ return await self._engine.get(key, refresh_callback)
266
+
267
+ async def put(
268
+ self,
269
+ key: str,
270
+ value: Any,
271
+ *,
272
+ regen_ms: float = 100.0,
273
+ regen_usd: float = 0.0,
274
+ change_interval_s: float = 0.0,
275
+ cls: str = "generic",
276
+ size_bytes: Optional[int] = None,
277
+ ) -> bool:
278
+ return await self._engine.put(
279
+ key=key, data=value, cls=cls,
280
+ size_bytes=size_bytes,
281
+ regen_ms=regen_ms, regen_usd=regen_usd,
282
+ change_interval_s=change_interval_s,
283
+ )
284
+
285
+ async def invalidate(self, key: str) -> bool:
286
+ return await self._engine.invalidate(key)
287
+
288
+ async def invalidate_prefix(self, prefix: str) -> int:
289
+ return await self._engine.invalidate_prefix(prefix)
290
+
291
+ async def clear(self) -> None:
292
+ await self._engine.clear()
293
+
294
+ def get_stats(self) -> CacheStats:
295
+ return CacheStats.from_snapshot(self._engine.snapshot())
296
+
297
+ @property
298
+ def count(self) -> int:
299
+ return self._engine.count
300
+
301
+ @property
302
+ def resident_mb(self) -> float:
303
+ return self._engine.resident_mb
304
+
305
+ @property
306
+ def capacity_mb(self) -> float:
307
+ return self._engine.capacity_mb
308
+
309
+ async def __aenter__(self) -> "AsyncCache":
310
+ return self
311
+
312
+ async def __aexit__(self, *_: Any) -> None:
313
+ self._engine.shutdown(wait=False)
314
+
315
+ def __repr__(self) -> str:
316
+ return (
317
+ f"AsyncCache(capacity_mb={self.capacity_mb}, "
318
+ f"resident={self.resident_mb:.2f}MB, "
319
+ f"count={self.count})"
320
+ )
advalcache/__main__.py ADDED
@@ -0,0 +1,58 @@
1
+ """advalcache/__main__.py — CLI entry point.
2
+
3
+ python -m advalcache --help
4
+ advalcache serve --port 9999 --capacity-mb 256
5
+ advalcache version
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser(
16
+ prog="advalcache",
17
+ description="AdVal Cache SDK — CLI tools",
18
+ )
19
+ sub = parser.add_subparsers(dest="command")
20
+
21
+ # version
22
+ sub.add_parser("version", help="Print the advalcache version and exit.")
23
+
24
+ # serve
25
+ serve_p = sub.add_parser("serve", help="Start a standalone AdVal observability server.")
26
+ serve_p.add_argument("--port", type=int, default=9999, help="HTTP port (default: 9999)")
27
+ serve_p.add_argument("--capacity-mb", type=float, default=256.0,
28
+ help="Cache capacity in MB (default: 256)")
29
+
30
+ args = parser.parse_args()
31
+
32
+ if args.command == "version":
33
+ from advalcache import __version__
34
+ print(f"advalcache {__version__}")
35
+
36
+ elif args.command == "serve":
37
+ print(f"Starting AdVal observability server on http://127.0.0.1:{args.port}")
38
+ print(f"Cache capacity: {args.capacity_mb} MB")
39
+ print("Press Ctrl+C to stop.")
40
+ try:
41
+ from advalcache import Cache
42
+ from advalcache.server import ObsServer
43
+ cache = Cache(capacity_mb=args.capacity_mb)
44
+ server = ObsServer(cache._engine, port=args.port)
45
+ server.run()
46
+ except ImportError:
47
+ print("ERROR: 'advalcache serve' requires the fastapi extra.")
48
+ print("Install with: pip install advalcache[fastapi]")
49
+ sys.exit(1)
50
+ except KeyboardInterrupt:
51
+ print("\nShutdown.")
52
+
53
+ else:
54
+ parser.print_help()
55
+
56
+
57
+ if __name__ == "__main__":
58
+ main()