indic-language-utils 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.
- indic_language_utils/__init__.py +178 -0
- indic_language_utils/cache.py +343 -0
- indic_language_utils/concurrency.py +30 -0
- indic_language_utils/config.py +389 -0
- indic_language_utils/detection/__init__.py +52 -0
- indic_language_utils/detection/bhashini_detect.py +202 -0
- indic_language_utils/detection/cache.py +171 -0
- indic_language_utils/detection/client.py +292 -0
- indic_language_utils/detection/fasttext.py +198 -0
- indic_language_utils/detection/helpers.py +178 -0
- indic_language_utils/detection/models.py +99 -0
- indic_language_utils/detection/protocols.py +19 -0
- indic_language_utils/detection/sync.py +93 -0
- indic_language_utils/errors.py +94 -0
- indic_language_utils/languages.py +138 -0
- indic_language_utils/models.py +75 -0
- indic_language_utils/processors.py +21 -0
- indic_language_utils/providers/__init__.py +29 -0
- indic_language_utils/providers/base.py +119 -0
- indic_language_utils/providers/bhashini.py +292 -0
- indic_language_utils/py.typed +0 -0
- indic_language_utils/retry.py +62 -0
- indic_language_utils/routing.py +70 -0
- indic_language_utils/telemetry.py +62 -0
- indic_language_utils/timing.py +32 -0
- indic_language_utils/translation/__init__.py +78 -0
- indic_language_utils/translation/bhashini_translate.py +206 -0
- indic_language_utils/translation/cache.py +142 -0
- indic_language_utils/translation/catalog.py +59 -0
- indic_language_utils/translation/client.py +418 -0
- indic_language_utils/translation/google_translate.py +419 -0
- indic_language_utils/translation/helpers.py +220 -0
- indic_language_utils/translation/models.py +95 -0
- indic_language_utils/translation/processing.py +290 -0
- indic_language_utils/translation/protocols.py +22 -0
- indic_language_utils/translation/sync.py +119 -0
- indic_language_utils-0.1.0.dist-info/METADATA +88 -0
- indic_language_utils-0.1.0.dist-info/RECORD +40 -0
- indic_language_utils-0.1.0.dist-info/WHEEL +4 -0
- indic_language_utils-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Shared foundations for provider-neutral Indian language operations."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("indic-language-utils")
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover - source tree without installation
|
|
8
|
+
__version__ = "0.0.0"
|
|
9
|
+
|
|
10
|
+
from .cache import CacheKeyBuilder, MemoryCache, NullCache, SingleFlight, SQLiteCache
|
|
11
|
+
from .config import (
|
|
12
|
+
CacheSettings,
|
|
13
|
+
ProviderSettings,
|
|
14
|
+
RetrySettings,
|
|
15
|
+
Secret,
|
|
16
|
+
Settings,
|
|
17
|
+
TelemetrySettings,
|
|
18
|
+
)
|
|
19
|
+
from .detection import (
|
|
20
|
+
BhashiniDetectionProvider,
|
|
21
|
+
DetectionClient,
|
|
22
|
+
DetectionOptions,
|
|
23
|
+
DetectionProvider,
|
|
24
|
+
DetectionProviderMetadata,
|
|
25
|
+
DetectionRequest,
|
|
26
|
+
DetectionResult,
|
|
27
|
+
DetectionResultCodec,
|
|
28
|
+
FastTextDetectionConfig,
|
|
29
|
+
FastTextDetectionProvider,
|
|
30
|
+
LanguageCandidate,
|
|
31
|
+
ProviderDetectionResult,
|
|
32
|
+
SyncDetectionClient,
|
|
33
|
+
create_detection_cache,
|
|
34
|
+
detect,
|
|
35
|
+
detect_batch,
|
|
36
|
+
detect_batch_sync,
|
|
37
|
+
detect_script,
|
|
38
|
+
detect_sync,
|
|
39
|
+
get_detection_client,
|
|
40
|
+
get_sync_detection_client,
|
|
41
|
+
)
|
|
42
|
+
from .errors import LanguageUtilsError
|
|
43
|
+
from .languages import DEFAULT_LANGUAGE_REGISTRY, LanguageRegistry, LanguageTag
|
|
44
|
+
from .models import (
|
|
45
|
+
CacheMetadata,
|
|
46
|
+
ExecutionTiming,
|
|
47
|
+
ModelIdentity,
|
|
48
|
+
OperationContext,
|
|
49
|
+
ProviderIdentity,
|
|
50
|
+
WarningInfo,
|
|
51
|
+
)
|
|
52
|
+
from .processors import ProcessorIdentity, VersionedProcessor
|
|
53
|
+
from .providers import (
|
|
54
|
+
BhashiniConfig,
|
|
55
|
+
CapabilityDeclaration,
|
|
56
|
+
CapabilityId,
|
|
57
|
+
ProviderRegistry,
|
|
58
|
+
)
|
|
59
|
+
from .routing import OrderedRouter, RouteCandidate, RouteRequirement
|
|
60
|
+
from .translation import (
|
|
61
|
+
DEFAULT_TRANSLATION_PROCESSORS,
|
|
62
|
+
BhashiniTranslationProvider,
|
|
63
|
+
CatalogEntry,
|
|
64
|
+
CatalogStatus,
|
|
65
|
+
DefaultTranslationStructureProcessor,
|
|
66
|
+
GoogletransConfig,
|
|
67
|
+
GoogleTranslateConfig,
|
|
68
|
+
GoogleTranslateProvider,
|
|
69
|
+
GoogletransTranslationProvider,
|
|
70
|
+
LocalizationCatalog,
|
|
71
|
+
PreparedText,
|
|
72
|
+
ProtectedContentProcessor,
|
|
73
|
+
Segment,
|
|
74
|
+
SyncTranslationClient,
|
|
75
|
+
TextFormat,
|
|
76
|
+
TranslationClient,
|
|
77
|
+
TranslationOptions,
|
|
78
|
+
TranslationProcessorPipeline,
|
|
79
|
+
TranslationRequest,
|
|
80
|
+
TranslationResult,
|
|
81
|
+
TranslationResultCodec,
|
|
82
|
+
TranslationSegmentProcessor,
|
|
83
|
+
TranslationStructureProcessor,
|
|
84
|
+
UnicodeNormalizationProcessor,
|
|
85
|
+
create_translation_cache,
|
|
86
|
+
get_sync_translation_client,
|
|
87
|
+
get_translation_client,
|
|
88
|
+
translate,
|
|
89
|
+
translate_batch,
|
|
90
|
+
translate_batch_sync,
|
|
91
|
+
translate_sync,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
__all__ = [
|
|
95
|
+
"DEFAULT_LANGUAGE_REGISTRY",
|
|
96
|
+
"DEFAULT_TRANSLATION_PROCESSORS",
|
|
97
|
+
"BhashiniConfig",
|
|
98
|
+
"BhashiniDetectionProvider",
|
|
99
|
+
"BhashiniTranslationProvider",
|
|
100
|
+
"CacheKeyBuilder",
|
|
101
|
+
"CacheMetadata",
|
|
102
|
+
"CacheSettings",
|
|
103
|
+
"CapabilityDeclaration",
|
|
104
|
+
"CapabilityId",
|
|
105
|
+
"CatalogEntry",
|
|
106
|
+
"CatalogStatus",
|
|
107
|
+
"DefaultTranslationStructureProcessor",
|
|
108
|
+
"DetectionClient",
|
|
109
|
+
"DetectionOptions",
|
|
110
|
+
"DetectionProvider",
|
|
111
|
+
"DetectionProviderMetadata",
|
|
112
|
+
"DetectionRequest",
|
|
113
|
+
"DetectionResult",
|
|
114
|
+
"DetectionResultCodec",
|
|
115
|
+
"ExecutionTiming",
|
|
116
|
+
"FastTextDetectionConfig",
|
|
117
|
+
"FastTextDetectionProvider",
|
|
118
|
+
"GoogleTranslateConfig",
|
|
119
|
+
"GoogleTranslateProvider",
|
|
120
|
+
"GoogletransConfig",
|
|
121
|
+
"GoogletransTranslationProvider",
|
|
122
|
+
"LanguageCandidate",
|
|
123
|
+
"LanguageRegistry",
|
|
124
|
+
"LanguageTag",
|
|
125
|
+
"LanguageUtilsError",
|
|
126
|
+
"LocalizationCatalog",
|
|
127
|
+
"MemoryCache",
|
|
128
|
+
"ModelIdentity",
|
|
129
|
+
"NullCache",
|
|
130
|
+
"OperationContext",
|
|
131
|
+
"OrderedRouter",
|
|
132
|
+
"PreparedText",
|
|
133
|
+
"ProcessorIdentity",
|
|
134
|
+
"ProtectedContentProcessor",
|
|
135
|
+
"ProviderDetectionResult",
|
|
136
|
+
"ProviderIdentity",
|
|
137
|
+
"ProviderRegistry",
|
|
138
|
+
"ProviderSettings",
|
|
139
|
+
"RetrySettings",
|
|
140
|
+
"RouteCandidate",
|
|
141
|
+
"RouteRequirement",
|
|
142
|
+
"SQLiteCache",
|
|
143
|
+
"Secret",
|
|
144
|
+
"Segment",
|
|
145
|
+
"Settings",
|
|
146
|
+
"SingleFlight",
|
|
147
|
+
"SyncDetectionClient",
|
|
148
|
+
"SyncTranslationClient",
|
|
149
|
+
"TelemetrySettings",
|
|
150
|
+
"TextFormat",
|
|
151
|
+
"TranslationClient",
|
|
152
|
+
"TranslationOptions",
|
|
153
|
+
"TranslationProcessorPipeline",
|
|
154
|
+
"TranslationRequest",
|
|
155
|
+
"TranslationResult",
|
|
156
|
+
"TranslationResultCodec",
|
|
157
|
+
"TranslationSegmentProcessor",
|
|
158
|
+
"TranslationStructureProcessor",
|
|
159
|
+
"UnicodeNormalizationProcessor",
|
|
160
|
+
"VersionedProcessor",
|
|
161
|
+
"WarningInfo",
|
|
162
|
+
"__version__",
|
|
163
|
+
"create_detection_cache",
|
|
164
|
+
"create_translation_cache",
|
|
165
|
+
"detect",
|
|
166
|
+
"detect_batch",
|
|
167
|
+
"detect_batch_sync",
|
|
168
|
+
"detect_script",
|
|
169
|
+
"detect_sync",
|
|
170
|
+
"get_detection_client",
|
|
171
|
+
"get_sync_detection_client",
|
|
172
|
+
"get_sync_translation_client",
|
|
173
|
+
"get_translation_client",
|
|
174
|
+
"translate",
|
|
175
|
+
"translate_batch",
|
|
176
|
+
"translate_batch_sync",
|
|
177
|
+
"translate_sync",
|
|
178
|
+
]
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""Async cache contracts, local implementations, keys, and request coalescing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sqlite3
|
|
10
|
+
import time
|
|
11
|
+
from collections import OrderedDict
|
|
12
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Generic, Protocol, TypeVar, runtime_checkable
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
Clock = Callable[[], float]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class CacheCodec(Protocol[T]):
|
|
23
|
+
"""Encode cache values without tying the cache to pickle or a model library."""
|
|
24
|
+
|
|
25
|
+
def encode(self, value: T) -> bytes: ...
|
|
26
|
+
|
|
27
|
+
def decode(self, value: bytes) -> T: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AsyncCache(Protocol[T]):
|
|
31
|
+
async def get(self, key: str) -> T | None: ...
|
|
32
|
+
|
|
33
|
+
async def set(self, key: str, value: T, *, ttl: float | None = None) -> None: ...
|
|
34
|
+
|
|
35
|
+
async def delete(self, key: str) -> bool: ...
|
|
36
|
+
|
|
37
|
+
async def clear(self) -> None: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NullCache(Generic[T]):
|
|
41
|
+
async def get(self, key: str) -> T | None:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
async def set(self, key: str, value: T, *, ttl: float | None = None) -> None:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
async def delete(self, key: str) -> bool:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
async def clear(self) -> None:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(slots=True)
|
|
55
|
+
class _Entry(Generic[T]):
|
|
56
|
+
value: T
|
|
57
|
+
expires_at: float | None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class MemoryCache(Generic[T]):
|
|
61
|
+
"""A process-local, bounded TTL cache with least-recently-used eviction."""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
max_entries: int = 1024,
|
|
66
|
+
default_ttl: float | None = 300,
|
|
67
|
+
*,
|
|
68
|
+
clock: Clock = time.monotonic,
|
|
69
|
+
) -> None:
|
|
70
|
+
if max_entries < 1 or (default_ttl is not None and default_ttl < 0):
|
|
71
|
+
raise ValueError("Cache size must be positive and TTL cannot be negative")
|
|
72
|
+
self._max_entries = max_entries
|
|
73
|
+
self._default_ttl = default_ttl
|
|
74
|
+
self._clock = clock
|
|
75
|
+
self._entries: OrderedDict[str, _Entry[T]] = OrderedDict()
|
|
76
|
+
self._lock = asyncio.Lock()
|
|
77
|
+
|
|
78
|
+
async def get(self, key: str) -> T | None:
|
|
79
|
+
async with self._lock:
|
|
80
|
+
entry = self._entries.get(key)
|
|
81
|
+
if entry is None:
|
|
82
|
+
return None
|
|
83
|
+
if entry.expires_at is not None and entry.expires_at <= self._clock():
|
|
84
|
+
del self._entries[key]
|
|
85
|
+
return None
|
|
86
|
+
self._entries.move_to_end(key)
|
|
87
|
+
return entry.value
|
|
88
|
+
|
|
89
|
+
async def set(self, key: str, value: T, *, ttl: float | None = None) -> None:
|
|
90
|
+
effective_ttl = self._default_ttl if ttl is None else ttl
|
|
91
|
+
if effective_ttl is not None and effective_ttl < 0:
|
|
92
|
+
raise ValueError("TTL cannot be negative")
|
|
93
|
+
expires_at = None if effective_ttl is None else self._clock() + effective_ttl
|
|
94
|
+
async with self._lock:
|
|
95
|
+
self._entries[key] = _Entry(value, expires_at)
|
|
96
|
+
self._entries.move_to_end(key)
|
|
97
|
+
while len(self._entries) > self._max_entries:
|
|
98
|
+
self._entries.popitem(last=False)
|
|
99
|
+
|
|
100
|
+
async def delete(self, key: str) -> bool:
|
|
101
|
+
async with self._lock:
|
|
102
|
+
return self._entries.pop(key, None) is not None
|
|
103
|
+
|
|
104
|
+
async def clear(self) -> None:
|
|
105
|
+
async with self._lock:
|
|
106
|
+
self._entries.clear()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class SQLiteCache(Generic[T]):
|
|
110
|
+
"""A persistent, bounded TTL/LRU cache backed by SQLite."""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
path: str | Path,
|
|
115
|
+
codec: CacheCodec[T],
|
|
116
|
+
*,
|
|
117
|
+
namespace: str = "indic-language-utils",
|
|
118
|
+
max_entries: int = 10_000,
|
|
119
|
+
default_ttl: float | None = 86_400,
|
|
120
|
+
timeout_seconds: float = 5.0,
|
|
121
|
+
clock: Clock = time.time,
|
|
122
|
+
) -> None:
|
|
123
|
+
self._path = Path(path).expanduser().resolve()
|
|
124
|
+
if not namespace:
|
|
125
|
+
raise ValueError("Cache namespace cannot be empty")
|
|
126
|
+
if max_entries < 1 or (default_ttl is not None and default_ttl < 0):
|
|
127
|
+
raise ValueError("Cache size must be positive and TTL cannot be negative")
|
|
128
|
+
if timeout_seconds <= 0:
|
|
129
|
+
raise ValueError("SQLite timeout must be positive")
|
|
130
|
+
self._codec = codec
|
|
131
|
+
self._namespace = namespace
|
|
132
|
+
self._max_entries = max_entries
|
|
133
|
+
self._default_ttl = default_ttl
|
|
134
|
+
self._timeout_seconds = timeout_seconds
|
|
135
|
+
self._clock = clock
|
|
136
|
+
self._initialized = False
|
|
137
|
+
self._initialization_lock = asyncio.Lock()
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def path(self) -> Path:
|
|
141
|
+
return self._path
|
|
142
|
+
|
|
143
|
+
async def get(self, key: str) -> T | None:
|
|
144
|
+
await self._ensure_initialized()
|
|
145
|
+
encoded = await asyncio.to_thread(self._get_sync, key)
|
|
146
|
+
if encoded is None:
|
|
147
|
+
return None
|
|
148
|
+
try:
|
|
149
|
+
return self._codec.decode(encoded)
|
|
150
|
+
except (KeyError, TypeError, ValueError, UnicodeError, json.JSONDecodeError):
|
|
151
|
+
await self.delete(key)
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
async def set(self, key: str, value: T, *, ttl: float | None = None) -> None:
|
|
155
|
+
effective_ttl = self._default_ttl if ttl is None else ttl
|
|
156
|
+
if effective_ttl is not None and effective_ttl < 0:
|
|
157
|
+
raise ValueError("TTL cannot be negative")
|
|
158
|
+
encoded = self._codec.encode(value)
|
|
159
|
+
await self._ensure_initialized()
|
|
160
|
+
await asyncio.to_thread(self._set_sync, key, encoded, effective_ttl)
|
|
161
|
+
|
|
162
|
+
async def delete(self, key: str) -> bool:
|
|
163
|
+
await self._ensure_initialized()
|
|
164
|
+
return await asyncio.to_thread(self._delete_sync, key)
|
|
165
|
+
|
|
166
|
+
async def clear(self) -> None:
|
|
167
|
+
await self._ensure_initialized()
|
|
168
|
+
await asyncio.to_thread(self._clear_sync)
|
|
169
|
+
|
|
170
|
+
async def _ensure_initialized(self) -> None:
|
|
171
|
+
if self._initialized:
|
|
172
|
+
return
|
|
173
|
+
async with self._initialization_lock:
|
|
174
|
+
if self._initialized:
|
|
175
|
+
return
|
|
176
|
+
await asyncio.to_thread(self._initialize_sync)
|
|
177
|
+
self._initialized = True
|
|
178
|
+
|
|
179
|
+
def _connect(self) -> sqlite3.Connection:
|
|
180
|
+
connection = sqlite3.connect(self._path, timeout=self._timeout_seconds)
|
|
181
|
+
connection.execute(f"PRAGMA busy_timeout = {int(self._timeout_seconds * 1_000)}")
|
|
182
|
+
return connection
|
|
183
|
+
|
|
184
|
+
def _initialize_sync(self) -> None:
|
|
185
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
is_new = not self._path.exists()
|
|
187
|
+
with self._connect() as connection:
|
|
188
|
+
connection.execute("PRAGMA journal_mode = WAL")
|
|
189
|
+
connection.execute("PRAGMA synchronous = NORMAL")
|
|
190
|
+
connection.execute(
|
|
191
|
+
"""
|
|
192
|
+
CREATE TABLE IF NOT EXISTS cache_entries (
|
|
193
|
+
namespace TEXT NOT NULL,
|
|
194
|
+
key TEXT NOT NULL,
|
|
195
|
+
value BLOB NOT NULL,
|
|
196
|
+
expires_at REAL,
|
|
197
|
+
accessed_at REAL NOT NULL,
|
|
198
|
+
PRIMARY KEY (namespace, key)
|
|
199
|
+
)
|
|
200
|
+
"""
|
|
201
|
+
)
|
|
202
|
+
connection.execute(
|
|
203
|
+
"""
|
|
204
|
+
CREATE INDEX IF NOT EXISTS cache_entries_expiry
|
|
205
|
+
ON cache_entries (namespace, expires_at)
|
|
206
|
+
"""
|
|
207
|
+
)
|
|
208
|
+
if is_new:
|
|
209
|
+
os.chmod(self._path, 0o600)
|
|
210
|
+
|
|
211
|
+
def _get_sync(self, key: str) -> bytes | None:
|
|
212
|
+
now = self._clock()
|
|
213
|
+
with self._connect() as connection:
|
|
214
|
+
row = connection.execute(
|
|
215
|
+
"""
|
|
216
|
+
SELECT value, expires_at FROM cache_entries
|
|
217
|
+
WHERE namespace = ? AND key = ?
|
|
218
|
+
""",
|
|
219
|
+
(self._namespace, key),
|
|
220
|
+
).fetchone()
|
|
221
|
+
if row is None:
|
|
222
|
+
return None
|
|
223
|
+
value, expires_at = row
|
|
224
|
+
if expires_at is not None and expires_at <= now:
|
|
225
|
+
connection.execute(
|
|
226
|
+
"DELETE FROM cache_entries WHERE namespace = ? AND key = ?",
|
|
227
|
+
(self._namespace, key),
|
|
228
|
+
)
|
|
229
|
+
return None
|
|
230
|
+
connection.execute(
|
|
231
|
+
"""
|
|
232
|
+
UPDATE cache_entries SET accessed_at = ?
|
|
233
|
+
WHERE namespace = ? AND key = ?
|
|
234
|
+
""",
|
|
235
|
+
(now, self._namespace, key),
|
|
236
|
+
)
|
|
237
|
+
return bytes(value)
|
|
238
|
+
|
|
239
|
+
def _set_sync(self, key: str, value: bytes, ttl: float | None) -> None:
|
|
240
|
+
now = self._clock()
|
|
241
|
+
expires_at = None if ttl is None else now + ttl
|
|
242
|
+
with self._connect() as connection:
|
|
243
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
244
|
+
connection.execute(
|
|
245
|
+
"DELETE FROM cache_entries WHERE namespace = ? AND expires_at <= ?",
|
|
246
|
+
(self._namespace, now),
|
|
247
|
+
)
|
|
248
|
+
connection.execute(
|
|
249
|
+
"""
|
|
250
|
+
INSERT INTO cache_entries (namespace, key, value, expires_at, accessed_at)
|
|
251
|
+
VALUES (?, ?, ?, ?, ?)
|
|
252
|
+
ON CONFLICT(namespace, key) DO UPDATE SET
|
|
253
|
+
value = excluded.value,
|
|
254
|
+
expires_at = excluded.expires_at,
|
|
255
|
+
accessed_at = excluded.accessed_at
|
|
256
|
+
""",
|
|
257
|
+
(self._namespace, key, value, expires_at, now),
|
|
258
|
+
)
|
|
259
|
+
connection.execute(
|
|
260
|
+
"""
|
|
261
|
+
DELETE FROM cache_entries
|
|
262
|
+
WHERE namespace = ? AND key IN (
|
|
263
|
+
SELECT key FROM cache_entries
|
|
264
|
+
WHERE namespace = ?
|
|
265
|
+
ORDER BY accessed_at DESC, key DESC
|
|
266
|
+
LIMIT -1 OFFSET ?
|
|
267
|
+
)
|
|
268
|
+
""",
|
|
269
|
+
(self._namespace, self._namespace, self._max_entries),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def _delete_sync(self, key: str) -> bool:
|
|
273
|
+
with self._connect() as connection:
|
|
274
|
+
cursor = connection.execute(
|
|
275
|
+
"DELETE FROM cache_entries WHERE namespace = ? AND key = ?",
|
|
276
|
+
(self._namespace, key),
|
|
277
|
+
)
|
|
278
|
+
return cursor.rowcount > 0
|
|
279
|
+
|
|
280
|
+
def _clear_sync(self) -> None:
|
|
281
|
+
with self._connect() as connection:
|
|
282
|
+
connection.execute("DELETE FROM cache_entries WHERE namespace = ?", (self._namespace,))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@dataclass(frozen=True, slots=True)
|
|
286
|
+
class CacheKeyBuilder:
|
|
287
|
+
namespace: str
|
|
288
|
+
tenant: str | None = None
|
|
289
|
+
version: int = 1
|
|
290
|
+
|
|
291
|
+
def build(self, capability: str, material: Mapping[str, object]) -> str:
|
|
292
|
+
if not self.namespace or self.version < 1:
|
|
293
|
+
raise ValueError("Cache namespace cannot be empty and version must be positive")
|
|
294
|
+
envelope = {
|
|
295
|
+
"version": self.version,
|
|
296
|
+
"capability": capability,
|
|
297
|
+
"material": material,
|
|
298
|
+
}
|
|
299
|
+
encoded = json.dumps(
|
|
300
|
+
envelope, sort_keys=True, separators=(",", ":"), ensure_ascii=True
|
|
301
|
+
).encode()
|
|
302
|
+
digest = hashlib.sha256(encoded).hexdigest()
|
|
303
|
+
boundary = (
|
|
304
|
+
hashlib.sha256(self.tenant.encode()).hexdigest()[:16] if self.tenant else "shared"
|
|
305
|
+
)
|
|
306
|
+
return f"{self.namespace}:{boundary}:v{self.version}:{capability}:{digest}"
|
|
307
|
+
|
|
308
|
+
@staticmethod
|
|
309
|
+
def hash_content(content: str | bytes) -> str:
|
|
310
|
+
raw = content.encode() if isinstance(content, str) else content
|
|
311
|
+
return hashlib.sha256(raw).hexdigest()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class SingleFlight(Generic[T]):
|
|
315
|
+
"""Coalesce concurrent calls by key without letting a waiter cancel shared work."""
|
|
316
|
+
|
|
317
|
+
def __init__(self) -> None:
|
|
318
|
+
self._tasks: dict[str, asyncio.Task[T]] = {}
|
|
319
|
+
self._lock = asyncio.Lock()
|
|
320
|
+
|
|
321
|
+
async def run(self, key: str, operation: Callable[[], Awaitable[T]]) -> T:
|
|
322
|
+
async with self._lock:
|
|
323
|
+
task = self._tasks.get(key)
|
|
324
|
+
if task is None:
|
|
325
|
+
task = asyncio.create_task(self._invoke(operation))
|
|
326
|
+
self._tasks[key] = task
|
|
327
|
+
task.add_done_callback(self._callback(key))
|
|
328
|
+
return await asyncio.shield(task)
|
|
329
|
+
|
|
330
|
+
async def _invoke(self, operation: Callable[[], Awaitable[T]]) -> T:
|
|
331
|
+
return await operation()
|
|
332
|
+
|
|
333
|
+
def _callback(self, key: str) -> Callable[[asyncio.Task[T]], None]:
|
|
334
|
+
def done(task: asyncio.Task[T]) -> None:
|
|
335
|
+
self._remove(key, task)
|
|
336
|
+
|
|
337
|
+
return done
|
|
338
|
+
|
|
339
|
+
def _remove(self, key: str, task: asyncio.Task[T]) -> None:
|
|
340
|
+
if self._tasks.get(key) is task:
|
|
341
|
+
del self._tasks[key]
|
|
342
|
+
if not task.cancelled():
|
|
343
|
+
task.exception()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Reusable concurrency limits keyed by provider and capability."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
|
|
9
|
+
from .providers import CapabilityId
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConcurrencyLimiter:
|
|
13
|
+
def __init__(
|
|
14
|
+
self, default_limit: int, limits: dict[tuple[str, CapabilityId], int] | None = None
|
|
15
|
+
) -> None:
|
|
16
|
+
if default_limit < 1 or any(value < 1 for value in (limits or {}).values()):
|
|
17
|
+
raise ValueError("Concurrency limits must be positive")
|
|
18
|
+
self._default = default_limit
|
|
19
|
+
self._limits = limits or {}
|
|
20
|
+
self._semaphores: dict[tuple[str, CapabilityId], asyncio.Semaphore] = {}
|
|
21
|
+
|
|
22
|
+
@asynccontextmanager
|
|
23
|
+
async def slot(self, provider: str, capability: CapabilityId) -> AsyncIterator[None]:
|
|
24
|
+
key = (provider, capability)
|
|
25
|
+
semaphore = self._semaphores.get(key)
|
|
26
|
+
if semaphore is None:
|
|
27
|
+
semaphore = asyncio.Semaphore(self._limits.get(key, self._default))
|
|
28
|
+
self._semaphores[key] = semaphore
|
|
29
|
+
async with semaphore:
|
|
30
|
+
yield
|