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 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."""