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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.