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,305 @@
|
|
|
1
|
+
"""Wire mappers for backend artifact-storage responses.
|
|
2
|
+
|
|
3
|
+
Core emits snake_case JSON and storage HEAD headers. This module is the
|
|
4
|
+
single translation seam into Python SDK models, with closed-enum
|
|
5
|
+
validation before any public model is returned.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from typing import Any, TypeVar
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
17
|
+
|
|
18
|
+
from atomicmemory.storage.errors import (
|
|
19
|
+
ArtifactInUseError,
|
|
20
|
+
ArtifactNotFoundError,
|
|
21
|
+
FilecoinDirectStorageNotSupportedError,
|
|
22
|
+
PointerContentNotManagedError,
|
|
23
|
+
StorageClientError,
|
|
24
|
+
UnsupportedCapabilityError,
|
|
25
|
+
)
|
|
26
|
+
from atomicmemory.storage.types import (
|
|
27
|
+
ArtifactHead,
|
|
28
|
+
DeleteArtifactResult,
|
|
29
|
+
StorageArtifactStatus,
|
|
30
|
+
StoredArtifact,
|
|
31
|
+
VerificationResult,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
ModelT = TypeVar("ModelT", bound=BaseModel)
|
|
35
|
+
|
|
36
|
+
STORAGE_MODES = ("pointer", "managed")
|
|
37
|
+
STORAGE_STATUSES: tuple[StorageArtifactStatus, ...] = (
|
|
38
|
+
"stored",
|
|
39
|
+
"pending",
|
|
40
|
+
"available",
|
|
41
|
+
"unavailable",
|
|
42
|
+
"deleting",
|
|
43
|
+
"deleted",
|
|
44
|
+
"delete_failed",
|
|
45
|
+
"failed",
|
|
46
|
+
)
|
|
47
|
+
CONTENT_ENCODINGS = ("identity", "aes_gcm")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def map_stored_artifact(raw: Any) -> StoredArtifact:
|
|
51
|
+
"""Translate and validate a snake_case artifact response."""
|
|
52
|
+
body = _require_object(raw, "StoredArtifact")
|
|
53
|
+
artifact_id = _require_wire_string(body, "artifact_id")
|
|
54
|
+
provider = _require_wire_string(body, "provider")
|
|
55
|
+
mode = _require_wire_enum(body, "mode", STORAGE_MODES)
|
|
56
|
+
status = _require_wire_enum(body, "status", STORAGE_STATUSES)
|
|
57
|
+
content_encoding = _require_wire_enum(body, "content_encoding", CONTENT_ENCODINGS)
|
|
58
|
+
artifact = {
|
|
59
|
+
"artifactId": artifact_id,
|
|
60
|
+
"provider": provider,
|
|
61
|
+
"mode": mode,
|
|
62
|
+
"uri": _optional_string(body, "uri"),
|
|
63
|
+
"status": status,
|
|
64
|
+
"sizeBytes": _optional_non_negative_int(body, "size_bytes"),
|
|
65
|
+
"contentType": _optional_string(body, "content_type"),
|
|
66
|
+
"contentEncoding": content_encoding,
|
|
67
|
+
"identifiers": _string_dict(body, "identifiers"),
|
|
68
|
+
"lifecycle": _object_or_empty(body, "lifecycle"),
|
|
69
|
+
"metadata": _metadata_dict(body, "metadata"),
|
|
70
|
+
"createdAt": _require_wire_string(body, "created_at"),
|
|
71
|
+
"updatedAt": _require_wire_string(body, "updated_at"),
|
|
72
|
+
}
|
|
73
|
+
content_hash = _optional_string(body, "content_hash")
|
|
74
|
+
if content_hash is not None:
|
|
75
|
+
artifact["contentHash"] = content_hash
|
|
76
|
+
_copy_optional_object(artifact, "providerDetails", body, "provider_details")
|
|
77
|
+
_copy_optional_object(artifact, "replication", body, "replication")
|
|
78
|
+
_copy_optional_object(artifact, "verification", body, "verification")
|
|
79
|
+
_copy_optional_object(artifact, "retrieval", body, "retrieval")
|
|
80
|
+
return validate_response_model(StoredArtifact, artifact, "StoredArtifact")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def map_head_headers(headers: httpx.Headers, fallback_id: str) -> ArtifactHead:
|
|
84
|
+
"""Project storage HEAD response headers into an ``ArtifactHead``."""
|
|
85
|
+
mode = _validate_header_enum(headers, "x-atomicmemory-storage-mode", STORAGE_MODES)
|
|
86
|
+
status = _validate_header_enum(headers, "x-atomicmemory-storage-status", STORAGE_STATUSES)
|
|
87
|
+
return validate_response_model(
|
|
88
|
+
ArtifactHead,
|
|
89
|
+
{
|
|
90
|
+
"artifactId": headers.get("x-atomicmemory-artifact-id") or fallback_id,
|
|
91
|
+
"provider": headers.get("x-atomicmemory-provider") or "",
|
|
92
|
+
"mode": mode,
|
|
93
|
+
"status": status,
|
|
94
|
+
"sizeBytes": _parse_size(headers.get("content-length")),
|
|
95
|
+
"contentType": headers.get("content-type"),
|
|
96
|
+
},
|
|
97
|
+
"ArtifactHead",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def map_delete_result(raw: Any) -> DeleteArtifactResult:
|
|
102
|
+
"""Translate and validate a snake_case delete response."""
|
|
103
|
+
body = _require_object(raw, "DeleteArtifactResult")
|
|
104
|
+
out: dict[str, Any] = {
|
|
105
|
+
"artifactId": _require_wire_string(body, "artifact_id"),
|
|
106
|
+
"status": _require_wire_enum(body, "status", STORAGE_STATUSES),
|
|
107
|
+
}
|
|
108
|
+
if isinstance(body.get("cascaded_document_ids"), list):
|
|
109
|
+
out["cascadedDocumentIds"] = [str(v) for v in body["cascaded_document_ids"]]
|
|
110
|
+
return validate_response_model(DeleteArtifactResult, out, "DeleteArtifactResult")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def map_verify_result(raw: Any) -> VerificationResult:
|
|
114
|
+
"""Map verification response variants to a single Python model."""
|
|
115
|
+
body = raw if isinstance(raw, dict) else {}
|
|
116
|
+
if body.get("kind") == "verified":
|
|
117
|
+
details = body.get("details") if isinstance(body.get("details"), dict) else {}
|
|
118
|
+
return validate_response_model(
|
|
119
|
+
VerificationResult, {"kind": "verified", "details": details}, "VerificationResult"
|
|
120
|
+
)
|
|
121
|
+
if body.get("kind") == "failed":
|
|
122
|
+
failed = {"kind": "failed", "reason": str(body.get("reason", "unknown failure"))}
|
|
123
|
+
return validate_response_model(VerificationResult, failed, "VerificationResult")
|
|
124
|
+
unsupported = {"kind": "unsupported", "reason": str(body.get("reason", "unsupported"))}
|
|
125
|
+
return validate_response_model(VerificationResult, unsupported, "VerificationResult")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def validate_response_model(model: type[ModelT], data: dict[str, Any], type_name: str) -> ModelT:
|
|
129
|
+
"""Validate a response model without leaking Pydantic errors."""
|
|
130
|
+
try:
|
|
131
|
+
return model.model_validate(data)
|
|
132
|
+
except PydanticValidationError as exc:
|
|
133
|
+
raise StorageClientError(
|
|
134
|
+
f"{type_name}: server response failed validation",
|
|
135
|
+
error_code="invalid_storage_response",
|
|
136
|
+
status=200,
|
|
137
|
+
body_text="",
|
|
138
|
+
context={"type": type_name, "errors": exc.errors()},
|
|
139
|
+
) from exc
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def raise_for_storage_response(response: httpx.Response, artifact_id: str | None = None) -> None:
|
|
143
|
+
"""Raise the typed storage error for a non-success response."""
|
|
144
|
+
body_text = response.text
|
|
145
|
+
envelope = _parse_error_envelope(body_text)
|
|
146
|
+
code = envelope.get("error_code")
|
|
147
|
+
message = str(envelope.get("error") or f"request failed with status {response.status_code}")
|
|
148
|
+
known_id = artifact_id or ""
|
|
149
|
+
if code == "artifact_in_use":
|
|
150
|
+
count = envelope.get("referenced_by_document_count")
|
|
151
|
+
raise ArtifactInUseError(
|
|
152
|
+
artifact_id=known_id,
|
|
153
|
+
referenced_by_document_count=count if isinstance(count, int) else 0,
|
|
154
|
+
body_text=body_text,
|
|
155
|
+
)
|
|
156
|
+
if code == "pointer_content_not_managed":
|
|
157
|
+
uri = envelope.get("uri")
|
|
158
|
+
raise PointerContentNotManagedError(
|
|
159
|
+
artifact_id=known_id,
|
|
160
|
+
uri=uri if isinstance(uri, str) else "",
|
|
161
|
+
body_text=body_text,
|
|
162
|
+
)
|
|
163
|
+
if code == "filecoin_direct_storage_not_yet_supported":
|
|
164
|
+
raise FilecoinDirectStorageNotSupportedError(body_text=body_text)
|
|
165
|
+
if code == "artifact_not_found" or response.status_code == 404:
|
|
166
|
+
raise ArtifactNotFoundError(artifact_id=known_id, body_text=body_text)
|
|
167
|
+
if code == "unsupported_capability":
|
|
168
|
+
raise UnsupportedCapabilityError(capability="unknown", message=message, body_text=body_text)
|
|
169
|
+
raise StorageClientError(
|
|
170
|
+
message,
|
|
171
|
+
error_code=code if isinstance(code, str) else f"http_{response.status_code}",
|
|
172
|
+
status=response.status_code,
|
|
173
|
+
body_text=body_text,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _require_object(raw: Any, type_name: str) -> dict[str, Any]:
|
|
178
|
+
if not isinstance(raw, dict):
|
|
179
|
+
raise StorageClientError(
|
|
180
|
+
f"{type_name}: server response is not a JSON object",
|
|
181
|
+
error_code="invalid_storage_response",
|
|
182
|
+
status=200,
|
|
183
|
+
body_text="",
|
|
184
|
+
)
|
|
185
|
+
return raw
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _require_wire_string(raw: dict[str, Any], field: str) -> str:
|
|
189
|
+
value = raw.get(field)
|
|
190
|
+
if not isinstance(value, str) or value == "":
|
|
191
|
+
raise StorageClientError(
|
|
192
|
+
f"mapStoredArtifact: server response is missing required `{field}`",
|
|
193
|
+
error_code="invalid_storage_response",
|
|
194
|
+
status=200,
|
|
195
|
+
body_text="",
|
|
196
|
+
)
|
|
197
|
+
return str(value)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _require_wire_enum(raw: dict[str, Any], field: str, allowed: Iterable[str]) -> str:
|
|
201
|
+
value = raw.get(field)
|
|
202
|
+
allowed_values = tuple(allowed)
|
|
203
|
+
if not isinstance(value, str) or value not in allowed_values:
|
|
204
|
+
raise StorageClientError(
|
|
205
|
+
f"mapStoredArtifact: `{field}` must be one of {', '.join(allowed_values)}",
|
|
206
|
+
error_code="invalid_storage_response",
|
|
207
|
+
status=200,
|
|
208
|
+
body_text="",
|
|
209
|
+
)
|
|
210
|
+
return value
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _validate_header_enum(headers: httpx.Headers, name: str, allowed: Iterable[str]) -> str:
|
|
214
|
+
value = headers.get(name)
|
|
215
|
+
allowed_values = tuple(allowed)
|
|
216
|
+
if not isinstance(value, str) or value not in allowed_values:
|
|
217
|
+
raise StorageClientError(
|
|
218
|
+
f"head(): server returned an unrecognized {name} value",
|
|
219
|
+
error_code="invalid_head_response",
|
|
220
|
+
status=200,
|
|
221
|
+
body_text="",
|
|
222
|
+
)
|
|
223
|
+
return value
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _parse_size(value: str | None) -> int | None:
|
|
227
|
+
if value is None:
|
|
228
|
+
return None
|
|
229
|
+
try:
|
|
230
|
+
parsed = int(value)
|
|
231
|
+
except ValueError:
|
|
232
|
+
return None
|
|
233
|
+
return parsed if parsed >= 0 else None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _optional_string(body: dict[str, Any], field: str) -> str | None:
|
|
237
|
+
if field not in body or body[field] is None:
|
|
238
|
+
return None
|
|
239
|
+
value = body[field]
|
|
240
|
+
if isinstance(value, str):
|
|
241
|
+
return value
|
|
242
|
+
raise _invalid_response(f"mapStoredArtifact: `{field}` must be a string or null")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _optional_non_negative_int(body: dict[str, Any], field: str) -> int | None:
|
|
246
|
+
if field not in body or body[field] is None:
|
|
247
|
+
return None
|
|
248
|
+
value = body[field]
|
|
249
|
+
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
|
|
250
|
+
return value
|
|
251
|
+
raise _invalid_response(f"mapStoredArtifact: `{field}` must be a non-negative integer or null")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _string_dict(body: dict[str, Any], field: str) -> dict[str, str]:
|
|
255
|
+
if field not in body or body[field] is None:
|
|
256
|
+
return {}
|
|
257
|
+
value = body[field]
|
|
258
|
+
if not isinstance(value, dict):
|
|
259
|
+
raise _invalid_response(f"mapStoredArtifact: `{field}` must be an object")
|
|
260
|
+
if not all(isinstance(v, str) for v in value.values()):
|
|
261
|
+
raise _invalid_response(f"mapStoredArtifact: `{field}` values must be strings")
|
|
262
|
+
return {str(k): v for k, v in value.items()}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _object_or_empty(body: dict[str, Any], field: str) -> dict[str, Any]:
|
|
266
|
+
if field not in body or body[field] is None:
|
|
267
|
+
return {}
|
|
268
|
+
value = body[field]
|
|
269
|
+
if not isinstance(value, dict):
|
|
270
|
+
raise _invalid_response(f"mapStoredArtifact: `{field}` must be an object")
|
|
271
|
+
return value
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _metadata_dict(body: dict[str, Any], field: str) -> dict[str, str | int | float | bool]:
|
|
275
|
+
if field not in body or body[field] is None:
|
|
276
|
+
return {}
|
|
277
|
+
value = body[field]
|
|
278
|
+
if not isinstance(value, dict):
|
|
279
|
+
raise _invalid_response(f"mapStoredArtifact: `{field}` must be an object")
|
|
280
|
+
if not all(isinstance(v, str | int | float | bool) for v in value.values()):
|
|
281
|
+
raise _invalid_response(f"mapStoredArtifact: `{field}` values must be scalar")
|
|
282
|
+
return {str(k): v for k, v in value.items() if isinstance(v, str | int | float | bool)}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _copy_optional_object(out: dict[str, Any], out_key: str, body: dict[str, Any], wire_key: str) -> None:
|
|
286
|
+
if wire_key not in body or body[wire_key] is None:
|
|
287
|
+
return
|
|
288
|
+
value = body[wire_key]
|
|
289
|
+
if not isinstance(value, dict):
|
|
290
|
+
raise _invalid_response(f"mapStoredArtifact: `{wire_key}` must be an object")
|
|
291
|
+
out[out_key] = value
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _invalid_response(message: str) -> StorageClientError:
|
|
295
|
+
return StorageClientError(message, error_code="invalid_storage_response", status=200, body_text="")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _parse_error_envelope(body_text: str) -> dict[str, Any]:
|
|
299
|
+
if body_text == "":
|
|
300
|
+
return {}
|
|
301
|
+
try:
|
|
302
|
+
parsed = json.loads(body_text)
|
|
303
|
+
except json.JSONDecodeError:
|
|
304
|
+
return {}
|
|
305
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Async client for backend artifact storage.
|
|
2
|
+
|
|
3
|
+
The async surface mirrors :mod:`atomicmemory.storage.client` while
|
|
4
|
+
using ``httpx.AsyncClient`` for every storage API request.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from collections.abc import AsyncIterator
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from types import TracebackType
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from atomicmemory.storage._mapping import (
|
|
18
|
+
map_delete_result,
|
|
19
|
+
map_head_headers,
|
|
20
|
+
map_stored_artifact,
|
|
21
|
+
map_verify_result,
|
|
22
|
+
raise_for_storage_response,
|
|
23
|
+
validate_response_model,
|
|
24
|
+
)
|
|
25
|
+
from atomicmemory.storage.client import (
|
|
26
|
+
METADATA_HEADER,
|
|
27
|
+
_coerce_config,
|
|
28
|
+
_coerce_delete_options,
|
|
29
|
+
_coerce_managed_body,
|
|
30
|
+
_coerce_put_input,
|
|
31
|
+
_coerce_verify_options,
|
|
32
|
+
_encode_metadata_header,
|
|
33
|
+
_json_response,
|
|
34
|
+
_managed_path,
|
|
35
|
+
_network_error,
|
|
36
|
+
_pointer_payload,
|
|
37
|
+
_quote_id,
|
|
38
|
+
_require_artifact_id,
|
|
39
|
+
)
|
|
40
|
+
from atomicmemory.storage.types import (
|
|
41
|
+
ArtifactHead,
|
|
42
|
+
ArtifactRef,
|
|
43
|
+
DeleteArtifactOptions,
|
|
44
|
+
DeleteArtifactResult,
|
|
45
|
+
PutArtifactInput,
|
|
46
|
+
PutManagedInput,
|
|
47
|
+
PutPointerInput,
|
|
48
|
+
StorageCapabilities,
|
|
49
|
+
StorageClientConfig,
|
|
50
|
+
StoredArtifact,
|
|
51
|
+
VerificationResult,
|
|
52
|
+
VerifyArtifactOptions,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AsyncStorageClient:
|
|
57
|
+
"""Async entry point for the direct artifact-storage API."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, config: StorageClientConfig | dict[str, Any]) -> None:
|
|
60
|
+
self._config = _coerce_config(config)
|
|
61
|
+
self._api_url = self._config.api_url.rstrip("/")
|
|
62
|
+
self._client = httpx.AsyncClient(timeout=self._config.timeout_seconds)
|
|
63
|
+
|
|
64
|
+
async def capabilities(self) -> StorageCapabilities:
|
|
65
|
+
response = await self._request("GET", "/v1/storage/capabilities")
|
|
66
|
+
return validate_response_model(StorageCapabilities, _json_response(response), "StorageCapabilities")
|
|
67
|
+
|
|
68
|
+
async def put(self, input: PutArtifactInput | dict[str, Any]) -> StoredArtifact:
|
|
69
|
+
value = _coerce_put_input(input)
|
|
70
|
+
if isinstance(value, PutPointerInput):
|
|
71
|
+
return await self._put_pointer(value)
|
|
72
|
+
return await self._put_managed(value)
|
|
73
|
+
|
|
74
|
+
async def get(self, ref: ArtifactRef | dict[str, Any]) -> StoredArtifact:
|
|
75
|
+
artifact_id = _require_artifact_id(ref)
|
|
76
|
+
response = await self._request(
|
|
77
|
+
"GET",
|
|
78
|
+
f"/v1/storage/artifacts/{_quote_id(artifact_id)}",
|
|
79
|
+
artifact_id=artifact_id,
|
|
80
|
+
)
|
|
81
|
+
return map_stored_artifact(_json_response(response))
|
|
82
|
+
|
|
83
|
+
async def get_content(self, ref: ArtifactRef | dict[str, Any]) -> httpx.Response:
|
|
84
|
+
"""Return a fully buffered content response for small artifacts."""
|
|
85
|
+
artifact_id = _require_artifact_id(ref)
|
|
86
|
+
path = f"/v1/storage/artifacts/{_quote_id(artifact_id)}/content"
|
|
87
|
+
return await self._request("GET", path, artifact_id=artifact_id)
|
|
88
|
+
|
|
89
|
+
@asynccontextmanager
|
|
90
|
+
async def stream_content(self, ref: ArtifactRef | dict[str, Any]) -> AsyncIterator[httpx.Response]:
|
|
91
|
+
"""Stream artifact bytes without loading the whole response into memory."""
|
|
92
|
+
artifact_id = _require_artifact_id(ref)
|
|
93
|
+
path = f"/v1/storage/artifacts/{_quote_id(artifact_id)}/content"
|
|
94
|
+
try:
|
|
95
|
+
async with self._client.stream("GET", f"{self._api_url}{path}", headers=self._headers(None)) as response:
|
|
96
|
+
if response.is_success:
|
|
97
|
+
yield response
|
|
98
|
+
return
|
|
99
|
+
await response.aread()
|
|
100
|
+
raise_for_storage_response(response, artifact_id)
|
|
101
|
+
except httpx.RequestError as exc:
|
|
102
|
+
raise _network_error("GET", path, exc) from exc
|
|
103
|
+
raise AssertionError("unreachable")
|
|
104
|
+
|
|
105
|
+
async def head(self, ref: ArtifactRef | dict[str, Any]) -> ArtifactHead:
|
|
106
|
+
artifact_id = _require_artifact_id(ref)
|
|
107
|
+
response = await self._request(
|
|
108
|
+
"HEAD",
|
|
109
|
+
f"/v1/storage/artifacts/{_quote_id(artifact_id)}",
|
|
110
|
+
artifact_id=artifact_id,
|
|
111
|
+
)
|
|
112
|
+
return map_head_headers(response.headers, artifact_id)
|
|
113
|
+
|
|
114
|
+
async def delete(
|
|
115
|
+
self,
|
|
116
|
+
ref: ArtifactRef | dict[str, Any],
|
|
117
|
+
options: DeleteArtifactOptions | dict[str, Any] | None = None,
|
|
118
|
+
) -> DeleteArtifactResult:
|
|
119
|
+
artifact_id = _require_artifact_id(ref)
|
|
120
|
+
path = f"/v1/storage/artifacts/{_quote_id(artifact_id)}"
|
|
121
|
+
policy = _coerce_delete_options(options).policy
|
|
122
|
+
if policy:
|
|
123
|
+
path = f"{path}?policy={policy}"
|
|
124
|
+
response = await self._request("DELETE", path, artifact_id=artifact_id)
|
|
125
|
+
return map_delete_result(_json_response(response))
|
|
126
|
+
|
|
127
|
+
async def verify(
|
|
128
|
+
self,
|
|
129
|
+
ref: ArtifactRef | dict[str, Any],
|
|
130
|
+
options: VerifyArtifactOptions | dict[str, Any] | None = None,
|
|
131
|
+
) -> VerificationResult:
|
|
132
|
+
_coerce_verify_options(options)
|
|
133
|
+
artifact_id = _require_artifact_id(ref)
|
|
134
|
+
path = f"/v1/storage/artifacts/{_quote_id(artifact_id)}/verify"
|
|
135
|
+
response = await self._request("POST", path, artifact_id=artifact_id)
|
|
136
|
+
return map_verify_result(_json_response(response))
|
|
137
|
+
|
|
138
|
+
async def close(self) -> None:
|
|
139
|
+
await self._client.aclose()
|
|
140
|
+
|
|
141
|
+
async def __aenter__(self) -> AsyncStorageClient:
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
async def __aexit__(
|
|
145
|
+
self,
|
|
146
|
+
exc_type: type[BaseException] | None,
|
|
147
|
+
exc: BaseException | None,
|
|
148
|
+
tb: TracebackType | None,
|
|
149
|
+
) -> None:
|
|
150
|
+
await self.close()
|
|
151
|
+
|
|
152
|
+
async def _put_pointer(self, input: PutPointerInput) -> StoredArtifact:
|
|
153
|
+
response = await self._request(
|
|
154
|
+
"POST",
|
|
155
|
+
"/v1/storage/artifacts",
|
|
156
|
+
headers={"Content-Type": "application/json"},
|
|
157
|
+
content=_json_bytes(_pointer_payload(input)),
|
|
158
|
+
)
|
|
159
|
+
return map_stored_artifact(_json_response(response))
|
|
160
|
+
|
|
161
|
+
async def _put_managed(self, input: PutManagedInput) -> StoredArtifact:
|
|
162
|
+
body = _coerce_managed_body(input.body)
|
|
163
|
+
headers = {"Content-Type": input.content_type, "Content-Length": str(len(body))}
|
|
164
|
+
if input.metadata is not None:
|
|
165
|
+
headers[METADATA_HEADER] = _encode_metadata_header(input.metadata)
|
|
166
|
+
response = await self._request(
|
|
167
|
+
"POST",
|
|
168
|
+
_managed_path(input.disclose_content_hash),
|
|
169
|
+
headers=headers,
|
|
170
|
+
content=body,
|
|
171
|
+
)
|
|
172
|
+
return map_stored_artifact(_json_response(response))
|
|
173
|
+
|
|
174
|
+
async def _request(
|
|
175
|
+
self,
|
|
176
|
+
method: str,
|
|
177
|
+
path: str,
|
|
178
|
+
*,
|
|
179
|
+
headers: dict[str, str] | None = None,
|
|
180
|
+
content: bytes | None = None,
|
|
181
|
+
artifact_id: str | None = None,
|
|
182
|
+
) -> httpx.Response:
|
|
183
|
+
try:
|
|
184
|
+
response = await self._client.request(
|
|
185
|
+
method,
|
|
186
|
+
f"{self._api_url}{path}",
|
|
187
|
+
headers=self._headers(headers),
|
|
188
|
+
content=content,
|
|
189
|
+
)
|
|
190
|
+
except httpx.RequestError as exc:
|
|
191
|
+
raise _network_error(method, path, exc) from exc
|
|
192
|
+
if response.is_success:
|
|
193
|
+
return response
|
|
194
|
+
raise_for_storage_response(response, artifact_id)
|
|
195
|
+
raise AssertionError("unreachable")
|
|
196
|
+
|
|
197
|
+
def _headers(self, extra: dict[str, str] | None) -> dict[str, str]:
|
|
198
|
+
headers = {
|
|
199
|
+
"Authorization": f"Bearer {self._config.api_key}",
|
|
200
|
+
"X-AtomicMemory-User-Id": self._config.user_id,
|
|
201
|
+
}
|
|
202
|
+
if extra:
|
|
203
|
+
headers.update(extra)
|
|
204
|
+
return headers
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _json_bytes(payload: dict[str, Any]) -> bytes:
|
|
208
|
+
return json.dumps(payload, separators=(",", ":")).encode()
|