atomicmemory 1.0.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.
- atomicmemory/__init__.py +166 -0
- atomicmemory/_version.py +3 -0
- atomicmemory/client/__init__.py +22 -0
- atomicmemory/client/async_memory_client.py +202 -0
- atomicmemory/client/atomic_memory_client.py +181 -0
- atomicmemory/client/memory_client.py +292 -0
- atomicmemory/core/__init__.py +34 -0
- atomicmemory/core/errors.py +122 -0
- atomicmemory/core/events.py +65 -0
- atomicmemory/core/logging.py +37 -0
- atomicmemory/core/retry.py +124 -0
- atomicmemory/core/validation.py +22 -0
- atomicmemory/embeddings/__init__.py +16 -0
- atomicmemory/embeddings/base.py +39 -0
- atomicmemory/embeddings/sentence_transformers.py +104 -0
- atomicmemory/kv_cache/__init__.py +17 -0
- atomicmemory/kv_cache/adapter.py +50 -0
- atomicmemory/kv_cache/memory_storage.py +98 -0
- atomicmemory/kv_cache/sqlite_storage.py +122 -0
- atomicmemory/memory/__init__.py +82 -0
- atomicmemory/memory/filters.py +68 -0
- atomicmemory/memory/pipeline.py +42 -0
- atomicmemory/memory/provider.py +397 -0
- atomicmemory/memory/registry.py +95 -0
- atomicmemory/memory/service.py +199 -0
- atomicmemory/memory/types.py +398 -0
- atomicmemory/providers/__init__.py +5 -0
- atomicmemory/providers/atomicmemory/__init__.py +43 -0
- atomicmemory/providers/atomicmemory/agents.py +156 -0
- atomicmemory/providers/atomicmemory/async_handle_impl.py +198 -0
- atomicmemory/providers/atomicmemory/async_provider.py +245 -0
- atomicmemory/providers/atomicmemory/audit.py +74 -0
- atomicmemory/providers/atomicmemory/config.py +38 -0
- atomicmemory/providers/atomicmemory/config_handle.py +123 -0
- atomicmemory/providers/atomicmemory/handle.py +513 -0
- atomicmemory/providers/atomicmemory/handle_impl.py +325 -0
- atomicmemory/providers/atomicmemory/http.py +255 -0
- atomicmemory/providers/atomicmemory/lessons.py +133 -0
- atomicmemory/providers/atomicmemory/lifecycle.py +202 -0
- atomicmemory/providers/atomicmemory/mappers.py +125 -0
- atomicmemory/providers/atomicmemory/path.py +20 -0
- atomicmemory/providers/atomicmemory/provider.py +300 -0
- atomicmemory/providers/atomicmemory/scope_mapper.py +98 -0
- atomicmemory/providers/mem0/__init__.py +41 -0
- atomicmemory/providers/mem0/async_provider.py +191 -0
- atomicmemory/providers/mem0/config.py +51 -0
- atomicmemory/providers/mem0/http.py +195 -0
- atomicmemory/providers/mem0/mappers.py +145 -0
- atomicmemory/providers/mem0/provider.py +202 -0
- atomicmemory/py.typed +0 -0
- atomicmemory/search/__init__.py +47 -0
- atomicmemory/search/chunking.py +161 -0
- atomicmemory/search/ranking.py +94 -0
- atomicmemory/search/semantic_search.py +130 -0
- atomicmemory/search/similarity.py +110 -0
- atomicmemory/storage/__init__.py +63 -0
- atomicmemory/storage/_mapping.py +305 -0
- atomicmemory/storage/async_client.py +208 -0
- atomicmemory/storage/client.py +339 -0
- atomicmemory/storage/errors.py +115 -0
- atomicmemory/storage/types.py +305 -0
- atomicmemory/utils/__init__.py +5 -0
- atomicmemory/utils/environment.py +23 -0
- atomicmemory-1.0.0.dist-info/METADATA +146 -0
- atomicmemory-1.0.0.dist-info/RECORD +67 -0
- atomicmemory-1.0.0.dist-info/WHEEL +4 -0
- atomicmemory-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""MemoryClient — sync facade for the V3 memory layer.
|
|
2
|
+
|
|
3
|
+
Port of `atomicmemory-sdk/src/client/memory-client.ts`. Wraps a
|
|
4
|
+
:class:`atomicmemory.memory.service.MemoryService` and the configured
|
|
5
|
+
providers, providing the public API users construct in application
|
|
6
|
+
code. Async users get the same surface via
|
|
7
|
+
``atomicmemory.AsyncMemoryClient`` (Phase 4).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from pydantic import TypeAdapter
|
|
17
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
18
|
+
|
|
19
|
+
# Importing the provider packages registers their factories.
|
|
20
|
+
import atomicmemory.providers.atomicmemory
|
|
21
|
+
import atomicmemory.providers.mem0 # noqa: F401
|
|
22
|
+
from atomicmemory.core.errors import ConfigError, NotInitializedError, ValidationError
|
|
23
|
+
from atomicmemory.core.validation import sanitized_pydantic_errors
|
|
24
|
+
from atomicmemory.memory.provider import BaseMemoryProvider
|
|
25
|
+
from atomicmemory.memory.registry import ProviderRegistry, default_registry
|
|
26
|
+
from atomicmemory.memory.service import MemoryService, MemoryServiceConfig
|
|
27
|
+
from atomicmemory.memory.types import (
|
|
28
|
+
Capabilities,
|
|
29
|
+
ContextPackage,
|
|
30
|
+
IngestInput,
|
|
31
|
+
IngestResult,
|
|
32
|
+
ListRequest,
|
|
33
|
+
ListResultPage,
|
|
34
|
+
Memory,
|
|
35
|
+
MemoryRef,
|
|
36
|
+
PackageRequest,
|
|
37
|
+
SearchRequest,
|
|
38
|
+
SearchResultPage,
|
|
39
|
+
)
|
|
40
|
+
from atomicmemory.providers.atomicmemory.handle_impl import AtomicMemoryHandle
|
|
41
|
+
|
|
42
|
+
# IngestInput is a discriminated union; TypeAdapter is the only way to
|
|
43
|
+
# validate from a plain dict since BaseModel.model_validate would not
|
|
44
|
+
# know which variant to pick.
|
|
45
|
+
_INGEST_ADAPTER: TypeAdapter[IngestInput] = TypeAdapter(IngestInput)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _wrap_pydantic_error(type_name: str, exc: PydanticValidationError) -> ValidationError:
|
|
49
|
+
"""Translate a Pydantic ValidationError into the SDK's ValidationError.
|
|
50
|
+
|
|
51
|
+
The SDK contract is that every SDK-raised exception inherits from
|
|
52
|
+
`AtomicMemoryError`. Without this wrapping, callers passing an
|
|
53
|
+
invalid dict at the client boundary would see Pydantic's exception
|
|
54
|
+
leak directly out of `MemoryClient`.
|
|
55
|
+
"""
|
|
56
|
+
return ValidationError(
|
|
57
|
+
f"Invalid {type_name}: {exc}",
|
|
58
|
+
context={"type": type_name, "errors": sanitized_pydantic_errors(exc)},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _coerce_ingest(value: IngestInput | dict[str, Any]) -> IngestInput:
|
|
63
|
+
if isinstance(value, dict):
|
|
64
|
+
try:
|
|
65
|
+
return _INGEST_ADAPTER.validate_python(value)
|
|
66
|
+
except PydanticValidationError as exc:
|
|
67
|
+
raise _wrap_pydantic_error("IngestInput", exc) from exc
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _coerce_search(value: SearchRequest | dict[str, Any]) -> SearchRequest:
|
|
72
|
+
if isinstance(value, dict):
|
|
73
|
+
try:
|
|
74
|
+
return SearchRequest.model_validate(value)
|
|
75
|
+
except PydanticValidationError as exc:
|
|
76
|
+
raise _wrap_pydantic_error("SearchRequest", exc) from exc
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _coerce_package(value: PackageRequest | dict[str, Any]) -> PackageRequest:
|
|
81
|
+
if isinstance(value, dict):
|
|
82
|
+
try:
|
|
83
|
+
return PackageRequest.model_validate(value)
|
|
84
|
+
except PydanticValidationError as exc:
|
|
85
|
+
raise _wrap_pydantic_error("PackageRequest", exc) from exc
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _coerce_ref(value: MemoryRef | dict[str, Any]) -> MemoryRef:
|
|
90
|
+
if isinstance(value, dict):
|
|
91
|
+
try:
|
|
92
|
+
return MemoryRef.model_validate(value)
|
|
93
|
+
except PydanticValidationError as exc:
|
|
94
|
+
raise _wrap_pydantic_error("MemoryRef", exc) from exc
|
|
95
|
+
return value
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _coerce_list_request(value: ListRequest | dict[str, Any]) -> ListRequest:
|
|
99
|
+
if isinstance(value, dict):
|
|
100
|
+
try:
|
|
101
|
+
return ListRequest.model_validate(value)
|
|
102
|
+
except PydanticValidationError as exc:
|
|
103
|
+
raise _wrap_pydantic_error("ListRequest", exc) from exc
|
|
104
|
+
return value
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
MemoryProviderConfigs = dict[str, Any]
|
|
108
|
+
"""Map of provider name → provider config (model or dict)."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class ProviderStatus:
|
|
113
|
+
"""Summary of one configured provider's runtime state."""
|
|
114
|
+
|
|
115
|
+
name: str
|
|
116
|
+
initialized: bool
|
|
117
|
+
capabilities: Capabilities | None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# Module-level alias so `MemoryClient.list` (the method) does not shadow
|
|
121
|
+
# the builtin `list` in return-type annotations within the class body.
|
|
122
|
+
_ProviderStatusList = list[ProviderStatus]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class MemoryClient:
|
|
126
|
+
"""Sync entry point for the V3 memory API.
|
|
127
|
+
|
|
128
|
+
Example:
|
|
129
|
+
>>> with MemoryClient(providers={"atomicmemory": {"api_url": "http://localhost:3050"}}) as memory:
|
|
130
|
+
... memory.initialize()
|
|
131
|
+
... memory.ingest({"mode": "text", "content": "hi", "scope": {"user": "u1"}})
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
providers: MemoryProviderConfigs,
|
|
137
|
+
default_provider: str | None = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
if not providers:
|
|
140
|
+
raise ConfigError(
|
|
141
|
+
'MemoryClient requires at least one provider config. Pass e.g. {"atomicmemory": {"api_url": "..."}}.'
|
|
142
|
+
)
|
|
143
|
+
chosen_default = default_provider or _pick_first_provider_key(providers)
|
|
144
|
+
if chosen_default is None:
|
|
145
|
+
raise ConfigError("No usable provider config supplied")
|
|
146
|
+
self._service = MemoryService(
|
|
147
|
+
MemoryServiceConfig(
|
|
148
|
+
default_provider=chosen_default,
|
|
149
|
+
provider_configs=dict(providers),
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
self._initialized = False
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
# Lifecycle
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def initialize(self, registry: ProviderRegistry | None = None) -> None:
|
|
159
|
+
"""Initialize all configured providers. Idempotent."""
|
|
160
|
+
if self._initialized:
|
|
161
|
+
return
|
|
162
|
+
self._service.initialize(registry if registry is not None else default_registry)
|
|
163
|
+
self._initialized = True
|
|
164
|
+
|
|
165
|
+
def close(self) -> None:
|
|
166
|
+
"""Close every initialized provider; safe to call multiple times."""
|
|
167
|
+
if not self._initialized:
|
|
168
|
+
return
|
|
169
|
+
self._service.close()
|
|
170
|
+
self._initialized = False
|
|
171
|
+
|
|
172
|
+
def __enter__(self) -> MemoryClient:
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
def __exit__(
|
|
176
|
+
self,
|
|
177
|
+
exc_type: type[BaseException] | None,
|
|
178
|
+
exc: BaseException | None,
|
|
179
|
+
tb: TracebackType | None,
|
|
180
|
+
) -> None:
|
|
181
|
+
self.close()
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
# Core operations (each pair: gated + direct mirror to TS surface)
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def ingest(self, input: IngestInput | dict[str, Any]) -> IngestResult:
|
|
188
|
+
self._assert_initialized()
|
|
189
|
+
return self._service.ingest(_coerce_ingest(input))
|
|
190
|
+
|
|
191
|
+
def ingest_direct(self, input: IngestInput | dict[str, Any]) -> IngestResult:
|
|
192
|
+
"""Identical to :meth:`ingest`; preserved for wrapper-subclass parity with TS."""
|
|
193
|
+
self._assert_initialized()
|
|
194
|
+
return self._service.ingest(_coerce_ingest(input))
|
|
195
|
+
|
|
196
|
+
def search(self, request: SearchRequest | dict[str, Any]) -> SearchResultPage:
|
|
197
|
+
self._assert_initialized()
|
|
198
|
+
return self._service.search(_coerce_search(request))
|
|
199
|
+
|
|
200
|
+
def search_direct(self, request: SearchRequest | dict[str, Any]) -> SearchResultPage:
|
|
201
|
+
"""Identical to :meth:`search`; preserved for wrapper-subclass parity with TS."""
|
|
202
|
+
self._assert_initialized()
|
|
203
|
+
return self._service.search(_coerce_search(request))
|
|
204
|
+
|
|
205
|
+
def package(self, request: PackageRequest | dict[str, Any]) -> ContextPackage:
|
|
206
|
+
self._assert_initialized()
|
|
207
|
+
return self._service.package(_coerce_package(request))
|
|
208
|
+
|
|
209
|
+
def package_direct(self, request: PackageRequest | dict[str, Any]) -> ContextPackage:
|
|
210
|
+
"""Identical to :meth:`package`; preserved for wrapper-subclass parity with TS."""
|
|
211
|
+
self._assert_initialized()
|
|
212
|
+
return self._service.package(_coerce_package(request))
|
|
213
|
+
|
|
214
|
+
def get(self, ref: MemoryRef | dict[str, Any]) -> Memory | None:
|
|
215
|
+
self._assert_initialized()
|
|
216
|
+
return self._service.get(_coerce_ref(ref))
|
|
217
|
+
|
|
218
|
+
def delete(self, ref: MemoryRef | dict[str, Any]) -> None:
|
|
219
|
+
self._assert_initialized()
|
|
220
|
+
self._service.delete(_coerce_ref(ref))
|
|
221
|
+
|
|
222
|
+
def list(self, request: ListRequest | dict[str, Any]) -> ListResultPage:
|
|
223
|
+
self._assert_initialized()
|
|
224
|
+
return self._service.list(_coerce_list_request(request))
|
|
225
|
+
|
|
226
|
+
# ------------------------------------------------------------------
|
|
227
|
+
# Capability + provider inspection
|
|
228
|
+
# ------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
def capabilities(self, provider_name: str | None = None) -> Capabilities:
|
|
231
|
+
self._assert_initialized()
|
|
232
|
+
return self._service.get_provider(provider_name).capabilities()
|
|
233
|
+
|
|
234
|
+
def get_extension(self, extension_name: str, provider_name: str | None = None) -> Any | None:
|
|
235
|
+
self._assert_initialized()
|
|
236
|
+
provider = self._service.get_provider(provider_name)
|
|
237
|
+
return provider.get_extension(extension_name)
|
|
238
|
+
|
|
239
|
+
def get_provider_status(self) -> _ProviderStatusList:
|
|
240
|
+
configured = self._service.get_configured_providers()
|
|
241
|
+
if not self._initialized:
|
|
242
|
+
return [ProviderStatus(name=n, initialized=False, capabilities=None) for n in configured]
|
|
243
|
+
available = set(self._service.get_available_providers())
|
|
244
|
+
statuses: _ProviderStatusList = []
|
|
245
|
+
for n in configured:
|
|
246
|
+
if n not in available:
|
|
247
|
+
statuses.append(ProviderStatus(name=n, initialized=False, capabilities=None))
|
|
248
|
+
continue
|
|
249
|
+
statuses.append(
|
|
250
|
+
ProviderStatus(
|
|
251
|
+
name=n,
|
|
252
|
+
initialized=True,
|
|
253
|
+
capabilities=self._service.get_provider(n).capabilities(),
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
return statuses
|
|
257
|
+
|
|
258
|
+
def get_provider(self, name: str | None = None) -> BaseMemoryProvider:
|
|
259
|
+
self._assert_initialized()
|
|
260
|
+
return self._service.get_provider(name)
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def atomicmemory(self) -> AtomicMemoryHandle | None:
|
|
264
|
+
"""Typed access to AtomicMemory-specific routes.
|
|
265
|
+
|
|
266
|
+
Returns ``None`` when the client is not yet initialized or the
|
|
267
|
+
``atomicmemory`` provider was not configured.
|
|
268
|
+
"""
|
|
269
|
+
if not self._initialized:
|
|
270
|
+
return None
|
|
271
|
+
if "atomicmemory" not in self._service.get_configured_providers():
|
|
272
|
+
return None
|
|
273
|
+
provider = self._service.get_provider("atomicmemory")
|
|
274
|
+
handle = provider.get_extension("atomicmemory.base")
|
|
275
|
+
if not isinstance(handle, AtomicMemoryHandle):
|
|
276
|
+
return None
|
|
277
|
+
return handle
|
|
278
|
+
|
|
279
|
+
# ------------------------------------------------------------------
|
|
280
|
+
# Internals
|
|
281
|
+
# ------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
def _assert_initialized(self) -> None:
|
|
284
|
+
if not self._initialized:
|
|
285
|
+
raise NotInitializedError("MemoryClient is not initialized. Call client.initialize() first.")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _pick_first_provider_key(providers: MemoryProviderConfigs) -> str | None:
|
|
289
|
+
for key, value in providers.items():
|
|
290
|
+
if value is not None and key != "default":
|
|
291
|
+
return key
|
|
292
|
+
return None
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Cross-cutting building blocks: errors, retry, events, logging.
|
|
2
|
+
|
|
3
|
+
Port of `atomicmemory-sdk/src/core/`. These modules are the only ones every
|
|
4
|
+
other layer depends on; they intentionally have no inward dependencies on
|
|
5
|
+
`memory/`, `providers/`, `client/`, etc.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from atomicmemory.core.errors import (
|
|
9
|
+
AtomicMemoryError,
|
|
10
|
+
ConfigError,
|
|
11
|
+
NetworkError,
|
|
12
|
+
NotInitializedError,
|
|
13
|
+
ProviderError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
ValidationError,
|
|
16
|
+
)
|
|
17
|
+
from atomicmemory.core.events import EventEmitter
|
|
18
|
+
from atomicmemory.core.logging import configure_logging, get_logger
|
|
19
|
+
from atomicmemory.core.retry import RetryConfig, with_retry
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AtomicMemoryError",
|
|
23
|
+
"ConfigError",
|
|
24
|
+
"EventEmitter",
|
|
25
|
+
"NetworkError",
|
|
26
|
+
"NotInitializedError",
|
|
27
|
+
"ProviderError",
|
|
28
|
+
"RateLimitError",
|
|
29
|
+
"RetryConfig",
|
|
30
|
+
"ValidationError",
|
|
31
|
+
"configure_logging",
|
|
32
|
+
"get_logger",
|
|
33
|
+
"with_retry",
|
|
34
|
+
]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Error hierarchy for the atomicmemory SDK.
|
|
2
|
+
|
|
3
|
+
Port of `atomicmemory-sdk/src/core/error-handling/`. Every SDK-raised
|
|
4
|
+
exception inherits from `AtomicMemoryError` so callers can catch the
|
|
5
|
+
whole surface with one type.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AtomicMemoryError(Exception):
|
|
14
|
+
"""Base class for every error raised by this SDK.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
message: Human-readable description.
|
|
18
|
+
context: Free-form structured data (e.g. provider name, route,
|
|
19
|
+
request body fingerprint) that aids debugging without leaking
|
|
20
|
+
secrets.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, message: str, *, context: dict[str, Any] | None = None) -> None:
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.message = message
|
|
26
|
+
self.context: dict[str, Any] = dict(context) if context else {}
|
|
27
|
+
|
|
28
|
+
def __repr__(self) -> str:
|
|
29
|
+
return f"{type(self).__name__}({self.message!r}, context={self.context!r})"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConfigError(AtomicMemoryError):
|
|
33
|
+
"""Configuration is missing, malformed, or self-inconsistent."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ValidationError(AtomicMemoryError):
|
|
37
|
+
"""Input failed schema or invariant validation before any I/O."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NotInitializedError(AtomicMemoryError):
|
|
41
|
+
"""A client method was called before `initialize()` completed."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ProviderError(AtomicMemoryError):
|
|
45
|
+
"""A backing provider returned an error or the request was rejected.
|
|
46
|
+
|
|
47
|
+
Wraps backend HTTP errors. `status_code` and `response_body` are
|
|
48
|
+
populated when the underlying transport surfaced them.
|
|
49
|
+
|
|
50
|
+
Attributes:
|
|
51
|
+
provider: Provider name, e.g. ``"atomicmemory"`` or ``"mem0"``.
|
|
52
|
+
status_code: HTTP status code if the failure originated from a
|
|
53
|
+
response; ``None`` for transport-level failures (use
|
|
54
|
+
`NetworkError` for those).
|
|
55
|
+
response_body: Decoded response body when available.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
message: str,
|
|
61
|
+
*,
|
|
62
|
+
provider: str,
|
|
63
|
+
status_code: int | None = None,
|
|
64
|
+
response_body: Any | None = None,
|
|
65
|
+
context: dict[str, Any] | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
merged: dict[str, Any] = {"provider": provider}
|
|
68
|
+
if status_code is not None:
|
|
69
|
+
merged["status_code"] = status_code
|
|
70
|
+
if context:
|
|
71
|
+
merged.update(context)
|
|
72
|
+
super().__init__(message, context=merged)
|
|
73
|
+
self.provider = provider
|
|
74
|
+
self.status_code = status_code
|
|
75
|
+
self.response_body = response_body
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class NetworkError(AtomicMemoryError):
|
|
79
|
+
"""A transport-level failure (timeout, connection refused, DNS, etc.)."""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
message: str,
|
|
84
|
+
*,
|
|
85
|
+
provider: str,
|
|
86
|
+
cause: BaseException | None = None,
|
|
87
|
+
context: dict[str, Any] | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
merged: dict[str, Any] = {"provider": provider}
|
|
90
|
+
if context:
|
|
91
|
+
merged.update(context)
|
|
92
|
+
super().__init__(message, context=merged)
|
|
93
|
+
self.provider = provider
|
|
94
|
+
self.__cause__ = cause
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class RateLimitError(ProviderError):
|
|
98
|
+
"""The backend returned HTTP 429 (rate limited).
|
|
99
|
+
|
|
100
|
+
`retry_after_seconds` is populated when the response carried a
|
|
101
|
+
`Retry-After` header (in seconds). Callers may use it to drive a
|
|
102
|
+
deferred retry.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
message: str = "Rate limited",
|
|
108
|
+
*,
|
|
109
|
+
provider: str,
|
|
110
|
+
retry_after_seconds: float | None = None,
|
|
111
|
+
context: dict[str, Any] | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
merged: dict[str, Any] = dict(context) if context else {}
|
|
114
|
+
if retry_after_seconds is not None:
|
|
115
|
+
merged["retry_after_seconds"] = retry_after_seconds
|
|
116
|
+
super().__init__(
|
|
117
|
+
message,
|
|
118
|
+
provider=provider,
|
|
119
|
+
status_code=429,
|
|
120
|
+
context=merged,
|
|
121
|
+
)
|
|
122
|
+
self.retry_after_seconds = retry_after_seconds
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Lightweight typed event emitter.
|
|
2
|
+
|
|
3
|
+
Port of `atomicmemory-sdk/src/core/events.ts`. Sync-only by design — the
|
|
4
|
+
SDK emits events for cache hits, search completions, and similar
|
|
5
|
+
diagnostic hooks; async listeners can schedule themselves via the event
|
|
6
|
+
loop if needed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
Listener = Callable[..., None]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EventEmitter:
|
|
18
|
+
"""Minimal pub/sub for diagnostic events.
|
|
19
|
+
|
|
20
|
+
Not thread-safe. Listeners are invoked synchronously in registration
|
|
21
|
+
order; an exception in one listener does not prevent the others from
|
|
22
|
+
running, but the first exception is re-raised after all have been
|
|
23
|
+
called.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self._listeners: dict[str, list[Listener]] = {}
|
|
28
|
+
|
|
29
|
+
def on(self, event: str, listener: Listener) -> None:
|
|
30
|
+
"""Register a listener for ``event``."""
|
|
31
|
+
self._listeners.setdefault(event, []).append(listener)
|
|
32
|
+
|
|
33
|
+
def off(self, event: str, listener: Listener) -> None:
|
|
34
|
+
"""Remove a previously registered listener.
|
|
35
|
+
|
|
36
|
+
No-op when the listener was not registered.
|
|
37
|
+
"""
|
|
38
|
+
if event not in self._listeners:
|
|
39
|
+
return
|
|
40
|
+
try:
|
|
41
|
+
self._listeners[event].remove(listener)
|
|
42
|
+
except ValueError:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
def emit(self, event: str, *args: Any, **kwargs: Any) -> None:
|
|
46
|
+
"""Invoke every listener registered for ``event``.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
BaseException: Re-raises the first listener exception after
|
|
50
|
+
all listeners have been invoked.
|
|
51
|
+
"""
|
|
52
|
+
listeners = list(self._listeners.get(event, ()))
|
|
53
|
+
first_exc: BaseException | None = None
|
|
54
|
+
for listener in listeners:
|
|
55
|
+
try:
|
|
56
|
+
listener(*args, **kwargs)
|
|
57
|
+
except BaseException as exc:
|
|
58
|
+
if first_exc is None:
|
|
59
|
+
first_exc = exc
|
|
60
|
+
if first_exc is not None:
|
|
61
|
+
raise first_exc
|
|
62
|
+
|
|
63
|
+
def listener_count(self, event: str) -> int:
|
|
64
|
+
"""Number of listeners registered for ``event``."""
|
|
65
|
+
return len(self._listeners.get(event, ()))
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Logging helpers for the SDK.
|
|
2
|
+
|
|
3
|
+
Port of `atomicmemory-sdk/src/utils/logger.ts`. Wraps stdlib `logging` so
|
|
4
|
+
SDK callers get a consistent logger name prefix without forcing handler
|
|
5
|
+
configuration on the host application.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
_ROOT_LOGGER_NAME = "atomicmemory"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_logger(name: str | None = None) -> logging.Logger:
|
|
16
|
+
"""Return a logger under the ``atomicmemory`` namespace.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
name: Optional dotted suffix appended to ``atomicmemory.``.
|
|
20
|
+
``None`` returns the root SDK logger.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Configured `logging.Logger`. Handler attachment is the host
|
|
24
|
+
application's responsibility; the SDK never adds default handlers.
|
|
25
|
+
"""
|
|
26
|
+
if name is None or name == "":
|
|
27
|
+
return logging.getLogger(_ROOT_LOGGER_NAME)
|
|
28
|
+
return logging.getLogger(f"{_ROOT_LOGGER_NAME}.{name}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def configure_logging(level: int = logging.INFO) -> None:
|
|
32
|
+
"""Set the SDK root logger's level.
|
|
33
|
+
|
|
34
|
+
Useful as a one-liner during development. Production callers should
|
|
35
|
+
configure their own handlers via stdlib `logging` directly.
|
|
36
|
+
"""
|
|
37
|
+
get_logger().setLevel(level)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Retry primitives with exponential backoff.
|
|
2
|
+
|
|
3
|
+
Port of `atomicmemory-sdk/src/storage/retry-engine.ts`. Used by the HTTP
|
|
4
|
+
transports for idempotent operations (GET, search, list); never wrap
|
|
5
|
+
non-idempotent calls without a caller-supplied `should_retry` predicate.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Awaitable, Callable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TypeVar
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class RetryConfig:
|
|
21
|
+
"""Knobs for `with_retry`.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
max_attempts: Total attempts, including the initial call. Must be
|
|
25
|
+
``>= 1``. ``1`` disables retry.
|
|
26
|
+
initial_delay_seconds: Delay before the *second* attempt.
|
|
27
|
+
max_delay_seconds: Upper bound on any single backoff delay.
|
|
28
|
+
backoff_multiplier: Each successive delay is multiplied by this.
|
|
29
|
+
``2.0`` doubles the wait per attempt.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
max_attempts: int = 3
|
|
33
|
+
initial_delay_seconds: float = 0.25
|
|
34
|
+
max_delay_seconds: float = 5.0
|
|
35
|
+
backoff_multiplier: float = 2.0
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
if self.max_attempts < 1:
|
|
39
|
+
raise ValueError("max_attempts must be >= 1")
|
|
40
|
+
if self.initial_delay_seconds < 0:
|
|
41
|
+
raise ValueError("initial_delay_seconds must be >= 0")
|
|
42
|
+
if self.max_delay_seconds < self.initial_delay_seconds:
|
|
43
|
+
raise ValueError("max_delay_seconds must be >= initial_delay_seconds")
|
|
44
|
+
if self.backoff_multiplier <= 0:
|
|
45
|
+
raise ValueError("backoff_multiplier must be > 0")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _delays(config: RetryConfig) -> list[float]:
|
|
49
|
+
"""Return the sequence of sleep durations between attempts."""
|
|
50
|
+
delays: list[float] = []
|
|
51
|
+
delay = config.initial_delay_seconds
|
|
52
|
+
for _ in range(config.max_attempts - 1):
|
|
53
|
+
delays.append(min(delay, config.max_delay_seconds))
|
|
54
|
+
delay *= config.backoff_multiplier
|
|
55
|
+
return delays
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def with_retry(
|
|
59
|
+
func: Callable[[], T],
|
|
60
|
+
*,
|
|
61
|
+
config: RetryConfig | None = None,
|
|
62
|
+
should_retry: Callable[[BaseException], bool] | None = None,
|
|
63
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
64
|
+
) -> T:
|
|
65
|
+
"""Run a callable with retry on transient failures.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
func: Zero-argument callable to invoke.
|
|
69
|
+
config: Retry configuration. Defaults to :class:`RetryConfig`'s
|
|
70
|
+
defaults when ``None``.
|
|
71
|
+
should_retry: Predicate that decides whether a given exception is
|
|
72
|
+
transient. ``None`` means "never retry exceptions" — only
|
|
73
|
+
useful for testing the success path.
|
|
74
|
+
sleep: Sleep function. Pluggable for deterministic testing.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Whatever ``func`` returns on its first successful attempt.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
BaseException: Re-raises the last exception if every attempt fails
|
|
81
|
+
or if ``should_retry`` rejects it.
|
|
82
|
+
"""
|
|
83
|
+
cfg = config or RetryConfig()
|
|
84
|
+
delays = _delays(cfg)
|
|
85
|
+
last_exc: BaseException | None = None
|
|
86
|
+
for attempt in range(cfg.max_attempts):
|
|
87
|
+
try:
|
|
88
|
+
return func()
|
|
89
|
+
except BaseException as exc:
|
|
90
|
+
last_exc = exc
|
|
91
|
+
is_last = attempt == cfg.max_attempts - 1
|
|
92
|
+
if is_last or should_retry is None or not should_retry(exc):
|
|
93
|
+
raise
|
|
94
|
+
sleep(delays[attempt])
|
|
95
|
+
assert last_exc is not None
|
|
96
|
+
raise last_exc
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def awith_retry(
|
|
100
|
+
func: Callable[[], Awaitable[T]],
|
|
101
|
+
*,
|
|
102
|
+
config: RetryConfig | None = None,
|
|
103
|
+
should_retry: Callable[[BaseException], bool] | None = None,
|
|
104
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
105
|
+
) -> T:
|
|
106
|
+
"""Async counterpart of :func:`with_retry`.
|
|
107
|
+
|
|
108
|
+
Behaviour mirrors the sync version; both use the same backoff schedule
|
|
109
|
+
and the same predicate signature.
|
|
110
|
+
"""
|
|
111
|
+
cfg = config or RetryConfig()
|
|
112
|
+
delays = _delays(cfg)
|
|
113
|
+
last_exc: BaseException | None = None
|
|
114
|
+
for attempt in range(cfg.max_attempts):
|
|
115
|
+
try:
|
|
116
|
+
return await func()
|
|
117
|
+
except BaseException as exc:
|
|
118
|
+
last_exc = exc
|
|
119
|
+
is_last = attempt == cfg.max_attempts - 1
|
|
120
|
+
if is_last or should_retry is None or not should_retry(exc):
|
|
121
|
+
raise
|
|
122
|
+
await sleep(delays[attempt])
|
|
123
|
+
assert last_exc is not None
|
|
124
|
+
raise last_exc
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Validation helpers shared by public SDK boundaries.
|
|
2
|
+
|
|
3
|
+
Pydantic errors can include caller input under the ``input`` key. Public
|
|
4
|
+
SDK exceptions should expose useful schema diagnostics without copying
|
|
5
|
+
API keys, metadata, or byte bodies into structured error context.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
13
|
+
|
|
14
|
+
_DROPPED_ERROR_KEYS = {"input", "ctx", "url"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def sanitized_pydantic_errors(exc: PydanticValidationError) -> list[dict[str, Any]]:
|
|
18
|
+
"""Return Pydantic errors stripped of caller-supplied values."""
|
|
19
|
+
sanitized: list[dict[str, Any]] = []
|
|
20
|
+
for error in exc.errors():
|
|
21
|
+
sanitized.append({key: value for key, value in error.items() if key not in _DROPPED_ERROR_KEYS})
|
|
22
|
+
return sanitized
|