cairnmark 0.1.0__tar.gz

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.
@@ -0,0 +1,40 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - run: uv sync
20
+ - run: uv run ruff check src tests
21
+ - run: uv run ruff format --check src tests
22
+ - run: uv run pyright src
23
+ - run: uv run pytest
24
+
25
+ integration:
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - uses: actions/checkout@v4
30
+ with:
31
+ repository: mettjs/cairnmark
32
+ path: server
33
+ - name: Start the CairnMark stack
34
+ run: docker compose up -d --build --wait
35
+ working-directory: server
36
+ - uses: astral-sh/setup-uv@v5
37
+ with:
38
+ python-version: "3.13"
39
+ - run: uv sync
40
+ - run: uv run pytest -m integration
@@ -0,0 +1,33 @@
1
+ name: Release
2
+
3
+ # Publishes to PyPI via trusted publishing (OIDC) — configure this repo as a
4
+ # trusted publisher on pypi.org (workflow: release.yml, environment: pypi)
5
+ # before the first tag; no token secrets needed.
6
+ on:
7
+ push:
8
+ tags: ["v*"]
9
+
10
+ jobs:
11
+ build:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: astral-sh/setup-uv@v5
16
+ - run: uv build
17
+ - uses: actions/upload-artifact@v4
18
+ with:
19
+ name: dist
20
+ path: dist/
21
+
22
+ publish:
23
+ needs: build
24
+ runs-on: ubuntu-latest
25
+ environment: pypi
26
+ permissions:
27
+ id-token: write
28
+ steps:
29
+ - uses: actions/download-artifact@v4
30
+ with:
31
+ name: dist
32
+ path: dist/
33
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ .venv/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .DS_Store
@@ -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.
@@ -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,121 @@
1
+ # cairnmark-python
2
+
3
+ The official Python client for [CairnMark](https://github.com/mettjs/cairnmark) —
4
+ a self-hostable file service over S3-compatible storage with a queryable
5
+ Postgres metadata layer.
6
+
7
+ Sync **and** async clients, streaming uploads/downloads, client-side checksum
8
+ verification, typed errors, safe retries, lazy pagination. One dependency
9
+ (`httpx`). Python ≥ 3.10, fully typed (`py.typed`). Requires a CairnMark
10
+ server ≥ v1.1.0 (cursor pagination and 409 `Retry-After`).
11
+
12
+ ```sh
13
+ pip install cairnmark # not yet on PyPI — until then: pip install <path to this repo>
14
+ ```
15
+
16
+ ## Quickstart
17
+
18
+ ```python
19
+ from cairnmark import CairnMark
20
+
21
+ with CairnMark("http://localhost:8080") as cm:
22
+ # Upload with tags; idempotency_key="auto" makes retries duplicate-safe.
23
+ f = cm.upload(
24
+ b"hello from Python",
25
+ filename="hello.txt",
26
+ content_type="text/plain",
27
+ metadata={"env": "demo"},
28
+ idempotency_key="auto",
29
+ )
30
+ print("uploaded:", f.id)
31
+
32
+ # Download — follows the presign redirect and verifies the SHA-256.
33
+ cm.download_to_file(f.id, "hello-copy.txt")
34
+
35
+ # Search by tag, lazily across pages.
36
+ for file in cm.iter_files(tags={"env": "demo"}):
37
+ print("found:", file.id, file.filename)
38
+ ```
39
+
40
+ Async is the same surface with `await` (and `aiter_bytes`/`aread`/`aclose` on
41
+ downloads):
42
+
43
+ ```python
44
+ from cairnmark import AsyncCairnMark
45
+
46
+ async with AsyncCairnMark("http://localhost:8080") as cm:
47
+ f = await cm.upload(b"hello", filename="hello.txt")
48
+ async with await cm.download(f.id, verify=True) as dl:
49
+ data = await dl.aread()
50
+ async for file in cm.iter_files(tags={"env": "demo"}):
51
+ ...
52
+ ```
53
+
54
+ ## Methods
55
+
56
+ | Method | Does |
57
+ |---|---|
58
+ | `upload(content, *, filename, content_type, size, metadata, idempotency_key)` | Stream bytes / a file-like / an iterator up. `idempotency_key="auto"` generates a key. |
59
+ | `upload_file(path, ...)` | Upload from disk; name and size inferred. |
60
+ | `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. |
61
+ | `download_to_file(id, path)` | Download to disk, checksum-verified; removes the file on failure. |
62
+ | `presign_url(id)` | Mint the presigned object-store URL without following it. |
63
+ | `get_metadata(id)` | Fetch the file record (a frozen `File` dataclass). |
64
+ | `update_metadata(id, tags, mode="merge"\|"replace")` | Patch tags. |
65
+ | `delete(id)` | Soft-delete. |
66
+ | `list(...)` / `iter_files(...)` | One page / lazy iteration over every match. |
67
+ | `health()` / `ready()` | Liveness / readiness probes (raise on failure). |
68
+
69
+ ## Configuration
70
+
71
+ `CairnMark(base_url, ...)` / `AsyncCairnMark(base_url, ...)` keyword options:
72
+
73
+ | Option | Default | Does |
74
+ |---|---|---|
75
+ | `headers` | `None` | Default headers on every request; the hook for gateway credentials. |
76
+ | `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. |
77
+ | `retries` | `2` | Retries after a network error or 5xx (so up to `retries + 1` attempts). `0` disables. |
78
+ | `user_agent` | `cairnmark-python/<version>` | Override the `User-Agent`. |
79
+ | `transport` | `None` | Custom `httpx.BaseTransport` (`AsyncBaseTransport` for async) — proxies, mocking, UDS. |
80
+
81
+ ## Errors
82
+
83
+ Every non-2xx response raises a subclass of `APIError` (which carries
84
+ `.status`, `.message`, and `.retry_after` on 409), itself a `CairnMarkError`:
85
+
86
+ `InvalidRequestError` (400) · `NotFoundError` (404) ·
87
+ `IdempotencyConflictError` (409) · `IdempotencyGoneError` (410) ·
88
+ `TooLargeError` (413) · `RangeNotSatisfiableError` (416) · `ServerError` (5xx)
89
+ — plus `ChecksumMismatchError` from verified download streams.
90
+
91
+ ## Semantics worth knowing
92
+
93
+ - **Retries.** Network errors and 5xx are retried with jittered backoff
94
+ (default 2 retries; `retries=` to change). Uploads retry **only** when they
95
+ carry an idempotency key *and* the body can be rewound (bytes or a seekable
96
+ file) — otherwise a retry could duplicate the file. A 409 (same key still in
97
+ flight) waits out the server's `Retry-After`.
98
+ - **Checksum verification.** The presigned object-store response carries no
99
+ checksum header, so `verify=True` hashes the stream client-side against the
100
+ stored SHA-256 (fetched from metadata) and raises `ChecksumMismatchError` as
101
+ the last chunk is consumed. Range downloads can't be verified.
102
+ - **Timeouts.** `timeout=` is httpx's per-operation timeout (connect, single
103
+ socket read), so large streams aren't cut off mid-transfer.
104
+ - **Auth.** The server has none; put it behind your gateway and inject
105
+ credentials with `headers=` (or a custom `transport=`).
106
+
107
+ ## Development
108
+
109
+ ```sh
110
+ uv sync
111
+ uv run pytest # unit tests (respx-mocked, no server needed)
112
+ uv run ruff check src tests && uv run pyright src
113
+
114
+ # integration: full round-trip against a live server
115
+ (cd ../CairnMark && docker compose up -d --build)
116
+ uv run pytest -m integration # honors CAIRNMARK_BASE_URL, default localhost:8080
117
+ ```
118
+
119
+ ## License
120
+
121
+ [MIT](LICENSE) © 2026 Michael Ramirez
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cairnmark"
7
+ version = "0.1.0"
8
+ description = "Official Python client for the CairnMark file service"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ authors = [{ name = "Michael Ramirez" }]
12
+ requires-python = ">=3.10"
13
+ dependencies = ["httpx>=0.27"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Typing :: Typed",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/mettjs/cairnmark-python"
23
+
24
+ [dependency-groups]
25
+ dev = [
26
+ "pytest>=8",
27
+ "pytest-asyncio>=0.24",
28
+ "respx>=0.21",
29
+ "ruff>=0.6",
30
+ "pyright>=1.1",
31
+ ]
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/cairnmark"]
35
+
36
+ [tool.ruff]
37
+ line-length = 100
38
+ target-version = "py310"
39
+
40
+ [tool.pytest.ini_options]
41
+ markers = ["integration: requires a live CairnMark server"]
42
+ addopts = "-m 'not integration'"
43
+ asyncio_mode = "auto"
@@ -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
+ ]