cairnmark 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.
- cairnmark/__init__.py +57 -0
- cairnmark/_async_client.py +375 -0
- cairnmark/_client.py +368 -0
- cairnmark/_http.py +161 -0
- cairnmark/errors.py +82 -0
- cairnmark/models.py +68 -0
- cairnmark/py.typed +0 -0
- cairnmark/version.py +1 -0
- cairnmark-0.1.0.dist-info/METADATA +137 -0
- cairnmark-0.1.0.dist-info/RECORD +12 -0
- cairnmark-0.1.0.dist-info/WHEEL +4 -0
- cairnmark-0.1.0.dist-info/licenses/LICENSE +21 -0
cairnmark/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Official Python client for the CairnMark file service.
|
|
2
|
+
|
|
3
|
+
Synchronous::
|
|
4
|
+
|
|
5
|
+
from cairnmark import CairnMark
|
|
6
|
+
|
|
7
|
+
with CairnMark("http://localhost:8080") as cm:
|
|
8
|
+
f = cm.upload(b"hello", filename="hello.txt", metadata={"env": "demo"},
|
|
9
|
+
idempotency_key="auto")
|
|
10
|
+
cm.download_to_file(f.id, "hello-copy.txt") # checksum-verified
|
|
11
|
+
|
|
12
|
+
Asynchronous::
|
|
13
|
+
|
|
14
|
+
from cairnmark import AsyncCairnMark
|
|
15
|
+
|
|
16
|
+
async with AsyncCairnMark("http://localhost:8080") as cm:
|
|
17
|
+
f = await cm.upload(b"hello", filename="hello.txt")
|
|
18
|
+
async for file in cm.iter_files(tags={"env": "demo"}):
|
|
19
|
+
...
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from ._async_client import AsyncCairnMark, AsyncDownload
|
|
23
|
+
from ._client import CairnMark, Download
|
|
24
|
+
from .errors import (
|
|
25
|
+
APIError,
|
|
26
|
+
CairnMarkError,
|
|
27
|
+
ChecksumMismatchError,
|
|
28
|
+
IdempotencyConflictError,
|
|
29
|
+
IdempotencyGoneError,
|
|
30
|
+
InvalidRequestError,
|
|
31
|
+
NotFoundError,
|
|
32
|
+
RangeNotSatisfiableError,
|
|
33
|
+
ServerError,
|
|
34
|
+
TooLargeError,
|
|
35
|
+
)
|
|
36
|
+
from .models import File, ListPage
|
|
37
|
+
from .version import __version__
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"APIError",
|
|
41
|
+
"AsyncCairnMark",
|
|
42
|
+
"AsyncDownload",
|
|
43
|
+
"CairnMark",
|
|
44
|
+
"CairnMarkError",
|
|
45
|
+
"ChecksumMismatchError",
|
|
46
|
+
"Download",
|
|
47
|
+
"File",
|
|
48
|
+
"IdempotencyConflictError",
|
|
49
|
+
"IdempotencyGoneError",
|
|
50
|
+
"InvalidRequestError",
|
|
51
|
+
"ListPage",
|
|
52
|
+
"NotFoundError",
|
|
53
|
+
"RangeNotSatisfiableError",
|
|
54
|
+
"ServerError",
|
|
55
|
+
"TooLargeError",
|
|
56
|
+
"__version__",
|
|
57
|
+
]
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""The asynchronous CairnMark client — the async twin of ``_client.py``.
|
|
2
|
+
|
|
3
|
+
The two are kept in lockstep by hand; the surface is small enough that a
|
|
4
|
+
codegen step would cost more than it saves. Change one, change both.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from typing import Any, AsyncIterator, Callable, Iterator, cast
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from ._http import (
|
|
19
|
+
DEFAULT_RETRIES,
|
|
20
|
+
DEFAULT_TIMEOUT,
|
|
21
|
+
averify_bytes,
|
|
22
|
+
backoff_delay,
|
|
23
|
+
error_from_response,
|
|
24
|
+
list_params,
|
|
25
|
+
range_header,
|
|
26
|
+
resolve_idempotency_key,
|
|
27
|
+
retry_delay,
|
|
28
|
+
rewinder,
|
|
29
|
+
unexpected_status,
|
|
30
|
+
upload_params_headers,
|
|
31
|
+
)
|
|
32
|
+
from .errors import CairnMarkError, ServerError
|
|
33
|
+
from .models import File, ListPage
|
|
34
|
+
from .version import __version__
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AsyncDownload:
|
|
38
|
+
"""An open async download stream plus the file's metadata record.
|
|
39
|
+
|
|
40
|
+
Use as an async context manager (or call ``aclose()``). With verification
|
|
41
|
+
on, ChecksumMismatchError is raised as the last chunk is consumed.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, response: httpx.Response, file: File, verify: bool) -> None:
|
|
45
|
+
self._response = response
|
|
46
|
+
self._verify = verify
|
|
47
|
+
self.file = file
|
|
48
|
+
|
|
49
|
+
def aiter_bytes(self, chunk_size: int | None = None) -> AsyncIterator[bytes]:
|
|
50
|
+
chunks = self._response.aiter_bytes(chunk_size)
|
|
51
|
+
if self._verify and self.file.checksum_sha256:
|
|
52
|
+
return averify_bytes(chunks, self.file.checksum_sha256)
|
|
53
|
+
return chunks
|
|
54
|
+
|
|
55
|
+
async def aread(self) -> bytes:
|
|
56
|
+
"""Consume the whole stream (still verified) into memory."""
|
|
57
|
+
parts = [chunk async for chunk in self.aiter_bytes()]
|
|
58
|
+
return b"".join(parts)
|
|
59
|
+
|
|
60
|
+
async def aclose(self) -> None:
|
|
61
|
+
await self._response.aclose()
|
|
62
|
+
|
|
63
|
+
async def __aenter__(self) -> AsyncDownload:
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
67
|
+
await self.aclose()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AsyncCairnMark:
|
|
71
|
+
"""Asynchronous client for one CairnMark server. See CairnMark for the
|
|
72
|
+
full semantics — the two expose the same operations."""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
base_url: str,
|
|
77
|
+
*,
|
|
78
|
+
headers: dict[str, str] | None = None,
|
|
79
|
+
timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
|
|
80
|
+
retries: int = DEFAULT_RETRIES,
|
|
81
|
+
user_agent: str | None = None,
|
|
82
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
self._retries = retries
|
|
85
|
+
merged = dict(headers or {})
|
|
86
|
+
merged["User-Agent"] = user_agent or f"cairnmark-python/{__version__}"
|
|
87
|
+
self._http = httpx.AsyncClient(
|
|
88
|
+
base_url=base_url.rstrip("/"),
|
|
89
|
+
headers=merged,
|
|
90
|
+
timeout=timeout,
|
|
91
|
+
transport=transport,
|
|
92
|
+
follow_redirects=False,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# -- core request loop -------------------------------------------------
|
|
96
|
+
|
|
97
|
+
async def _request(
|
|
98
|
+
self,
|
|
99
|
+
method: str,
|
|
100
|
+
path: str,
|
|
101
|
+
*,
|
|
102
|
+
params: dict[str, str] | None = None,
|
|
103
|
+
headers: dict[str, str] | None = None,
|
|
104
|
+
content: bytes | None = None,
|
|
105
|
+
follow_redirects: bool = False,
|
|
106
|
+
expect: int = 200,
|
|
107
|
+
) -> httpx.Response:
|
|
108
|
+
attempt = 0
|
|
109
|
+
while True:
|
|
110
|
+
try:
|
|
111
|
+
resp = await self._http.request(
|
|
112
|
+
method,
|
|
113
|
+
path,
|
|
114
|
+
params=params,
|
|
115
|
+
headers=headers,
|
|
116
|
+
content=content,
|
|
117
|
+
follow_redirects=follow_redirects,
|
|
118
|
+
)
|
|
119
|
+
except httpx.TransportError:
|
|
120
|
+
if attempt < self._retries:
|
|
121
|
+
await asyncio.sleep(backoff_delay(attempt))
|
|
122
|
+
attempt += 1
|
|
123
|
+
continue
|
|
124
|
+
raise
|
|
125
|
+
if resp.status_code < 400:
|
|
126
|
+
if resp.status_code != expect:
|
|
127
|
+
raise unexpected_status(resp, expect)
|
|
128
|
+
return resp
|
|
129
|
+
err = error_from_response(resp)
|
|
130
|
+
if isinstance(err, ServerError) and attempt < self._retries:
|
|
131
|
+
await asyncio.sleep(backoff_delay(attempt))
|
|
132
|
+
attempt += 1
|
|
133
|
+
continue
|
|
134
|
+
raise err
|
|
135
|
+
|
|
136
|
+
# -- uploads -------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
async def upload(
|
|
139
|
+
self,
|
|
140
|
+
content: Any,
|
|
141
|
+
*,
|
|
142
|
+
filename: str | None = None,
|
|
143
|
+
content_type: str | None = None,
|
|
144
|
+
size: int | None = None,
|
|
145
|
+
metadata: dict[str, Any] | None = None,
|
|
146
|
+
idempotency_key: str | None = None,
|
|
147
|
+
) -> File:
|
|
148
|
+
"""Stream ``content`` up; see CairnMark.upload for the semantics."""
|
|
149
|
+
key = resolve_idempotency_key(idempotency_key)
|
|
150
|
+
if isinstance(content, (bytearray, memoryview)):
|
|
151
|
+
content = bytes(content) # httpx's typed surface takes bytes
|
|
152
|
+
if isinstance(content, bytes):
|
|
153
|
+
size = None # httpx sets Content-Length for in-memory bodies
|
|
154
|
+
params, headers = upload_params_headers(filename, content_type, metadata, key, size)
|
|
155
|
+
rewind = rewinder(content)
|
|
156
|
+
retryable = key is not None and rewind is not None
|
|
157
|
+
|
|
158
|
+
attempt = 0
|
|
159
|
+
while True:
|
|
160
|
+
if attempt and rewind is not None:
|
|
161
|
+
rewind()
|
|
162
|
+
try:
|
|
163
|
+
# Wrapped fresh per attempt: a rewound file needs a new stream.
|
|
164
|
+
resp = await self._http.post(
|
|
165
|
+
"/files", params=params, headers=headers, content=_as_async_content(content)
|
|
166
|
+
)
|
|
167
|
+
except httpx.TransportError:
|
|
168
|
+
if retryable and attempt < self._retries:
|
|
169
|
+
await asyncio.sleep(backoff_delay(attempt))
|
|
170
|
+
attempt += 1
|
|
171
|
+
continue
|
|
172
|
+
raise
|
|
173
|
+
if resp.status_code == 201:
|
|
174
|
+
return File.from_json(resp.json())
|
|
175
|
+
err = error_from_response(resp)
|
|
176
|
+
if retryable and attempt < self._retries and err.status in (409, *range(500, 600)):
|
|
177
|
+
await asyncio.sleep(retry_delay(err if err.status == 409 else None, attempt))
|
|
178
|
+
attempt += 1
|
|
179
|
+
continue
|
|
180
|
+
raise err
|
|
181
|
+
|
|
182
|
+
async def upload_file(
|
|
183
|
+
self,
|
|
184
|
+
path: str | os.PathLike[str],
|
|
185
|
+
*,
|
|
186
|
+
filename: str | None = None,
|
|
187
|
+
content_type: str | None = None,
|
|
188
|
+
metadata: dict[str, Any] | None = None,
|
|
189
|
+
idempotency_key: str | None = None,
|
|
190
|
+
) -> File:
|
|
191
|
+
"""Upload the file at ``path``; name and size are inferred."""
|
|
192
|
+
with open(path, "rb") as f:
|
|
193
|
+
return await self.upload(
|
|
194
|
+
f,
|
|
195
|
+
filename=filename or os.path.basename(os.fspath(path)),
|
|
196
|
+
content_type=content_type,
|
|
197
|
+
size=os.fstat(f.fileno()).st_size,
|
|
198
|
+
metadata=metadata,
|
|
199
|
+
idempotency_key=idempotency_key,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# -- downloads -----------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
async def download(
|
|
205
|
+
self,
|
|
206
|
+
file_id: str,
|
|
207
|
+
*,
|
|
208
|
+
verify: bool = False,
|
|
209
|
+
offset: int | None = None,
|
|
210
|
+
length: int | None = None,
|
|
211
|
+
) -> AsyncDownload:
|
|
212
|
+
"""Open the file's content; see CairnMark.download for the semantics."""
|
|
213
|
+
ranged = offset is not None or length is not None
|
|
214
|
+
if verify and ranged:
|
|
215
|
+
raise ValueError("verify is incompatible with a range download")
|
|
216
|
+
|
|
217
|
+
file = await self.get_metadata(file_id)
|
|
218
|
+
if verify and not file.checksum_sha256:
|
|
219
|
+
raise CairnMarkError(f"file {file_id} has no stored checksum to verify against")
|
|
220
|
+
|
|
221
|
+
headers = {"Range": range_header(offset or 0, length)} if ranged else None
|
|
222
|
+
expect = 206 if ranged else 200
|
|
223
|
+
attempt = 0
|
|
224
|
+
while True:
|
|
225
|
+
request = self._http.build_request("GET", _file_path(file_id), headers=headers)
|
|
226
|
+
try:
|
|
227
|
+
resp = await self._http.send(request, stream=True, follow_redirects=True)
|
|
228
|
+
except httpx.TransportError:
|
|
229
|
+
if attempt < self._retries:
|
|
230
|
+
await asyncio.sleep(backoff_delay(attempt))
|
|
231
|
+
attempt += 1
|
|
232
|
+
continue
|
|
233
|
+
raise
|
|
234
|
+
if resp.status_code == expect:
|
|
235
|
+
return AsyncDownload(resp, file, verify)
|
|
236
|
+
await resp.aread()
|
|
237
|
+
await resp.aclose()
|
|
238
|
+
err = error_from_response(resp)
|
|
239
|
+
if isinstance(err, ServerError) and attempt < self._retries:
|
|
240
|
+
await asyncio.sleep(backoff_delay(attempt))
|
|
241
|
+
attempt += 1
|
|
242
|
+
continue
|
|
243
|
+
raise err
|
|
244
|
+
|
|
245
|
+
async def download_to_file(
|
|
246
|
+
self, file_id: str, path: str | os.PathLike[str], *, verify: bool = True
|
|
247
|
+
) -> File:
|
|
248
|
+
"""Download into ``path``, checksum-verified; removed on failure."""
|
|
249
|
+
async with await self.download(file_id, verify=verify) as dl:
|
|
250
|
+
try:
|
|
251
|
+
with open(path, "wb") as out:
|
|
252
|
+
async for chunk in dl.aiter_bytes():
|
|
253
|
+
out.write(chunk)
|
|
254
|
+
except BaseException:
|
|
255
|
+
with contextlib.suppress(OSError):
|
|
256
|
+
os.remove(path)
|
|
257
|
+
raise
|
|
258
|
+
return dl.file
|
|
259
|
+
|
|
260
|
+
async def presign_url(self, file_id: str) -> str:
|
|
261
|
+
"""The presigned object-store URL, without following it."""
|
|
262
|
+
resp = await self._request("GET", _file_path(file_id), expect=302)
|
|
263
|
+
location = resp.headers.get("location")
|
|
264
|
+
if not location:
|
|
265
|
+
raise CairnMarkError("presign response has no Location header")
|
|
266
|
+
return location
|
|
267
|
+
|
|
268
|
+
# -- metadata ------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
async def get_metadata(self, file_id: str) -> File:
|
|
271
|
+
"""Fetch the metadata record for ``file_id``."""
|
|
272
|
+
resp = await self._request("GET", _file_path(file_id) + "/metadata")
|
|
273
|
+
return File.from_json(resp.json())
|
|
274
|
+
|
|
275
|
+
async def update_metadata(
|
|
276
|
+
self, file_id: str, tags: dict[str, Any], *, mode: str = "merge"
|
|
277
|
+
) -> File:
|
|
278
|
+
"""Merge (default) or replace (``mode="replace"``) the file's tags."""
|
|
279
|
+
if mode not in ("merge", "replace"):
|
|
280
|
+
raise ValueError(f'mode must be "merge" or "replace", not {mode!r}')
|
|
281
|
+
params = {"mode": "replace"} if mode == "replace" else None
|
|
282
|
+
resp = await self._request(
|
|
283
|
+
"PATCH",
|
|
284
|
+
_file_path(file_id) + "/metadata",
|
|
285
|
+
params=params,
|
|
286
|
+
headers={"Content-Type": "application/json"},
|
|
287
|
+
content=json.dumps(tags).encode(),
|
|
288
|
+
)
|
|
289
|
+
return File.from_json(resp.json())
|
|
290
|
+
|
|
291
|
+
async def delete(self, file_id: str) -> None:
|
|
292
|
+
"""Soft-delete ``file_id``; the stored object is purged asynchronously."""
|
|
293
|
+
await self._request("DELETE", _file_path(file_id), expect=204)
|
|
294
|
+
|
|
295
|
+
# -- listing ---------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
async def list(
|
|
298
|
+
self,
|
|
299
|
+
*,
|
|
300
|
+
content_type: str | None = None,
|
|
301
|
+
tags: dict[str, str] | None = None,
|
|
302
|
+
limit: int | None = None,
|
|
303
|
+
cursor: str | None = None,
|
|
304
|
+
) -> ListPage:
|
|
305
|
+
"""One page of files matching the filter, newest first."""
|
|
306
|
+
resp = await self._request(
|
|
307
|
+
"GET", "/files", params=list_params(content_type, tags, limit, cursor)
|
|
308
|
+
)
|
|
309
|
+
return ListPage.from_json(resp.json())
|
|
310
|
+
|
|
311
|
+
async def iter_files(
|
|
312
|
+
self,
|
|
313
|
+
*,
|
|
314
|
+
content_type: str | None = None,
|
|
315
|
+
tags: dict[str, str] | None = None,
|
|
316
|
+
limit: int | None = None,
|
|
317
|
+
) -> AsyncIterator[File]:
|
|
318
|
+
"""Every file matching the filter, fetching pages lazily."""
|
|
319
|
+
cursor: str | None = None
|
|
320
|
+
while True:
|
|
321
|
+
page = await self.list(content_type=content_type, tags=tags, limit=limit, cursor=cursor)
|
|
322
|
+
for file in page.files:
|
|
323
|
+
yield file
|
|
324
|
+
if not page.next_cursor:
|
|
325
|
+
return
|
|
326
|
+
cursor = page.next_cursor
|
|
327
|
+
|
|
328
|
+
# -- probes / lifecycle ------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
async def health(self) -> None:
|
|
331
|
+
"""Raise unless the server answers 200 on /healthz (liveness)."""
|
|
332
|
+
await self._request("GET", "/healthz")
|
|
333
|
+
|
|
334
|
+
async def ready(self) -> None:
|
|
335
|
+
"""Raise unless /readyz says storage and database are reachable."""
|
|
336
|
+
await self._request("GET", "/readyz")
|
|
337
|
+
|
|
338
|
+
async def aclose(self) -> None:
|
|
339
|
+
await self._http.aclose()
|
|
340
|
+
|
|
341
|
+
async def __aenter__(self) -> AsyncCairnMark:
|
|
342
|
+
return self
|
|
343
|
+
|
|
344
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
345
|
+
await self.aclose()
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _file_path(file_id: str) -> str:
|
|
349
|
+
return "/files/" + quote(file_id, safe="")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _as_async_content(content: Any) -> Any:
|
|
353
|
+
"""Make ``content`` acceptable to httpx.AsyncClient, which refuses sync
|
|
354
|
+
streams. In-memory bytes and async iterables pass through; sync file-likes
|
|
355
|
+
and iterators are wrapped into async generators (their reads still block —
|
|
356
|
+
fine for files, use an async iterable for anything slower)."""
|
|
357
|
+
if isinstance(content, (bytes, bytearray, memoryview)) or hasattr(content, "__aiter__"):
|
|
358
|
+
return content
|
|
359
|
+
read = cast("Callable[[int], bytes] | None", getattr(content, "read", None))
|
|
360
|
+
if callable(read):
|
|
361
|
+
|
|
362
|
+
async def from_file() -> AsyncIterator[bytes]:
|
|
363
|
+
while chunk := read(65536):
|
|
364
|
+
yield chunk
|
|
365
|
+
|
|
366
|
+
return from_file()
|
|
367
|
+
if hasattr(content, "__iter__"):
|
|
368
|
+
iterator = cast("Iterator[bytes]", iter(content))
|
|
369
|
+
|
|
370
|
+
async def from_iter() -> AsyncIterator[bytes]:
|
|
371
|
+
for chunk in iterator:
|
|
372
|
+
yield chunk
|
|
373
|
+
|
|
374
|
+
return from_iter()
|
|
375
|
+
return content
|
cairnmark/_client.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""The synchronous CairnMark client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Iterator
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from ._http import (
|
|
15
|
+
DEFAULT_RETRIES,
|
|
16
|
+
DEFAULT_TIMEOUT,
|
|
17
|
+
backoff_delay,
|
|
18
|
+
error_from_response,
|
|
19
|
+
list_params,
|
|
20
|
+
range_header,
|
|
21
|
+
resolve_idempotency_key,
|
|
22
|
+
retry_delay,
|
|
23
|
+
rewinder,
|
|
24
|
+
unexpected_status,
|
|
25
|
+
upload_params_headers,
|
|
26
|
+
verify_bytes,
|
|
27
|
+
)
|
|
28
|
+
from .errors import CairnMarkError, ServerError
|
|
29
|
+
from .models import File, ListPage
|
|
30
|
+
from .version import __version__
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Download:
|
|
34
|
+
"""An open download stream plus the file's metadata record.
|
|
35
|
+
|
|
36
|
+
Use as a context manager (or call ``close()``). With verification on,
|
|
37
|
+
ChecksumMismatchError is raised as the last chunk is consumed.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, response: httpx.Response, file: File, verify: bool) -> None:
|
|
41
|
+
self._response = response
|
|
42
|
+
self._verify = verify
|
|
43
|
+
self.file = file
|
|
44
|
+
|
|
45
|
+
def iter_bytes(self, chunk_size: int | None = None) -> Iterator[bytes]:
|
|
46
|
+
chunks = self._response.iter_bytes(chunk_size)
|
|
47
|
+
if self._verify and self.file.checksum_sha256:
|
|
48
|
+
return verify_bytes(chunks, self.file.checksum_sha256)
|
|
49
|
+
return chunks
|
|
50
|
+
|
|
51
|
+
def read(self) -> bytes:
|
|
52
|
+
"""Consume the whole stream (still verified) into memory."""
|
|
53
|
+
return b"".join(self.iter_bytes())
|
|
54
|
+
|
|
55
|
+
def close(self) -> None:
|
|
56
|
+
self._response.close()
|
|
57
|
+
|
|
58
|
+
def __enter__(self) -> Download:
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
def __exit__(self, *exc: object) -> None:
|
|
62
|
+
self.close()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CairnMark:
|
|
66
|
+
"""Synchronous client for one CairnMark server.
|
|
67
|
+
|
|
68
|
+
The server has no auth — put it behind your gateway and inject
|
|
69
|
+
credentials via ``headers``. ``timeout`` is httpx's per-operation timeout
|
|
70
|
+
(connect / single read), so it does not cut off large streams.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
base_url: str,
|
|
76
|
+
*,
|
|
77
|
+
headers: dict[str, str] | None = None,
|
|
78
|
+
timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
|
|
79
|
+
retries: int = DEFAULT_RETRIES,
|
|
80
|
+
user_agent: str | None = None,
|
|
81
|
+
transport: httpx.BaseTransport | None = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
self._retries = retries
|
|
84
|
+
merged = dict(headers or {})
|
|
85
|
+
merged["User-Agent"] = user_agent or f"cairnmark-python/{__version__}"
|
|
86
|
+
self._http = httpx.Client(
|
|
87
|
+
base_url=base_url.rstrip("/"),
|
|
88
|
+
headers=merged,
|
|
89
|
+
timeout=timeout,
|
|
90
|
+
transport=transport,
|
|
91
|
+
follow_redirects=False,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# -- core request loop -------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def _request(
|
|
97
|
+
self,
|
|
98
|
+
method: str,
|
|
99
|
+
path: str,
|
|
100
|
+
*,
|
|
101
|
+
params: dict[str, str] | None = None,
|
|
102
|
+
headers: dict[str, str] | None = None,
|
|
103
|
+
content: bytes | None = None,
|
|
104
|
+
follow_redirects: bool = False,
|
|
105
|
+
expect: int = 200,
|
|
106
|
+
) -> httpx.Response:
|
|
107
|
+
"""One small (non-streaming) call, retried on network errors and 5xx."""
|
|
108
|
+
attempt = 0
|
|
109
|
+
while True:
|
|
110
|
+
try:
|
|
111
|
+
resp = self._http.request(
|
|
112
|
+
method,
|
|
113
|
+
path,
|
|
114
|
+
params=params,
|
|
115
|
+
headers=headers,
|
|
116
|
+
content=content,
|
|
117
|
+
follow_redirects=follow_redirects,
|
|
118
|
+
)
|
|
119
|
+
except httpx.TransportError:
|
|
120
|
+
if attempt < self._retries:
|
|
121
|
+
time.sleep(backoff_delay(attempt))
|
|
122
|
+
attempt += 1
|
|
123
|
+
continue
|
|
124
|
+
raise
|
|
125
|
+
if resp.status_code < 400:
|
|
126
|
+
if resp.status_code != expect:
|
|
127
|
+
raise unexpected_status(resp, expect)
|
|
128
|
+
return resp
|
|
129
|
+
err = error_from_response(resp)
|
|
130
|
+
if isinstance(err, ServerError) and attempt < self._retries:
|
|
131
|
+
time.sleep(backoff_delay(attempt))
|
|
132
|
+
attempt += 1
|
|
133
|
+
continue
|
|
134
|
+
raise err
|
|
135
|
+
|
|
136
|
+
# -- uploads -------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def upload(
|
|
139
|
+
self,
|
|
140
|
+
content: Any,
|
|
141
|
+
*,
|
|
142
|
+
filename: str | None = None,
|
|
143
|
+
content_type: str | None = None,
|
|
144
|
+
size: int | None = None,
|
|
145
|
+
metadata: dict[str, Any] | None = None,
|
|
146
|
+
idempotency_key: str | None = None,
|
|
147
|
+
) -> File:
|
|
148
|
+
"""Stream ``content`` (bytes or a binary file-like/iterator) up.
|
|
149
|
+
|
|
150
|
+
``size`` sets Content-Length for file-like bodies whose length you
|
|
151
|
+
know, letting the server reject oversized uploads up front. Pass
|
|
152
|
+
``idempotency_key="auto"`` to get a generated key, which is also what
|
|
153
|
+
makes retries safe: without a key (or with a body that can't be
|
|
154
|
+
rewound) a transient failure is raised, never retried, since a resend
|
|
155
|
+
could duplicate the file.
|
|
156
|
+
"""
|
|
157
|
+
key = resolve_idempotency_key(idempotency_key)
|
|
158
|
+
if isinstance(content, (bytearray, memoryview)):
|
|
159
|
+
content = bytes(content) # httpx's typed surface takes bytes
|
|
160
|
+
if isinstance(content, bytes):
|
|
161
|
+
size = None # httpx sets Content-Length for in-memory bodies
|
|
162
|
+
params, headers = upload_params_headers(filename, content_type, metadata, key, size)
|
|
163
|
+
rewind = rewinder(content)
|
|
164
|
+
retryable = key is not None and rewind is not None
|
|
165
|
+
|
|
166
|
+
attempt = 0
|
|
167
|
+
while True:
|
|
168
|
+
if attempt and rewind is not None:
|
|
169
|
+
rewind()
|
|
170
|
+
try:
|
|
171
|
+
resp = self._http.post("/files", params=params, headers=headers, content=content)
|
|
172
|
+
except httpx.TransportError:
|
|
173
|
+
if retryable and attempt < self._retries:
|
|
174
|
+
time.sleep(backoff_delay(attempt))
|
|
175
|
+
attempt += 1
|
|
176
|
+
continue
|
|
177
|
+
raise
|
|
178
|
+
if resp.status_code == 201:
|
|
179
|
+
return File.from_json(resp.json())
|
|
180
|
+
err = error_from_response(resp)
|
|
181
|
+
if retryable and attempt < self._retries and err.status in (409, *range(500, 600)):
|
|
182
|
+
time.sleep(retry_delay(err if err.status == 409 else None, attempt))
|
|
183
|
+
attempt += 1
|
|
184
|
+
continue
|
|
185
|
+
raise err
|
|
186
|
+
|
|
187
|
+
def upload_file(
|
|
188
|
+
self,
|
|
189
|
+
path: str | os.PathLike[str],
|
|
190
|
+
*,
|
|
191
|
+
filename: str | None = None,
|
|
192
|
+
content_type: str | None = None,
|
|
193
|
+
metadata: dict[str, Any] | None = None,
|
|
194
|
+
idempotency_key: str | None = None,
|
|
195
|
+
) -> File:
|
|
196
|
+
"""Upload the file at ``path``; name and size are inferred."""
|
|
197
|
+
with open(path, "rb") as f:
|
|
198
|
+
return self.upload(
|
|
199
|
+
f,
|
|
200
|
+
filename=filename or os.path.basename(os.fspath(path)),
|
|
201
|
+
content_type=content_type,
|
|
202
|
+
size=os.fstat(f.fileno()).st_size,
|
|
203
|
+
metadata=metadata,
|
|
204
|
+
idempotency_key=idempotency_key,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# -- downloads -----------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def download(
|
|
210
|
+
self,
|
|
211
|
+
file_id: str,
|
|
212
|
+
*,
|
|
213
|
+
verify: bool = False,
|
|
214
|
+
offset: int | None = None,
|
|
215
|
+
length: int | None = None,
|
|
216
|
+
) -> Download:
|
|
217
|
+
"""Open the file's content for reading.
|
|
218
|
+
|
|
219
|
+
Full downloads follow the server's 302 to a presigned object-store
|
|
220
|
+
URL; ``offset``/``length`` request a 206 range through the server.
|
|
221
|
+
``verify`` hashes the stream against the stored SHA-256 (incompatible
|
|
222
|
+
with a range — partial bytes can't match a whole-file hash).
|
|
223
|
+
"""
|
|
224
|
+
ranged = offset is not None or length is not None
|
|
225
|
+
if verify and ranged:
|
|
226
|
+
raise ValueError("verify is incompatible with a range download")
|
|
227
|
+
|
|
228
|
+
# The metadata record supplies the stored checksum for verification
|
|
229
|
+
# and rounds out the result either way.
|
|
230
|
+
file = self.get_metadata(file_id)
|
|
231
|
+
if verify and not file.checksum_sha256:
|
|
232
|
+
raise CairnMarkError(f"file {file_id} has no stored checksum to verify against")
|
|
233
|
+
|
|
234
|
+
headers = {"Range": range_header(offset or 0, length)} if ranged else None
|
|
235
|
+
expect = 206 if ranged else 200
|
|
236
|
+
attempt = 0
|
|
237
|
+
while True:
|
|
238
|
+
request = self._http.build_request("GET", _file_path(file_id), headers=headers)
|
|
239
|
+
try:
|
|
240
|
+
resp = self._http.send(request, stream=True, follow_redirects=True)
|
|
241
|
+
except httpx.TransportError:
|
|
242
|
+
if attempt < self._retries:
|
|
243
|
+
time.sleep(backoff_delay(attempt))
|
|
244
|
+
attempt += 1
|
|
245
|
+
continue
|
|
246
|
+
raise
|
|
247
|
+
if resp.status_code == expect:
|
|
248
|
+
return Download(resp, file, verify)
|
|
249
|
+
resp.read()
|
|
250
|
+
resp.close()
|
|
251
|
+
err = error_from_response(resp)
|
|
252
|
+
if isinstance(err, ServerError) and attempt < self._retries:
|
|
253
|
+
time.sleep(backoff_delay(attempt))
|
|
254
|
+
attempt += 1
|
|
255
|
+
continue
|
|
256
|
+
raise err
|
|
257
|
+
|
|
258
|
+
def download_to_file(
|
|
259
|
+
self, file_id: str, path: str | os.PathLike[str], *, verify: bool = True
|
|
260
|
+
) -> File:
|
|
261
|
+
"""Download into ``path`` (created or truncated), checksum-verified.
|
|
262
|
+
|
|
263
|
+
On any failure — including a checksum mismatch — the partial file is
|
|
264
|
+
removed.
|
|
265
|
+
"""
|
|
266
|
+
with self.download(file_id, verify=verify) as dl:
|
|
267
|
+
try:
|
|
268
|
+
with open(path, "wb") as out:
|
|
269
|
+
for chunk in dl.iter_bytes():
|
|
270
|
+
out.write(chunk)
|
|
271
|
+
except BaseException:
|
|
272
|
+
with contextlib.suppress(OSError):
|
|
273
|
+
os.remove(path)
|
|
274
|
+
raise
|
|
275
|
+
return dl.file
|
|
276
|
+
|
|
277
|
+
def presign_url(self, file_id: str) -> str:
|
|
278
|
+
"""The presigned object-store URL, without following it.
|
|
279
|
+
|
|
280
|
+
Hand it to a browser or another service; it expires after the
|
|
281
|
+
server's configured TTL, and its host is only as reachable as the
|
|
282
|
+
server's CAIRNMARK_S3_PUBLIC_ENDPOINT makes it.
|
|
283
|
+
"""
|
|
284
|
+
resp = self._request("GET", _file_path(file_id), expect=302)
|
|
285
|
+
location = resp.headers.get("location")
|
|
286
|
+
if not location:
|
|
287
|
+
raise CairnMarkError("presign response has no Location header")
|
|
288
|
+
return location
|
|
289
|
+
|
|
290
|
+
# -- metadata ------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
def get_metadata(self, file_id: str) -> File:
|
|
293
|
+
"""Fetch the metadata record for ``file_id``."""
|
|
294
|
+
resp = self._request("GET", _file_path(file_id) + "/metadata")
|
|
295
|
+
return File.from_json(resp.json())
|
|
296
|
+
|
|
297
|
+
def update_metadata(self, file_id: str, tags: dict[str, Any], *, mode: str = "merge") -> File:
|
|
298
|
+
"""Merge (default) or replace (``mode="replace"``) the file's tags."""
|
|
299
|
+
if mode not in ("merge", "replace"):
|
|
300
|
+
raise ValueError(f'mode must be "merge" or "replace", not {mode!r}')
|
|
301
|
+
params = {"mode": "replace"} if mode == "replace" else None
|
|
302
|
+
resp = self._request(
|
|
303
|
+
"PATCH",
|
|
304
|
+
_file_path(file_id) + "/metadata",
|
|
305
|
+
params=params,
|
|
306
|
+
headers={"Content-Type": "application/json"},
|
|
307
|
+
content=json.dumps(tags).encode(),
|
|
308
|
+
)
|
|
309
|
+
return File.from_json(resp.json())
|
|
310
|
+
|
|
311
|
+
def delete(self, file_id: str) -> None:
|
|
312
|
+
"""Soft-delete ``file_id``; the stored object is purged asynchronously."""
|
|
313
|
+
self._request("DELETE", _file_path(file_id), expect=204)
|
|
314
|
+
|
|
315
|
+
# -- listing ---------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
def list(
|
|
318
|
+
self,
|
|
319
|
+
*,
|
|
320
|
+
content_type: str | None = None,
|
|
321
|
+
tags: dict[str, str] | None = None,
|
|
322
|
+
limit: int | None = None,
|
|
323
|
+
cursor: str | None = None,
|
|
324
|
+
) -> ListPage:
|
|
325
|
+
"""One page of files matching the filter, newest first."""
|
|
326
|
+
resp = self._request("GET", "/files", params=list_params(content_type, tags, limit, cursor))
|
|
327
|
+
return ListPage.from_json(resp.json())
|
|
328
|
+
|
|
329
|
+
def iter_files(
|
|
330
|
+
self,
|
|
331
|
+
*,
|
|
332
|
+
content_type: str | None = None,
|
|
333
|
+
tags: dict[str, str] | None = None,
|
|
334
|
+
limit: int | None = None,
|
|
335
|
+
) -> Iterator[File]:
|
|
336
|
+
"""Every file matching the filter, fetching pages lazily."""
|
|
337
|
+
cursor: str | None = None
|
|
338
|
+
while True:
|
|
339
|
+
page = self.list(content_type=content_type, tags=tags, limit=limit, cursor=cursor)
|
|
340
|
+
yield from page.files
|
|
341
|
+
# A page without a cursor is the last (a final empty page is
|
|
342
|
+
# normal when the total is an exact multiple of the page size).
|
|
343
|
+
if not page.next_cursor:
|
|
344
|
+
return
|
|
345
|
+
cursor = page.next_cursor
|
|
346
|
+
|
|
347
|
+
# -- probes / lifecycle ------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
def health(self) -> None:
|
|
350
|
+
"""Raise unless the server answers 200 on /healthz (liveness)."""
|
|
351
|
+
self._request("GET", "/healthz")
|
|
352
|
+
|
|
353
|
+
def ready(self) -> None:
|
|
354
|
+
"""Raise unless /readyz says storage and database are reachable."""
|
|
355
|
+
self._request("GET", "/readyz")
|
|
356
|
+
|
|
357
|
+
def close(self) -> None:
|
|
358
|
+
self._http.close()
|
|
359
|
+
|
|
360
|
+
def __enter__(self) -> CairnMark:
|
|
361
|
+
return self
|
|
362
|
+
|
|
363
|
+
def __exit__(self, *exc: object) -> None:
|
|
364
|
+
self.close()
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _file_path(file_id: str) -> str:
|
|
368
|
+
return "/files/" + quote(file_id, safe="")
|
cairnmark/_http.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Shared request/response plumbing for the sync and async clients.
|
|
2
|
+
|
|
3
|
+
Everything here is pure or synchronous helper logic; the two clients own the
|
|
4
|
+
actual I/O and retry loops so each can sleep its own way.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import random
|
|
12
|
+
import uuid
|
|
13
|
+
from typing import Any, AsyncIterator, Callable, Iterator
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from .errors import APIError, ChecksumMismatchError, error_class_for
|
|
18
|
+
|
|
19
|
+
DEFAULT_RETRIES = 2
|
|
20
|
+
DEFAULT_TIMEOUT = 30.0
|
|
21
|
+
#: Cap on how long a retry waits on a server-supplied Retry-After.
|
|
22
|
+
MAX_RETRY_AFTER = 30.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def backoff_delay(attempt: int) -> float:
|
|
26
|
+
"""Exponential, jittered delay in seconds before retry ``attempt`` (0-based)."""
|
|
27
|
+
d = min(0.25 * (2**attempt), 4.0)
|
|
28
|
+
return d / 2 + random.uniform(0, d / 2)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def retry_delay(err: APIError | None, attempt: int) -> float:
|
|
32
|
+
"""Backoff, except a server-supplied Retry-After (bounded) wins."""
|
|
33
|
+
if err is not None and err.retry_after:
|
|
34
|
+
return min(err.retry_after, MAX_RETRY_AFTER)
|
|
35
|
+
return backoff_delay(attempt)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def error_from_response(response: httpx.Response) -> APIError:
|
|
39
|
+
"""Map a non-2xx response (body already read) to a typed APIError."""
|
|
40
|
+
message = ""
|
|
41
|
+
try:
|
|
42
|
+
body = response.json()
|
|
43
|
+
if isinstance(body, dict):
|
|
44
|
+
message = str(body.get("error") or "")
|
|
45
|
+
except ValueError:
|
|
46
|
+
pass
|
|
47
|
+
if not message:
|
|
48
|
+
message = response.text.strip() or f"HTTP {response.status_code}"
|
|
49
|
+
retry_after = None
|
|
50
|
+
ra = response.headers.get("retry-after", "")
|
|
51
|
+
if ra.isdigit() and int(ra) > 0:
|
|
52
|
+
retry_after = float(ra)
|
|
53
|
+
return error_class_for(response.status_code)(response.status_code, message, retry_after)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def unexpected_status(response: httpx.Response, want: int) -> APIError:
|
|
57
|
+
return APIError(response.status_code, f"unexpected status {response.status_code} (want {want})")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def new_idempotency_key() -> str:
|
|
61
|
+
"""32 hex chars, unique per call — comfortably under the server's 255 cap."""
|
|
62
|
+
return uuid.uuid4().hex
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def resolve_idempotency_key(key: str | None) -> str | None:
|
|
66
|
+
"""The sentinel value "auto" becomes a freshly generated key."""
|
|
67
|
+
return new_idempotency_key() if key == "auto" else key
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def upload_params_headers(
|
|
71
|
+
filename: str | None,
|
|
72
|
+
content_type: str | None,
|
|
73
|
+
metadata: dict[str, Any] | None,
|
|
74
|
+
idempotency_key: str | None,
|
|
75
|
+
size: int | None,
|
|
76
|
+
) -> tuple[dict[str, str], dict[str, str]]:
|
|
77
|
+
"""Query params and headers for POST /files."""
|
|
78
|
+
params: dict[str, str] = {}
|
|
79
|
+
if filename:
|
|
80
|
+
params["filename"] = filename
|
|
81
|
+
headers: dict[str, str] = {}
|
|
82
|
+
if content_type:
|
|
83
|
+
headers["Content-Type"] = content_type
|
|
84
|
+
if metadata:
|
|
85
|
+
# The X-Metadata JSON header carries typed/nested tag values intact.
|
|
86
|
+
headers["X-Metadata"] = json.dumps(metadata, separators=(",", ":"))
|
|
87
|
+
if idempotency_key:
|
|
88
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
89
|
+
if size is not None and size > 0:
|
|
90
|
+
headers["Content-Length"] = str(size)
|
|
91
|
+
return params, headers
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def list_params(
|
|
95
|
+
content_type: str | None,
|
|
96
|
+
tags: dict[str, str] | None,
|
|
97
|
+
limit: int | None,
|
|
98
|
+
cursor: str | None,
|
|
99
|
+
) -> dict[str, str]:
|
|
100
|
+
"""Query params for GET /files."""
|
|
101
|
+
params: dict[str, str] = {}
|
|
102
|
+
if content_type:
|
|
103
|
+
params["content_type"] = content_type
|
|
104
|
+
for key, value in (tags or {}).items():
|
|
105
|
+
params[f"tag.{key}"] = value
|
|
106
|
+
if limit:
|
|
107
|
+
params["limit"] = str(limit)
|
|
108
|
+
if cursor:
|
|
109
|
+
params["cursor"] = cursor
|
|
110
|
+
return params
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def range_header(offset: int, length: int | None) -> str:
|
|
114
|
+
"""A bytes= header for offset/length (length None means "to the end")."""
|
|
115
|
+
if length is not None and length > 0:
|
|
116
|
+
return f"bytes={offset}-{offset + length - 1}"
|
|
117
|
+
return f"bytes={offset}-"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def rewinder(content: Any) -> Callable[[], None] | None:
|
|
121
|
+
"""A callable that resets ``content`` for a resend, or None if it can't be.
|
|
122
|
+
|
|
123
|
+
Retries must resend the body; bytes are trivially reusable, seekable
|
|
124
|
+
file-likes are rewound to their current position, and anything else
|
|
125
|
+
(a one-shot iterator) disables retries rather than resending garbage.
|
|
126
|
+
"""
|
|
127
|
+
if isinstance(content, (bytes, bytearray, memoryview)):
|
|
128
|
+
return lambda: None
|
|
129
|
+
seek = getattr(content, "seek", None)
|
|
130
|
+
tell = getattr(content, "tell", None)
|
|
131
|
+
seekable = getattr(content, "seekable", None)
|
|
132
|
+
if callable(seek) and callable(tell) and (seekable is None or seekable()):
|
|
133
|
+
pos = content.tell()
|
|
134
|
+
return lambda: content.seek(pos)
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def verify_bytes(chunks: Iterator[bytes], expected: str) -> Iterator[bytes]:
|
|
139
|
+
"""Pass chunks through, raising ChecksumMismatchError after the last one.
|
|
140
|
+
|
|
141
|
+
The presigned object-store response carries no checksum header, so this
|
|
142
|
+
client-side hash is the only integrity signal on an offloaded download.
|
|
143
|
+
"""
|
|
144
|
+
digest = hashlib.sha256()
|
|
145
|
+
for chunk in chunks:
|
|
146
|
+
digest.update(chunk)
|
|
147
|
+
yield chunk
|
|
148
|
+
got = digest.hexdigest()
|
|
149
|
+
if got != expected.lower():
|
|
150
|
+
raise ChecksumMismatchError(got, expected)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def averify_bytes(chunks: AsyncIterator[bytes], expected: str) -> AsyncIterator[bytes]:
|
|
154
|
+
"""Async twin of verify_bytes."""
|
|
155
|
+
digest = hashlib.sha256()
|
|
156
|
+
async for chunk in chunks:
|
|
157
|
+
digest.update(chunk)
|
|
158
|
+
yield chunk
|
|
159
|
+
got = digest.hexdigest()
|
|
160
|
+
if got != expected.lower():
|
|
161
|
+
raise ChecksumMismatchError(got, expected)
|
cairnmark/errors.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Typed errors for the CairnMark API's client-facing failure modes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CairnMarkError(Exception):
|
|
7
|
+
"""Base class for every error this SDK raises deliberately."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ChecksumMismatchError(CairnMarkError):
|
|
11
|
+
"""A verified download's bytes did not hash to the stored SHA-256.
|
|
12
|
+
|
|
13
|
+
Raised from the stream once it is fully consumed — never by the server.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, got: str, expected: str) -> None:
|
|
17
|
+
super().__init__(f"cairnmark: downloaded sha256 {got}, stored {expected}")
|
|
18
|
+
self.got = got
|
|
19
|
+
self.expected = expected
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class APIError(CairnMarkError):
|
|
23
|
+
"""A non-2xx response from the server."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, status: int, message: str, retry_after: float | None = None) -> None:
|
|
26
|
+
super().__init__(f"cairnmark: server returned {status}: {message}")
|
|
27
|
+
self.status = status
|
|
28
|
+
self.message = message
|
|
29
|
+
#: Seconds the server asked to wait (409 idempotency conflicts); else None.
|
|
30
|
+
self.retry_after = retry_after
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InvalidRequestError(APIError):
|
|
34
|
+
"""400: malformed request — bad id, bad parameters, bad metadata JSON."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NotFoundError(APIError):
|
|
38
|
+
"""404: the file id does not exist (or was soft-deleted)."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class IdempotencyConflictError(APIError):
|
|
42
|
+
"""409: an upload with the same Idempotency-Key is still in flight.
|
|
43
|
+
|
|
44
|
+
``retry_after`` says when to ask again.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class IdempotencyGoneError(APIError):
|
|
49
|
+
"""410: the file created under this Idempotency-Key was deleted.
|
|
50
|
+
|
|
51
|
+
Retrying the same key can never succeed — switch to a new key.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TooLargeError(APIError):
|
|
56
|
+
"""413: the body exceeds a server cap (upload size or metadata patch)."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RangeNotSatisfiableError(APIError):
|
|
60
|
+
"""416: the requested byte range lies outside the file."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ServerError(APIError):
|
|
64
|
+
"""5xx: the server failed; the message is generic by design."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_BY_STATUS: dict[int, type[APIError]] = {
|
|
68
|
+
400: InvalidRequestError,
|
|
69
|
+
404: NotFoundError,
|
|
70
|
+
409: IdempotencyConflictError,
|
|
71
|
+
410: IdempotencyGoneError,
|
|
72
|
+
413: TooLargeError,
|
|
73
|
+
416: RangeNotSatisfiableError,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def error_class_for(status: int) -> type[APIError]:
|
|
78
|
+
"""The APIError subclass for an HTTP status."""
|
|
79
|
+
cls = _BY_STATUS.get(status)
|
|
80
|
+
if cls is not None:
|
|
81
|
+
return cls
|
|
82
|
+
return ServerError if status >= 500 else APIError
|
cairnmark/models.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Public data models returned by the CairnMark API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _parse_time(value: str) -> datetime:
|
|
11
|
+
# The server emits RFC 3339 with a Z suffix; fromisoformat on Python 3.10
|
|
12
|
+
# only accepts a numeric offset.
|
|
13
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class File:
|
|
18
|
+
"""A stored file's metadata record. Files are addressed by ``id`` only."""
|
|
19
|
+
|
|
20
|
+
id: str
|
|
21
|
+
filename: str
|
|
22
|
+
content_type: str
|
|
23
|
+
size_bytes: int
|
|
24
|
+
#: Hex SHA-256 computed by the server during upload; None if absent.
|
|
25
|
+
checksum_sha256: str | None
|
|
26
|
+
#: The queryable tag set (arbitrary JSON values).
|
|
27
|
+
metadata: dict[str, Any]
|
|
28
|
+
created_at: datetime
|
|
29
|
+
#: None until the file's tags are first changed — a quick way to tell an
|
|
30
|
+
#: untouched original from an edited record.
|
|
31
|
+
updated_at: datetime | None
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_json(cls, data: dict[str, Any]) -> File:
|
|
35
|
+
updated = data.get("updated_at")
|
|
36
|
+
return cls(
|
|
37
|
+
id=data["id"],
|
|
38
|
+
filename=data["filename"],
|
|
39
|
+
content_type=data["content_type"],
|
|
40
|
+
size_bytes=data["size_bytes"],
|
|
41
|
+
checksum_sha256=data.get("checksum_sha256"),
|
|
42
|
+
metadata=data.get("metadata") or {},
|
|
43
|
+
created_at=_parse_time(data["created_at"]),
|
|
44
|
+
updated_at=_parse_time(updated) if updated else None,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ListPage:
|
|
50
|
+
"""One page of list results.
|
|
51
|
+
|
|
52
|
+
A non-None ``next_cursor`` means more may follow; its absence is the
|
|
53
|
+
definitive end-of-list signal.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
files: list[File]
|
|
57
|
+
limit: int
|
|
58
|
+
count: int
|
|
59
|
+
next_cursor: str | None
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_json(cls, data: dict[str, Any]) -> ListPage:
|
|
63
|
+
return cls(
|
|
64
|
+
files=[File.from_json(f) for f in data.get("files") or []],
|
|
65
|
+
limit=data["limit"],
|
|
66
|
+
count=data["count"],
|
|
67
|
+
next_cursor=data.get("next_cursor"),
|
|
68
|
+
)
|
cairnmark/py.typed
ADDED
|
File without changes
|
cairnmark/version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cairnmark
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the CairnMark file service
|
|
5
|
+
Project-URL: Homepage, https://github.com/mettjs/cairnmark-python
|
|
6
|
+
Author: Michael Ramirez
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: httpx>=0.27
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# cairnmark-python
|
|
18
|
+
|
|
19
|
+
The official Python client for [CairnMark](https://github.com/mettjs/cairnmark) —
|
|
20
|
+
a self-hostable file service over S3-compatible storage with a queryable
|
|
21
|
+
Postgres metadata layer.
|
|
22
|
+
|
|
23
|
+
Sync **and** async clients, streaming uploads/downloads, client-side checksum
|
|
24
|
+
verification, typed errors, safe retries, lazy pagination. One dependency
|
|
25
|
+
(`httpx`). Python ≥ 3.10, fully typed (`py.typed`). Requires a CairnMark
|
|
26
|
+
server ≥ v1.1.0 (cursor pagination and 409 `Retry-After`).
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pip install cairnmark # not yet on PyPI — until then: pip install <path to this repo>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quickstart
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from cairnmark import CairnMark
|
|
36
|
+
|
|
37
|
+
with CairnMark("http://localhost:8080") as cm:
|
|
38
|
+
# Upload with tags; idempotency_key="auto" makes retries duplicate-safe.
|
|
39
|
+
f = cm.upload(
|
|
40
|
+
b"hello from Python",
|
|
41
|
+
filename="hello.txt",
|
|
42
|
+
content_type="text/plain",
|
|
43
|
+
metadata={"env": "demo"},
|
|
44
|
+
idempotency_key="auto",
|
|
45
|
+
)
|
|
46
|
+
print("uploaded:", f.id)
|
|
47
|
+
|
|
48
|
+
# Download — follows the presign redirect and verifies the SHA-256.
|
|
49
|
+
cm.download_to_file(f.id, "hello-copy.txt")
|
|
50
|
+
|
|
51
|
+
# Search by tag, lazily across pages.
|
|
52
|
+
for file in cm.iter_files(tags={"env": "demo"}):
|
|
53
|
+
print("found:", file.id, file.filename)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Async is the same surface with `await` (and `aiter_bytes`/`aread`/`aclose` on
|
|
57
|
+
downloads):
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from cairnmark import AsyncCairnMark
|
|
61
|
+
|
|
62
|
+
async with AsyncCairnMark("http://localhost:8080") as cm:
|
|
63
|
+
f = await cm.upload(b"hello", filename="hello.txt")
|
|
64
|
+
async with await cm.download(f.id, verify=True) as dl:
|
|
65
|
+
data = await dl.aread()
|
|
66
|
+
async for file in cm.iter_files(tags={"env": "demo"}):
|
|
67
|
+
...
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Methods
|
|
71
|
+
|
|
72
|
+
| Method | Does |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `upload(content, *, filename, content_type, size, metadata, idempotency_key)` | Stream bytes / a file-like / an iterator up. `idempotency_key="auto"` generates a key. |
|
|
75
|
+
| `upload_file(path, ...)` | Upload from disk; name and size inferred. |
|
|
76
|
+
| `download(id, *, verify, offset, length)` | Open a content stream (context manager). Default follows the presign redirect; `offset`/`length` for a 206 range; `verify` for checksum checking. |
|
|
77
|
+
| `download_to_file(id, path)` | Download to disk, checksum-verified; removes the file on failure. |
|
|
78
|
+
| `presign_url(id)` | Mint the presigned object-store URL without following it. |
|
|
79
|
+
| `get_metadata(id)` | Fetch the file record (a frozen `File` dataclass). |
|
|
80
|
+
| `update_metadata(id, tags, mode="merge"\|"replace")` | Patch tags. |
|
|
81
|
+
| `delete(id)` | Soft-delete. |
|
|
82
|
+
| `list(...)` / `iter_files(...)` | One page / lazy iteration over every match. |
|
|
83
|
+
| `health()` / `ready()` | Liveness / readiness probes (raise on failure). |
|
|
84
|
+
|
|
85
|
+
## Configuration
|
|
86
|
+
|
|
87
|
+
`CairnMark(base_url, ...)` / `AsyncCairnMark(base_url, ...)` keyword options:
|
|
88
|
+
|
|
89
|
+
| Option | Default | Does |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| `headers` | `None` | Default headers on every request; the hook for gateway credentials. |
|
|
92
|
+
| `timeout` | `30.0` | httpx per-operation timeout (connect / single socket read) — a `float` or an `httpx.Timeout` for fine-grained control. Doesn't cut off large streams. |
|
|
93
|
+
| `retries` | `2` | Retries after a network error or 5xx (so up to `retries + 1` attempts). `0` disables. |
|
|
94
|
+
| `user_agent` | `cairnmark-python/<version>` | Override the `User-Agent`. |
|
|
95
|
+
| `transport` | `None` | Custom `httpx.BaseTransport` (`AsyncBaseTransport` for async) — proxies, mocking, UDS. |
|
|
96
|
+
|
|
97
|
+
## Errors
|
|
98
|
+
|
|
99
|
+
Every non-2xx response raises a subclass of `APIError` (which carries
|
|
100
|
+
`.status`, `.message`, and `.retry_after` on 409), itself a `CairnMarkError`:
|
|
101
|
+
|
|
102
|
+
`InvalidRequestError` (400) · `NotFoundError` (404) ·
|
|
103
|
+
`IdempotencyConflictError` (409) · `IdempotencyGoneError` (410) ·
|
|
104
|
+
`TooLargeError` (413) · `RangeNotSatisfiableError` (416) · `ServerError` (5xx)
|
|
105
|
+
— plus `ChecksumMismatchError` from verified download streams.
|
|
106
|
+
|
|
107
|
+
## Semantics worth knowing
|
|
108
|
+
|
|
109
|
+
- **Retries.** Network errors and 5xx are retried with jittered backoff
|
|
110
|
+
(default 2 retries; `retries=` to change). Uploads retry **only** when they
|
|
111
|
+
carry an idempotency key *and* the body can be rewound (bytes or a seekable
|
|
112
|
+
file) — otherwise a retry could duplicate the file. A 409 (same key still in
|
|
113
|
+
flight) waits out the server's `Retry-After`.
|
|
114
|
+
- **Checksum verification.** The presigned object-store response carries no
|
|
115
|
+
checksum header, so `verify=True` hashes the stream client-side against the
|
|
116
|
+
stored SHA-256 (fetched from metadata) and raises `ChecksumMismatchError` as
|
|
117
|
+
the last chunk is consumed. Range downloads can't be verified.
|
|
118
|
+
- **Timeouts.** `timeout=` is httpx's per-operation timeout (connect, single
|
|
119
|
+
socket read), so large streams aren't cut off mid-transfer.
|
|
120
|
+
- **Auth.** The server has none; put it behind your gateway and inject
|
|
121
|
+
credentials with `headers=` (or a custom `transport=`).
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
uv sync
|
|
127
|
+
uv run pytest # unit tests (respx-mocked, no server needed)
|
|
128
|
+
uv run ruff check src tests && uv run pyright src
|
|
129
|
+
|
|
130
|
+
# integration: full round-trip against a live server
|
|
131
|
+
(cd ../CairnMark && docker compose up -d --build)
|
|
132
|
+
uv run pytest -m integration # honors CAIRNMARK_BASE_URL, default localhost:8080
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
[MIT](LICENSE) © 2026 Michael Ramirez
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
cairnmark/__init__.py,sha256=Z7RGVC8sWOvOEvTOEpNWZ9W7Ujlhmw5cTXbks5NL4Is,1429
|
|
2
|
+
cairnmark/_async_client.py,sha256=Y_D35YexjjWHxIe4_UW8hsme0XP--1WRXTlHZpCtUWE,13567
|
|
3
|
+
cairnmark/_client.py,sha256=UzgIcjBJCevIcPmrRkFJcMf73jmEt5xWv7OLtUIek4w,13240
|
|
4
|
+
cairnmark/_http.py,sha256=2EP3y-WANaUh0ERRF-1uYI4Als2nEmVGy6Rk-YcNvbg,5426
|
|
5
|
+
cairnmark/errors.py,sha256=8e1-1ZVD7Ih287CnkPKggAR1ZxGBMmuU_e1-hv7rNcQ,2396
|
|
6
|
+
cairnmark/models.py,sha256=NogldgdFqMZZoi62osP8cuGnxPwx66-JJiOtXOPU3sQ,2058
|
|
7
|
+
cairnmark/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
cairnmark/version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
9
|
+
cairnmark-0.1.0.dist-info/METADATA,sha256=2YDQGoyYGcNkqZUTZqN7Ga7j-PIhZI17YvQdG_UcfJM,5687
|
|
10
|
+
cairnmark-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
11
|
+
cairnmark-0.1.0.dist-info/licenses/LICENSE,sha256=BfU8cCXBUlCgAkGfpDCRmlOCMCAnq-dKHWDbExV1U5M,1072
|
|
12
|
+
cairnmark-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Ramirez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|