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.
Files changed (67) hide show
  1. atomicmemory/__init__.py +166 -0
  2. atomicmemory/_version.py +3 -0
  3. atomicmemory/client/__init__.py +22 -0
  4. atomicmemory/client/async_memory_client.py +202 -0
  5. atomicmemory/client/atomic_memory_client.py +181 -0
  6. atomicmemory/client/memory_client.py +292 -0
  7. atomicmemory/core/__init__.py +34 -0
  8. atomicmemory/core/errors.py +122 -0
  9. atomicmemory/core/events.py +65 -0
  10. atomicmemory/core/logging.py +37 -0
  11. atomicmemory/core/retry.py +124 -0
  12. atomicmemory/core/validation.py +22 -0
  13. atomicmemory/embeddings/__init__.py +16 -0
  14. atomicmemory/embeddings/base.py +39 -0
  15. atomicmemory/embeddings/sentence_transformers.py +104 -0
  16. atomicmemory/kv_cache/__init__.py +17 -0
  17. atomicmemory/kv_cache/adapter.py +50 -0
  18. atomicmemory/kv_cache/memory_storage.py +98 -0
  19. atomicmemory/kv_cache/sqlite_storage.py +122 -0
  20. atomicmemory/memory/__init__.py +82 -0
  21. atomicmemory/memory/filters.py +68 -0
  22. atomicmemory/memory/pipeline.py +42 -0
  23. atomicmemory/memory/provider.py +397 -0
  24. atomicmemory/memory/registry.py +95 -0
  25. atomicmemory/memory/service.py +199 -0
  26. atomicmemory/memory/types.py +398 -0
  27. atomicmemory/providers/__init__.py +5 -0
  28. atomicmemory/providers/atomicmemory/__init__.py +43 -0
  29. atomicmemory/providers/atomicmemory/agents.py +156 -0
  30. atomicmemory/providers/atomicmemory/async_handle_impl.py +198 -0
  31. atomicmemory/providers/atomicmemory/async_provider.py +245 -0
  32. atomicmemory/providers/atomicmemory/audit.py +74 -0
  33. atomicmemory/providers/atomicmemory/config.py +38 -0
  34. atomicmemory/providers/atomicmemory/config_handle.py +123 -0
  35. atomicmemory/providers/atomicmemory/handle.py +513 -0
  36. atomicmemory/providers/atomicmemory/handle_impl.py +325 -0
  37. atomicmemory/providers/atomicmemory/http.py +255 -0
  38. atomicmemory/providers/atomicmemory/lessons.py +133 -0
  39. atomicmemory/providers/atomicmemory/lifecycle.py +202 -0
  40. atomicmemory/providers/atomicmemory/mappers.py +125 -0
  41. atomicmemory/providers/atomicmemory/path.py +20 -0
  42. atomicmemory/providers/atomicmemory/provider.py +300 -0
  43. atomicmemory/providers/atomicmemory/scope_mapper.py +98 -0
  44. atomicmemory/providers/mem0/__init__.py +41 -0
  45. atomicmemory/providers/mem0/async_provider.py +191 -0
  46. atomicmemory/providers/mem0/config.py +51 -0
  47. atomicmemory/providers/mem0/http.py +195 -0
  48. atomicmemory/providers/mem0/mappers.py +145 -0
  49. atomicmemory/providers/mem0/provider.py +202 -0
  50. atomicmemory/py.typed +0 -0
  51. atomicmemory/search/__init__.py +47 -0
  52. atomicmemory/search/chunking.py +161 -0
  53. atomicmemory/search/ranking.py +94 -0
  54. atomicmemory/search/semantic_search.py +130 -0
  55. atomicmemory/search/similarity.py +110 -0
  56. atomicmemory/storage/__init__.py +63 -0
  57. atomicmemory/storage/_mapping.py +305 -0
  58. atomicmemory/storage/async_client.py +208 -0
  59. atomicmemory/storage/client.py +339 -0
  60. atomicmemory/storage/errors.py +115 -0
  61. atomicmemory/storage/types.py +305 -0
  62. atomicmemory/utils/__init__.py +5 -0
  63. atomicmemory/utils/environment.py +23 -0
  64. atomicmemory-1.0.0.dist-info/METADATA +146 -0
  65. atomicmemory-1.0.0.dist-info/RECORD +67 -0
  66. atomicmemory-1.0.0.dist-info/WHEEL +4 -0
  67. 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