dc-mar-sdk 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.
- dc_mar_sdk/__init__.py +54 -0
- dc_mar_sdk/cache/__init__.py +19 -0
- dc_mar_sdk/cache/backends.py +492 -0
- dc_mar_sdk/cli/__init__.py +1 -0
- dc_mar_sdk/cli/main.py +1019 -0
- dc_mar_sdk/client/__init__.py +6 -0
- dc_mar_sdk/client/cache_policy.py +16 -0
- dc_mar_sdk/client/contracts.py +13 -0
- dc_mar_sdk/client/mar.py +729 -0
- dc_mar_sdk/dashboard/__init__.py +1 -0
- dc_mar_sdk/dashboard/app.py +147 -0
- dc_mar_sdk/dashboard/static/dashboard.css +95 -0
- dc_mar_sdk/dashboard/static/dashboard.js +95 -0
- dc_mar_sdk/dashboard/templates/index.html +72 -0
- dc_mar_sdk/models/__init__.py +77 -0
- dc_mar_sdk/models/core.py +313 -0
- dc_mar_sdk/operations.py +71 -0
- dc_mar_sdk/py.typed +0 -0
- dc_mar_sdk/scripts/__init__.py +1 -0
- dc_mar_sdk/scripts/geocode_quality_collect.py +333 -0
- dc_mar_sdk/scripts/release.py +155 -0
- dc_mar_sdk/settings.py +63 -0
- dc_mar_sdk/telemetry/__init__.py +11 -0
- dc_mar_sdk/telemetry/tracking.py +586 -0
- dc_mar_sdk/tools/__init__.py +0 -0
- dc_mar_sdk/tools/drift_detector.py +566 -0
- dc_mar_sdk/tools/swagger_manager.py +465 -0
- dc_mar_sdk-0.1.0.dist-info/METADATA +348 -0
- dc_mar_sdk-0.1.0.dist-info/RECORD +32 -0
- dc_mar_sdk-0.1.0.dist-info/WHEEL +4 -0
- dc_mar_sdk-0.1.0.dist-info/entry_points.txt +4 -0
- dc_mar_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
dc_mar_sdk/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""DC MAR SDK for DC DataGate Master Address Repository (MAR).
|
|
2
|
+
|
|
3
|
+
Provides caching, telemetry, dashboard, and CLI helpers for data collection.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from importlib.metadata import PackageNotFoundError
|
|
7
|
+
from importlib.metadata import version as _pkg_version
|
|
8
|
+
|
|
9
|
+
__version__ = "0.0.0"
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
__version__ = _pkg_version("dc-mar-sdk")
|
|
13
|
+
except PackageNotFoundError:
|
|
14
|
+
# When running from source (not installed), read pyproject.toml if present.
|
|
15
|
+
try:
|
|
16
|
+
import pathlib
|
|
17
|
+
import tomllib
|
|
18
|
+
|
|
19
|
+
_py = pathlib.Path(__file__).resolve().parents[2] / "pyproject.toml"
|
|
20
|
+
if _py.exists():
|
|
21
|
+
with _py.open("rb") as _f:
|
|
22
|
+
_data = tomllib.load(_f)
|
|
23
|
+
version = _data.get("project", {}).get("version")
|
|
24
|
+
if isinstance(version, str):
|
|
25
|
+
__version__ = version
|
|
26
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
from dc_mar_sdk.cache import CacheManager, InMemoryCache, RedisCache, SQLiteCache
|
|
30
|
+
from dc_mar_sdk.client import DCMarClient, MARClient
|
|
31
|
+
from dc_mar_sdk.models import (
|
|
32
|
+
AddressInput,
|
|
33
|
+
AddressMatch,
|
|
34
|
+
AutocompleteResponse,
|
|
35
|
+
MARResponseEnvelope,
|
|
36
|
+
SearchResponse,
|
|
37
|
+
)
|
|
38
|
+
from dc_mar_sdk.telemetry import RequestMetrics, TelemetryDB
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"AddressInput",
|
|
42
|
+
"AddressMatch",
|
|
43
|
+
"AutocompleteResponse",
|
|
44
|
+
"CacheManager",
|
|
45
|
+
"DCMarClient",
|
|
46
|
+
"InMemoryCache",
|
|
47
|
+
"MARClient",
|
|
48
|
+
"MARResponseEnvelope",
|
|
49
|
+
"RedisCache",
|
|
50
|
+
"RequestMetrics",
|
|
51
|
+
"SQLiteCache",
|
|
52
|
+
"SearchResponse",
|
|
53
|
+
"TelemetryDB",
|
|
54
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Cache package - pluggable backends and manager"""
|
|
2
|
+
|
|
3
|
+
from .backends import (
|
|
4
|
+
CacheBackend,
|
|
5
|
+
CacheManager,
|
|
6
|
+
CacheStats,
|
|
7
|
+
InMemoryCache,
|
|
8
|
+
RedisCache,
|
|
9
|
+
SQLiteCache,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"CacheBackend",
|
|
14
|
+
"CacheManager",
|
|
15
|
+
"CacheStats",
|
|
16
|
+
"InMemoryCache",
|
|
17
|
+
"RedisCache",
|
|
18
|
+
"SQLiteCache",
|
|
19
|
+
]
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cache abstraction layer supporting multiple backends.
|
|
3
|
+
Provides a common interface for SQLite, Redis, and in-memory caching.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import sqlite3
|
|
9
|
+
import time
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, TypedDict
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CacheStats(TypedDict):
|
|
18
|
+
"""Stable, normalized cache statistics returned to SDK consumers."""
|
|
19
|
+
|
|
20
|
+
backend: str
|
|
21
|
+
entries: int | None
|
|
22
|
+
hits: int
|
|
23
|
+
misses: int
|
|
24
|
+
total_requests: int
|
|
25
|
+
hit_rate: float
|
|
26
|
+
size_bytes: int | None
|
|
27
|
+
backend_details: dict[str, Any]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CacheBackend(ABC):
|
|
31
|
+
"""Abstract base class for cache backends"""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
async def get(self, key: str) -> Any | None:
|
|
35
|
+
"""Retrieve a value from cache by key"""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
async def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None:
|
|
39
|
+
"""Store a value in cache with optional TTL"""
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
async def delete(self, key: str) -> None:
|
|
43
|
+
"""Delete a key from cache"""
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
async def clear(self) -> None:
|
|
47
|
+
"""Clear all entries from cache"""
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
async def get_stats(self) -> dict[str, Any]:
|
|
51
|
+
"""Get cache statistics (hits, misses, size, etc.)"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class InMemoryCache(CacheBackend):
|
|
55
|
+
"""Simple in-memory cache backend with TTL support"""
|
|
56
|
+
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
self.store: dict[str, tuple[Any, float | None]] = {}
|
|
59
|
+
self.hits = 0
|
|
60
|
+
self.misses = 0
|
|
61
|
+
|
|
62
|
+
async def get(self, key: str) -> Any | None:
|
|
63
|
+
"""Get value from memory, checking TTL"""
|
|
64
|
+
if key not in self.store:
|
|
65
|
+
self.misses += 1
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
value, expiry = self.store[key]
|
|
69
|
+
if expiry is not None and time.time() > expiry:
|
|
70
|
+
del self.store[key]
|
|
71
|
+
self.misses += 1
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
self.hits += 1
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
async def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None:
|
|
78
|
+
"""Store value in memory with optional TTL"""
|
|
79
|
+
expiry = None
|
|
80
|
+
if ttl_seconds is not None:
|
|
81
|
+
expiry = time.time() + ttl_seconds
|
|
82
|
+
self.store[key] = (value, expiry)
|
|
83
|
+
|
|
84
|
+
async def delete(self, key: str) -> None:
|
|
85
|
+
"""Delete key from memory"""
|
|
86
|
+
self.store.pop(key, None)
|
|
87
|
+
|
|
88
|
+
async def clear(self) -> None:
|
|
89
|
+
"""Clear all entries"""
|
|
90
|
+
self.store.clear()
|
|
91
|
+
|
|
92
|
+
async def get_stats(self) -> dict[str, Any]:
|
|
93
|
+
"""Return cache statistics"""
|
|
94
|
+
total_requests = self.hits + self.misses
|
|
95
|
+
hit_rate = (self.hits / total_requests * 100) if total_requests > 0 else 0
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
"backend": "in_memory",
|
|
99
|
+
"entries": len(self.store),
|
|
100
|
+
"hits": self.hits,
|
|
101
|
+
"misses": self.misses,
|
|
102
|
+
"hit_rate": hit_rate,
|
|
103
|
+
"total_requests": total_requests,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class SQLiteCache(CacheBackend):
|
|
108
|
+
"""SQLite-backed cache backend with TTL support"""
|
|
109
|
+
|
|
110
|
+
def __init__(self, db_path: Path | None = None):
|
|
111
|
+
if db_path is None:
|
|
112
|
+
db_path = Path.home() / ".cache" / "dc_mar_sdk" / "cache.db"
|
|
113
|
+
|
|
114
|
+
self.db_path = Path(db_path)
|
|
115
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
self._init_db()
|
|
117
|
+
|
|
118
|
+
def _init_db(self) -> None:
|
|
119
|
+
"""Initialize database schema"""
|
|
120
|
+
with self._connection() as conn:
|
|
121
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
122
|
+
conn.execute(
|
|
123
|
+
"""
|
|
124
|
+
CREATE TABLE IF NOT EXISTS cache (
|
|
125
|
+
key TEXT PRIMARY KEY,
|
|
126
|
+
value TEXT NOT NULL,
|
|
127
|
+
created_at REAL NOT NULL,
|
|
128
|
+
expires_at REAL,
|
|
129
|
+
hits INTEGER DEFAULT 0,
|
|
130
|
+
misses INTEGER DEFAULT 0
|
|
131
|
+
)
|
|
132
|
+
"""
|
|
133
|
+
)
|
|
134
|
+
conn.execute(
|
|
135
|
+
"""
|
|
136
|
+
CREATE TABLE IF NOT EXISTS cache_statistics (
|
|
137
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
138
|
+
hits INTEGER NOT NULL DEFAULT 0,
|
|
139
|
+
misses INTEGER NOT NULL DEFAULT 0
|
|
140
|
+
)
|
|
141
|
+
"""
|
|
142
|
+
)
|
|
143
|
+
conn.execute("INSERT OR IGNORE INTO cache_statistics (id) VALUES (1)")
|
|
144
|
+
conn.execute(
|
|
145
|
+
"DELETE FROM cache WHERE expires_at IS NOT NULL AND expires_at < ?",
|
|
146
|
+
(time.time(),),
|
|
147
|
+
)
|
|
148
|
+
conn.commit()
|
|
149
|
+
|
|
150
|
+
@contextmanager
|
|
151
|
+
def _connection(self) -> Iterator[sqlite3.Connection]:
|
|
152
|
+
"""Open a short-lived connection that is always closed after each operation."""
|
|
153
|
+
conn = sqlite3.connect(self.db_path, timeout=5)
|
|
154
|
+
try:
|
|
155
|
+
conn.execute("PRAGMA busy_timeout = 5000")
|
|
156
|
+
yield conn
|
|
157
|
+
finally:
|
|
158
|
+
conn.close()
|
|
159
|
+
|
|
160
|
+
async def get(self, key: str) -> Any | None:
|
|
161
|
+
"""Retrieve value from SQLite cache"""
|
|
162
|
+
return await asyncio.to_thread(self._get, key)
|
|
163
|
+
|
|
164
|
+
def _get(self, key: str) -> Any | None:
|
|
165
|
+
"""Retrieve a value from SQLite cache in a worker thread."""
|
|
166
|
+
with self._connection() as conn:
|
|
167
|
+
cursor = conn.cursor()
|
|
168
|
+
|
|
169
|
+
# Check if key exists and not expired
|
|
170
|
+
cursor.execute("SELECT value, expires_at FROM cache WHERE key = ?", (key,))
|
|
171
|
+
row = cursor.fetchone()
|
|
172
|
+
|
|
173
|
+
if row is None:
|
|
174
|
+
cursor.execute("UPDATE cache_statistics SET misses = misses + 1 WHERE id = 1")
|
|
175
|
+
conn.commit()
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
value_json, expires_at = row
|
|
179
|
+
|
|
180
|
+
# Check expiry
|
|
181
|
+
if expires_at is not None and time.time() > expires_at:
|
|
182
|
+
cursor.execute("DELETE FROM cache WHERE key = ?", (key,))
|
|
183
|
+
cursor.execute("UPDATE cache_statistics SET misses = misses + 1 WHERE id = 1")
|
|
184
|
+
conn.commit()
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
# Update hit count
|
|
188
|
+
cursor.execute("UPDATE cache SET hits = hits + 1 WHERE key = ?", (key,))
|
|
189
|
+
cursor.execute("UPDATE cache_statistics SET hits = hits + 1 WHERE id = 1")
|
|
190
|
+
conn.commit()
|
|
191
|
+
|
|
192
|
+
return json.loads(value_json)
|
|
193
|
+
|
|
194
|
+
async def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None:
|
|
195
|
+
"""Store value in SQLite cache"""
|
|
196
|
+
await asyncio.to_thread(self._set, key, value, ttl_seconds)
|
|
197
|
+
|
|
198
|
+
def _set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None:
|
|
199
|
+
"""Store a value in SQLite cache in a worker thread."""
|
|
200
|
+
with self._connection() as conn:
|
|
201
|
+
expires_at = None
|
|
202
|
+
if ttl_seconds is not None:
|
|
203
|
+
expires_at = time.time() + ttl_seconds
|
|
204
|
+
|
|
205
|
+
conn.execute(
|
|
206
|
+
"""
|
|
207
|
+
INSERT OR REPLACE INTO cache (key, value, created_at, expires_at)
|
|
208
|
+
VALUES (?, ?, ?, ?)
|
|
209
|
+
""",
|
|
210
|
+
(key, json.dumps(value), time.time(), expires_at),
|
|
211
|
+
)
|
|
212
|
+
conn.commit()
|
|
213
|
+
|
|
214
|
+
async def delete(self, key: str) -> None:
|
|
215
|
+
"""Delete key from SQLite cache"""
|
|
216
|
+
await asyncio.to_thread(self._delete, key)
|
|
217
|
+
|
|
218
|
+
def _delete(self, key: str) -> None:
|
|
219
|
+
"""Delete a key from SQLite cache in a worker thread."""
|
|
220
|
+
with self._connection() as conn:
|
|
221
|
+
conn.execute("DELETE FROM cache WHERE key = ?", (key,))
|
|
222
|
+
conn.commit()
|
|
223
|
+
|
|
224
|
+
async def clear(self) -> None:
|
|
225
|
+
"""Clear all entries from SQLite cache"""
|
|
226
|
+
await asyncio.to_thread(self._clear)
|
|
227
|
+
|
|
228
|
+
def _clear(self) -> None:
|
|
229
|
+
"""Clear SQLite cache entries in a worker thread."""
|
|
230
|
+
with self._connection() as conn:
|
|
231
|
+
conn.execute("DELETE FROM cache")
|
|
232
|
+
conn.commit()
|
|
233
|
+
|
|
234
|
+
async def get_stats(self) -> dict[str, Any]:
|
|
235
|
+
"""Get cache statistics"""
|
|
236
|
+
return await asyncio.to_thread(self._get_stats)
|
|
237
|
+
|
|
238
|
+
def _get_stats(self) -> dict[str, Any]:
|
|
239
|
+
"""Get SQLite cache statistics in a worker thread."""
|
|
240
|
+
with self._connection() as conn:
|
|
241
|
+
cursor = conn.cursor()
|
|
242
|
+
|
|
243
|
+
cursor.execute("SELECT COUNT(*) FROM cache")
|
|
244
|
+
entry_count = cursor.fetchone()[0]
|
|
245
|
+
|
|
246
|
+
cursor.execute("SELECT hits, misses FROM cache_statistics WHERE id = 1")
|
|
247
|
+
hits, misses = cursor.fetchone()
|
|
248
|
+
|
|
249
|
+
total_requests = hits + misses
|
|
250
|
+
hit_rate = (hits / total_requests * 100) if total_requests > 0 else 0
|
|
251
|
+
|
|
252
|
+
# Get database size
|
|
253
|
+
size_bytes = self.db_path.stat().st_size if self.db_path.exists() else 0
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
"backend": "sqlite",
|
|
257
|
+
"db_path": str(self.db_path),
|
|
258
|
+
"entries": entry_count,
|
|
259
|
+
"hits": hits,
|
|
260
|
+
"misses": misses,
|
|
261
|
+
"hit_rate": hit_rate,
|
|
262
|
+
"total_requests": total_requests,
|
|
263
|
+
"size_bytes": size_bytes,
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
def _invalidate_pattern(self, sql_pattern: str) -> int:
|
|
267
|
+
"""Delete keys matching a SQL LIKE pattern in a worker thread."""
|
|
268
|
+
with self._connection() as conn:
|
|
269
|
+
cursor = conn.cursor()
|
|
270
|
+
cursor.execute("SELECT key FROM cache WHERE key LIKE ? ESCAPE '\\'", (sql_pattern,))
|
|
271
|
+
rows = cursor.fetchall()
|
|
272
|
+
keys = [row[0] for row in rows]
|
|
273
|
+
if not keys:
|
|
274
|
+
return 0
|
|
275
|
+
|
|
276
|
+
cursor.executemany("DELETE FROM cache WHERE key = ?", [(key,) for key in keys])
|
|
277
|
+
conn.commit()
|
|
278
|
+
return len(keys)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class RedisCache(CacheBackend):
|
|
282
|
+
"""Redis cache backend (optional, requires redis package)"""
|
|
283
|
+
|
|
284
|
+
def __init__(self, host: str = "localhost", port: int = 6379, db: int = 0):
|
|
285
|
+
try:
|
|
286
|
+
import redis
|
|
287
|
+
|
|
288
|
+
self.redis_client = redis.Redis(host=host, port=port, db=db, decode_responses=True)
|
|
289
|
+
self.redis_client.ping()
|
|
290
|
+
except ImportError as error:
|
|
291
|
+
raise ImportError("Redis backend requires 'redis' package. Install with: pip install redis") from error
|
|
292
|
+
except Exception as e:
|
|
293
|
+
raise ConnectionError(f"Failed to connect to Redis at {host}:{port}: {e}") from e
|
|
294
|
+
|
|
295
|
+
async def get(self, key: str) -> Any | None:
|
|
296
|
+
"""Retrieve value from Redis"""
|
|
297
|
+
value = await asyncio.to_thread(self.redis_client.get, key)
|
|
298
|
+
if value is None:
|
|
299
|
+
return None
|
|
300
|
+
try:
|
|
301
|
+
return json.loads(value)
|
|
302
|
+
except json.JSONDecodeError:
|
|
303
|
+
return value
|
|
304
|
+
|
|
305
|
+
async def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None:
|
|
306
|
+
"""Store value in Redis"""
|
|
307
|
+
value_json = json.dumps(value)
|
|
308
|
+
if ttl_seconds is not None:
|
|
309
|
+
await asyncio.to_thread(self.redis_client.setex, key, ttl_seconds, value_json)
|
|
310
|
+
else:
|
|
311
|
+
await asyncio.to_thread(self.redis_client.set, key, value_json)
|
|
312
|
+
|
|
313
|
+
async def delete(self, key: str) -> None:
|
|
314
|
+
"""Delete key from Redis"""
|
|
315
|
+
await asyncio.to_thread(self.redis_client.delete, key)
|
|
316
|
+
|
|
317
|
+
async def clear(self) -> None:
|
|
318
|
+
"""Clear all entries from Redis"""
|
|
319
|
+
await asyncio.to_thread(self.redis_client.flushdb)
|
|
320
|
+
|
|
321
|
+
async def get_stats(self) -> dict[str, Any]:
|
|
322
|
+
"""Get Redis statistics"""
|
|
323
|
+
info = await asyncio.to_thread(self.redis_client.info)
|
|
324
|
+
entries = await asyncio.to_thread(self.redis_client.dbsize)
|
|
325
|
+
return {
|
|
326
|
+
"backend": "redis",
|
|
327
|
+
"entries": entries,
|
|
328
|
+
"used_memory": info.get("used_memory_human", "unknown"),
|
|
329
|
+
"connected_clients": info.get("connected_clients", 0),
|
|
330
|
+
"total_commands_processed": info.get("total_commands_processed", 0),
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class CacheManager:
|
|
335
|
+
"""High-level cache manager with invalidation tracking"""
|
|
336
|
+
|
|
337
|
+
def __init__(self, backend: CacheBackend | None = None) -> None:
|
|
338
|
+
"""Initialize cache manager with optional backend (defaults to SQLite)"""
|
|
339
|
+
self.backend = backend or SQLiteCache()
|
|
340
|
+
|
|
341
|
+
async def get(self, key: str) -> Any | None:
|
|
342
|
+
"""Get value from cache"""
|
|
343
|
+
return await self.backend.get(key)
|
|
344
|
+
|
|
345
|
+
async def set(self, key: str, value: Any, ttl_seconds: int = 3600) -> None:
|
|
346
|
+
"""Set value in cache with TTL (default 1 hour)"""
|
|
347
|
+
await self.backend.set(key, value, ttl_seconds)
|
|
348
|
+
|
|
349
|
+
async def delete(self, key: str) -> None:
|
|
350
|
+
"""Delete value from cache"""
|
|
351
|
+
await self.backend.delete(key)
|
|
352
|
+
|
|
353
|
+
async def invalidate_pattern(self, pattern: str) -> int:
|
|
354
|
+
"""
|
|
355
|
+
Invalidate cache entries matching a pattern.
|
|
356
|
+
|
|
357
|
+
Supported pattern syntax (compatible with Redis-style simple globs):
|
|
358
|
+
- '*' matches any number of characters
|
|
359
|
+
- '?' matches a single character
|
|
360
|
+
- exact key (no wildcard) will delete that single key
|
|
361
|
+
|
|
362
|
+
Behavior per backend:
|
|
363
|
+
- Redis: Uses redis.keys(pattern) (unchanged)
|
|
364
|
+
- InMemory: Matches keys using the same simple glob rules and deletes them
|
|
365
|
+
- SQLite: Converts glob to SQL LIKE (with escaping) and deterministically deletes only matching rows
|
|
366
|
+
|
|
367
|
+
Returns the number of keys deleted. Does NOT perform a full-table
|
|
368
|
+
clear unless the pattern explicitly matches every key (for example
|
|
369
|
+
'*').
|
|
370
|
+
"""
|
|
371
|
+
if not pattern:
|
|
372
|
+
raise ValueError("pattern must be a non-empty string")
|
|
373
|
+
|
|
374
|
+
# Redis keeps existing behavior
|
|
375
|
+
if isinstance(self.backend, RedisCache):
|
|
376
|
+
keys = await asyncio.to_thread(self.backend.redis_client.keys, pattern)
|
|
377
|
+
for key in keys:
|
|
378
|
+
await self.backend.delete(key)
|
|
379
|
+
return len(keys)
|
|
380
|
+
|
|
381
|
+
# Helper: compile simple glob pattern (Redis-style) to predicate
|
|
382
|
+
def _glob_matches(pattern: str, key: str) -> bool:
|
|
383
|
+
# Fast path for exact match
|
|
384
|
+
if "*" not in pattern and "?" not in pattern:
|
|
385
|
+
return pattern == key
|
|
386
|
+
|
|
387
|
+
# Translate glob to regex in a safe way without importing re at module level
|
|
388
|
+
# Escape regex metacharacters from pattern except our wildcards
|
|
389
|
+
import re
|
|
390
|
+
|
|
391
|
+
esc = re.escape(pattern)
|
|
392
|
+
# re.escape will backslash '*' and '?', revert those so we can translate
|
|
393
|
+
esc = esc.replace(r"\*", "*").replace(r"\?", "?")
|
|
394
|
+
# now replace glob wildcards with regex
|
|
395
|
+
esc = esc.replace("*", ".*").replace("?", ".")
|
|
396
|
+
# anchor
|
|
397
|
+
regex = f"^{esc}$"
|
|
398
|
+
return re.match(regex, key) is not None
|
|
399
|
+
|
|
400
|
+
# InMemory backend: iterate keys and delete matches
|
|
401
|
+
if isinstance(self.backend, InMemoryCache):
|
|
402
|
+
keys_to_delete = [k for k in list(self.backend.store.keys()) if _glob_matches(pattern, k)]
|
|
403
|
+
for k in keys_to_delete:
|
|
404
|
+
await self.backend.delete(k)
|
|
405
|
+
return len(keys_to_delete)
|
|
406
|
+
|
|
407
|
+
# SQLite backend: convert glob to SQL LIKE safely and delete only matching rows
|
|
408
|
+
if isinstance(self.backend, SQLiteCache):
|
|
409
|
+
# Convert glob pattern to SQL LIKE pattern
|
|
410
|
+
# Escaping: escape existing '%' '_' and backslash in the pattern first
|
|
411
|
+
sql_pattern = pattern.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
412
|
+
# translate wildcards
|
|
413
|
+
sql_pattern = sql_pattern.replace("*", "%").replace("?", "_")
|
|
414
|
+
|
|
415
|
+
return await asyncio.to_thread(self.backend._invalidate_pattern, sql_pattern)
|
|
416
|
+
|
|
417
|
+
# Unknown backend: be conservative
|
|
418
|
+
raise NotImplementedError(f"invalidate_pattern not implemented for backend type {type(self.backend)}")
|
|
419
|
+
|
|
420
|
+
async def clear(self) -> None:
|
|
421
|
+
"""Clear all cache entries"""
|
|
422
|
+
await self.backend.clear()
|
|
423
|
+
|
|
424
|
+
async def get_stats(self) -> CacheStats:
|
|
425
|
+
"""Get cache statistics with a normalized, client-facing shape.
|
|
426
|
+
|
|
427
|
+
Normalized schema (stable for consumers):
|
|
428
|
+
- backend: string identifier of backend (e.g., 'sqlite', 'redis', 'in_memory')
|
|
429
|
+
- entries: int | None
|
|
430
|
+
- hits: int
|
|
431
|
+
- misses: int
|
|
432
|
+
- total_requests: int
|
|
433
|
+
- hit_rate: float (percentage 0-100)
|
|
434
|
+
- size_bytes: int | None
|
|
435
|
+
- backend_details: dict (any additional backend-specific fields)
|
|
436
|
+
|
|
437
|
+
This function calls the backend's get_stats() and normalizes the
|
|
438
|
+
result so consumers (and the MAR client) don't need to access
|
|
439
|
+
backend internals and can rely on a stable shape.
|
|
440
|
+
"""
|
|
441
|
+
raw = await self.backend.get_stats()
|
|
442
|
+
|
|
443
|
+
# Defensive normalization with defaults
|
|
444
|
+
backend = raw.get("backend") if isinstance(raw, dict) else None
|
|
445
|
+
backend = backend or getattr(self.backend, "__class__", type(self.backend)).__name__.lower()
|
|
446
|
+
|
|
447
|
+
def to_int(value: Any) -> int | None:
|
|
448
|
+
try:
|
|
449
|
+
return int(value)
|
|
450
|
+
except Exception:
|
|
451
|
+
return None
|
|
452
|
+
|
|
453
|
+
hits = to_int(raw.get("hits") if isinstance(raw, dict) else None) or 0
|
|
454
|
+
misses = to_int(raw.get("misses") if isinstance(raw, dict) else None) or 0
|
|
455
|
+
total_requests = to_int(raw.get("total_requests") if isinstance(raw, dict) else None)
|
|
456
|
+
if total_requests is None:
|
|
457
|
+
total_requests = hits + misses
|
|
458
|
+
|
|
459
|
+
# hit_rate may be provided by backend, otherwise compute
|
|
460
|
+
hit_rate: float | None = None
|
|
461
|
+
if isinstance(raw, dict) and raw.get("hit_rate") is not None:
|
|
462
|
+
try:
|
|
463
|
+
hit_rate = float(raw["hit_rate"])
|
|
464
|
+
except Exception:
|
|
465
|
+
hit_rate = None
|
|
466
|
+
|
|
467
|
+
if hit_rate is None:
|
|
468
|
+
hit_rate = (hits / total_requests * 100) if total_requests > 0 else 0.0
|
|
469
|
+
|
|
470
|
+
entries = to_int(raw.get("entries") if isinstance(raw, dict) else None)
|
|
471
|
+
size_bytes = to_int(raw.get("size_bytes") if isinstance(raw, dict) else None)
|
|
472
|
+
|
|
473
|
+
# Collect remaining backend-specific details
|
|
474
|
+
backend_details: dict[str, Any] = {}
|
|
475
|
+
if isinstance(raw, dict):
|
|
476
|
+
for k, v in raw.items():
|
|
477
|
+
if k in {"backend", "entries", "hits", "misses", "total_requests", "hit_rate", "size_bytes"}:
|
|
478
|
+
continue
|
|
479
|
+
backend_details[k] = v
|
|
480
|
+
|
|
481
|
+
normalized: CacheStats = {
|
|
482
|
+
"backend": backend,
|
|
483
|
+
"entries": entries,
|
|
484
|
+
"hits": hits,
|
|
485
|
+
"misses": misses,
|
|
486
|
+
"total_requests": total_requests,
|
|
487
|
+
"hit_rate": float(round(hit_rate, 2)) if hit_rate is not None else None,
|
|
488
|
+
"size_bytes": size_bytes,
|
|
489
|
+
"backend_details": backend_details,
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return normalized
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI package for dc_mar_sdk."""
|