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.
Files changed (40) hide show
  1. indic_language_utils/__init__.py +178 -0
  2. indic_language_utils/cache.py +343 -0
  3. indic_language_utils/concurrency.py +30 -0
  4. indic_language_utils/config.py +389 -0
  5. indic_language_utils/detection/__init__.py +52 -0
  6. indic_language_utils/detection/bhashini_detect.py +202 -0
  7. indic_language_utils/detection/cache.py +171 -0
  8. indic_language_utils/detection/client.py +292 -0
  9. indic_language_utils/detection/fasttext.py +198 -0
  10. indic_language_utils/detection/helpers.py +178 -0
  11. indic_language_utils/detection/models.py +99 -0
  12. indic_language_utils/detection/protocols.py +19 -0
  13. indic_language_utils/detection/sync.py +93 -0
  14. indic_language_utils/errors.py +94 -0
  15. indic_language_utils/languages.py +138 -0
  16. indic_language_utils/models.py +75 -0
  17. indic_language_utils/processors.py +21 -0
  18. indic_language_utils/providers/__init__.py +29 -0
  19. indic_language_utils/providers/base.py +119 -0
  20. indic_language_utils/providers/bhashini.py +292 -0
  21. indic_language_utils/py.typed +0 -0
  22. indic_language_utils/retry.py +62 -0
  23. indic_language_utils/routing.py +70 -0
  24. indic_language_utils/telemetry.py +62 -0
  25. indic_language_utils/timing.py +32 -0
  26. indic_language_utils/translation/__init__.py +78 -0
  27. indic_language_utils/translation/bhashini_translate.py +206 -0
  28. indic_language_utils/translation/cache.py +142 -0
  29. indic_language_utils/translation/catalog.py +59 -0
  30. indic_language_utils/translation/client.py +418 -0
  31. indic_language_utils/translation/google_translate.py +419 -0
  32. indic_language_utils/translation/helpers.py +220 -0
  33. indic_language_utils/translation/models.py +95 -0
  34. indic_language_utils/translation/processing.py +290 -0
  35. indic_language_utils/translation/protocols.py +22 -0
  36. indic_language_utils/translation/sync.py +119 -0
  37. indic_language_utils-0.1.0.dist-info/METADATA +88 -0
  38. indic_language_utils-0.1.0.dist-info/RECORD +40 -0
  39. indic_language_utils-0.1.0.dist-info/WHEEL +4 -0
  40. 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