Skip to content

SDK API Reference

Auto-generated from source code docstrings.

rahcp-client

HCPClient

HCPClient(endpoint='http://localhost:8000/api/v1', username='', password='', tenant=None, *, timeout=30.0, max_retries=4, retry_base_delay=1.0, multipart_threshold=100 * 1024 * 1024, multipart_chunk=64 * 1024 * 1024, multipart_concurrency=6, verify_ssl=True)

Async HTTP client for the HCP Unified API.

Usage::

async with HCPClient.from_env() as hcp:
    buckets = await hcp.s3.list_objects("bucket", "prefix/")
    ns = await hcp.mapi.list_namespaces("tenant")

Initialize the HCP client.

Parameters:

Name Type Description Default
endpoint str

Base URL for the HCP Unified API.

'http://localhost:8000/api/v1'
username str

Username for authentication.

''
password str

Password for authentication.

''
tenant str | None

HCP tenant name (routes requests to the correct tenant).

None
timeout float

HTTP request timeout in seconds.

30.0
max_retries int

Maximum number of retry attempts for transient errors.

4
retry_base_delay float

Base delay between retries in seconds (doubles each attempt).

1.0
multipart_threshold int

File size in bytes above which multipart upload is used.

100 * 1024 * 1024
multipart_chunk int

Part size in bytes for multipart uploads.

64 * 1024 * 1024
verify_ssl bool

Whether to verify TLS certificates.

True
Source code in packages/rahcp-client/src/rahcp_client/client.py
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
def __init__(
    self,
    endpoint: str = "http://localhost:8000/api/v1",
    username: str = "",
    password: str = "",
    tenant: str | None = None,
    *,
    timeout: float = 30.0,
    max_retries: int = 4,
    retry_base_delay: float = 1.0,
    multipart_threshold: int = 100 * 1024 * 1024,
    multipart_chunk: int = 64 * 1024 * 1024,
    multipart_concurrency: int = 6,
    verify_ssl: bool = True,
) -> None:
    """Initialize the HCP client.

    Args:
        endpoint: Base URL for the HCP Unified API.
        username: Username for authentication.
        password: Password for authentication.
        tenant: HCP tenant name (routes requests to the correct tenant).
        timeout: HTTP request timeout in seconds.
        max_retries: Maximum number of retry attempts for transient errors.
        retry_base_delay: Base delay between retries in seconds (doubles each attempt).
        multipart_threshold: File size in bytes above which multipart upload is used.
        multipart_chunk: Part size in bytes for multipart uploads.
        verify_ssl: Whether to verify TLS certificates.
    """
    self.endpoint = endpoint.rstrip("/")
    self.username = username
    self.password = password
    self.tenant = tenant
    self.timeout = timeout
    self.max_retries = max_retries
    self.retry_base_delay = retry_base_delay
    self.multipart_threshold = multipart_threshold
    self.multipart_chunk = multipart_chunk
    self.multipart_concurrency = multipart_concurrency
    self.verify_ssl = verify_ssl

    self._http = httpx.AsyncClient(
        base_url=self.endpoint,
        timeout=timeout,
        verify=verify_ssl,
    )
    self._token: str | None = None
    self._s3: S3Ops | None = None
    self._mapi: MapiOps | None = None

token property

The current bearer token (set after login).

s3 property

S3 data-plane operations.

mapi property

MAPI management-plane operations.

transfer_settings property

Settings needed by the bulk transfer engine.

from_env() classmethod

Create a client configured from HCP_* environment variables.

Source code in packages/rahcp-client/src/rahcp_client/client.py
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
@classmethod
def from_env(cls) -> HCPClient:
    """Create a client configured from ``HCP_*`` environment variables."""
    settings = HCPSettings()
    return cls(
        endpoint=settings.endpoint,
        username=settings.username,
        password=settings.password,
        tenant=settings.tenant,
        timeout=settings.timeout,
        max_retries=settings.max_retries,
        retry_base_delay=settings.retry_base_delay,
        multipart_threshold=settings.multipart_threshold,
        multipart_chunk=settings.multipart_chunk,
        multipart_concurrency=settings.multipart_concurrency,
        verify_ssl=settings.verify_ssl,
    )

request(method, path, *, params=None, json=None, data=None, content=None, headers=None) async

Send an HTTP request with retry, tracing, and automatic 401 refresh.

Source code in packages/rahcp-client/src/rahcp_client/client.py
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
async def request(
    self,
    method: str,
    path: str,
    *,
    params: dict[str, Any] | None = None,
    json: Any = None,
    data: Any = None,
    content: bytes | None = None,
    headers: dict[str, str] | None = None,
) -> httpx.Response:
    """Send an HTTP request with retry, tracing, and automatic 401 refresh."""
    merged_headers = {**self._auth_headers(), **(headers or {})}
    refreshed = False

    with tracer.start_as_current_span(f"{method} {path}") as span:
        span.set_attribute("http.method", method)
        span.set_attribute("http.path", path)

        async for attempt in AsyncRetrying(
            stop=stop_after_attempt(self.max_retries + 1),
            wait=wait_exponential_jitter(
                initial=self.retry_base_delay,
                max=60,
                jitter=self.retry_base_delay,
            ),
            retry=retry_if_exception_type(RetryableError),
            reraise=True,
        ):
            with attempt:
                t0 = time.monotonic()
                try:
                    response = await self._http.request(
                        method,
                        path,
                        params=params,
                        json=json,
                        data=data,
                        content=content,
                        headers=merged_headers,
                    )
                except httpx.TransportError as exc:
                    duration = (time.monotonic() - t0) * 1000
                    log.warning(
                        "%s %s — transport error after %.0fms: %s",
                        method,
                        path,
                        duration,
                        exc,
                    )
                    raise RetryableError(str(exc)) from exc

                duration = (time.monotonic() - t0) * 1000
                span.set_attribute("http.status_code", response.status_code)

                log.debug(
                    "%s %s%d (%.0fms)",
                    method,
                    path,
                    response.status_code,
                    duration,
                )

                # One-time token refresh on 401
                if response.status_code == 401 and self.username and not refreshed:
                    log.info("Token expired, refreshing...")
                    await self._login()
                    merged_headers = {**self._auth_headers(), **(headers or {})}
                    refreshed = True
                    raise RetryableError("token refresh")

                if response.status_code in _RETRYABLE_STATUSES:
                    raise error_for_status(
                        response.status_code, _redact(response.text)
                    )

                if response.status_code >= 400:
                    safe_body = _redact(response.text)
                    log_fn = log.debug if response.status_code == 404 else log.error
                    log_fn(
                        "%s %s%d (%.0fms): %s",
                        method,
                        path,
                        response.status_code,
                        duration,
                        safe_body,
                    )
                    raise error_for_status(response.status_code, safe_body)

                return response

    raise RetryableError("All retries exhausted")  # pragma: no cover

S3Ops

S3Ops(client)

S3 data-plane operations — presigned-first, multipart for large files.

All data transfer prefers presigned URLs. Files above client.multipart_threshold automatically use multipart upload.

Path mapping to the backend API (all under /api/v1): GET /buckets — list buckets GET /buckets/{b}/objects — list objects POST /presign — single presigned URL POST /buckets/{b}/objects/presign — bulk presigned URLs POST /buckets/{b}/multipart/{key} — initiate multipart POST /buckets/{b}/multipart/{key}/presign — presign parts POST /buckets/{b}/multipart/{key}/complete — complete multipart DELETE /buckets/{b}/objects/{key} — delete object POST /buckets/{b}/objects/delete — bulk delete POST /buckets/{b}/objects/{key}/copy — copy object HEAD /buckets/{b}/objects/{key} — head object

Source code in packages/rahcp-client/src/rahcp_client/s3.py
52
53
def __init__(self, client: HCPClient) -> None:
    self._client = client

presign_get(bucket, key, *, expires=3600) async

Get a presigned download URL.

Source code in packages/rahcp-client/src/rahcp_client/s3.py
63
64
65
66
67
68
69
70
71
72
73
74
75
async def presign_get(self, bucket: str, key: str, *, expires: int = 3600) -> str:
    """Get a presigned download URL."""
    resp = await self._client.request(
        "POST",
        "/presign",
        json={
            "bucket": bucket,
            "key": key,
            "method": "get_object",
            "expires_in": expires,
        },
    )
    return resp.json()["url"]

presign_put(bucket, key, *, expires=3600) async

Get a presigned upload URL.

Source code in packages/rahcp-client/src/rahcp_client/s3.py
77
78
79
80
81
82
83
84
85
86
87
88
89
async def presign_put(self, bucket: str, key: str, *, expires: int = 3600) -> str:
    """Get a presigned upload URL."""
    resp = await self._client.request(
        "POST",
        "/presign",
        json={
            "bucket": bucket,
            "key": key,
            "method": "put_object",
            "expires_in": expires,
        },
    )
    return resp.json()["url"]

presign_bulk(bucket, keys, *, method='get_object', expires=3600) async

Presign multiple keys in one API call. Returns {key: url} mapping.

Source code in packages/rahcp-client/src/rahcp_client/s3.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
async def presign_bulk(
    self,
    bucket: str,
    keys: list[str],
    *,
    method: str = "get_object",
    expires: int = 3600,
) -> dict[str, str]:
    """Presign multiple keys in one API call. Returns {key: url} mapping."""
    resp = await self._client.request(
        "POST",
        f"/buckets/{bucket}/objects/presign",
        json={
            "keys": keys,
            "method": method,
            "expires_in": expires,
        },
    )
    return {item["key"]: item["url"] for item in resp.json()["urls"]}

upload(bucket, key, data) async

Upload data — auto-selects presigned PUT or multipart based on size.

Returns the ETag of the uploaded object.

Source code in packages/rahcp-client/src/rahcp_client/s3.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
async def upload(self, bucket: str, key: str, data: bytes | Path) -> str:
    """Upload data — auto-selects presigned PUT or multipart based on size.

    Returns the ETag of the uploaded object.
    """
    with tracer.start_as_current_span("s3.upload") as span:
        span.set_attribute("s3.bucket", bucket)
        span.set_attribute("s3.key", key)

        if isinstance(data, Path):
            size = await asyncio.to_thread(lambda: data.stat().st_size)
            span.set_attribute("s3.size", size)
            if size >= self._client.multipart_threshold:
                return await self.upload_multipart(bucket, key, data)
            content = await asyncio.to_thread(data.read_bytes)
        else:
            content = data
            span.set_attribute("s3.size", len(content))

        url = await self.presign_put(bucket, key)

        async def _put() -> str:
            async with self._make_http_client() as http:
                resp = await http.put(url, content=content)
            raise_if_transient(resp, bucket, key)
            _raise_for_presigned(resp, bucket, key)
            return resp.headers.get("etag", "")

        etag = await transfer_with_retry(
            _put,
            max_attempts=self._client.max_retries + 1,
            base_delay=self._client.retry_base_delay,
        )
        log.debug("Uploaded %s/%s (%s bytes)", bucket, key, len(content))
        return etag

download(bucket, key, dest) async

Download an object via presigned GET. Returns byte count.

Source code in packages/rahcp-client/src/rahcp_client/s3.py
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
async def download(self, bucket: str, key: str, dest: Path) -> int:
    """Download an object via presigned GET. Returns byte count."""
    with tracer.start_as_current_span("s3.download") as span:
        span.set_attribute("s3.bucket", bucket)
        span.set_attribute("s3.key", key)
        url = await self.presign_get(bucket, key)

        async def _get() -> int:
            # Each attempt re-opens dest in "wb", discarding any partial
            # bytes written before a mid-stream transport error.
            total = 0
            async with self._make_http_client() as http:
                async with http.stream("GET", url) as resp:
                    raise_if_transient(resp, bucket, key)
                    _raise_for_presigned(resp, bucket, key)
                    file_handle = await asyncio.to_thread(lambda: dest.open("wb"))
                    try:
                        async for chunk in resp.aiter_bytes(chunk_size=8192):
                            await asyncio.to_thread(file_handle.write, chunk)
                            total += len(chunk)
                    finally:
                        await asyncio.to_thread(file_handle.close)
            return total

        total = await transfer_with_retry(
            _get,
            max_attempts=self._client.max_retries + 1,
            base_delay=self._client.retry_base_delay,
        )
        span.set_attribute("s3.bytes", total)
        log.debug("Downloaded %s/%s (%d bytes)", bucket, key, total)
        return total

download_bytes(bucket, key) async

Download an object as bytes via presigned GET.

Source code in packages/rahcp-client/src/rahcp_client/s3.py
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
async def download_bytes(self, bucket: str, key: str) -> bytes:
    """Download an object as bytes via presigned GET."""
    url = await self.presign_get(bucket, key)

    async def _get() -> bytes:
        async with self._make_http_client() as http:
            resp = await http.get(url)
        raise_if_transient(resp, bucket, key)
        _raise_for_presigned(resp, bucket, key)
        return resp.content

    return await transfer_with_retry(
        _get,
        max_attempts=self._client.max_retries + 1,
        base_delay=self._client.retry_base_delay,
    )

upload_multipart(bucket, key, path, *, concurrency=None) async

Presigned multipart upload for large files.

  1. Initiate multipart → upload_id
  2. Presign each part
  3. Upload parts in parallel (bounded semaphore)
  4. Complete multipart (or abort on failure)
Source code in packages/rahcp-client/src/rahcp_client/s3.py
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
async def upload_multipart(
    self,
    bucket: str,
    key: str,
    path: Path,
    *,
    concurrency: int | None = None,
) -> str:
    """Presigned multipart upload for large files.

    1. Initiate multipart → upload_id
    2. Presign each part
    3. Upload parts in parallel (bounded semaphore)
    4. Complete multipart (or abort on failure)
    """
    concurrency = concurrency or self._client.multipart_concurrency
    file_size = await asyncio.to_thread(lambda: path.stat().st_size)
    default_part_size = self._client.multipart_chunk

    resp = await self._client.request(
        "POST",
        f"/buckets/{bucket}/multipart/{key}",
    )
    upload_id = resp.json()["upload_id"]

    try:
        resp = await self._client.request(
            "POST",
            f"/buckets/{bucket}/multipart/{key}/presign",
            json={
                "upload_id": upload_id,
                "file_size": file_size,
                "part_size": default_part_size,
            },
        )
        data = resp.json()
        part_urls = [p["url"] for p in data["urls"]]
        part_size = data.get("part_size", default_part_size)

        sem = asyncio.Semaphore(concurrency)

        part_timeout = httpx.Timeout(
            max(300.0, self._client.timeout * 5), connect=30.0
        )

        async def _upload_part(part_num: int, url: str) -> dict[str, Any]:
            offset = part_num * part_size
            read_size = min(part_size, file_size - offset)

            def _read_chunk() -> bytes:
                with path.open("rb") as f:
                    f.seek(offset)
                    return f.read(read_size)

            part_data = await asyncio.to_thread(_read_chunk)
            label = f"{key} (part {part_num + 1})"

            async def _put_part() -> dict[str, Any]:
                async with httpx.AsyncClient(
                    verify=self._client.verify_ssl, timeout=part_timeout
                ) as http:
                    resp = await http.put(url, content=part_data)
                raise_if_transient(resp, bucket, label)
                _raise_for_presigned(resp, bucket, label)
                return {"part_number": part_num + 1, "etag": resp.headers["etag"]}

            async with sem:
                log.debug(
                    "Uploading part %d/%d (%d bytes) for %s/%s",
                    part_num + 1,
                    len(part_urls),
                    len(part_data),
                    bucket,
                    key,
                )
                return await transfer_with_retry(
                    _put_part,
                    max_attempts=self._client.max_retries + 1,
                    base_delay=self._client.retry_base_delay,
                )

        parts = await asyncio.gather(
            *[_upload_part(i, url) for i, url in enumerate(part_urls)]
        )

        parts_sorted = sorted(parts, key=lambda p: p["part_number"])
        resp = await self._client.request(
            "POST",
            f"/buckets/{bucket}/multipart/{key}/complete",
            json={
                "upload_id": upload_id,
                "parts": [
                    {"ETag": p["etag"], "PartNumber": p["part_number"]}
                    for p in parts_sorted
                ],
            },
        )
        log.info(
            "Multipart upload complete: %s/%s (%d parts)",
            bucket,
            key,
            len(parts_sorted),
        )
        return resp.json().get("etag", "")

    except Exception:
        log.warning("Multipart upload failed, aborting: %s/%s", bucket, key)
        try:
            await self._client.request(
                "POST",
                f"/buckets/{bucket}/multipart/{key}/abort",
                json={"upload_id": upload_id},
            )
        except Exception:
            log.warning("Failed to abort multipart upload: %s", upload_id)
        raise

list_buckets() async

List all S3 buckets.

Source code in packages/rahcp-client/src/rahcp_client/s3.py
320
321
322
323
async def list_buckets(self) -> dict[str, Any]:
    """List all S3 buckets."""
    resp = await self._client.request("GET", "/buckets")
    return resp.json()

list_objects(bucket, prefix='', *, max_keys=1000, continuation_token=None, delimiter=None) async

List objects in a bucket with optional prefix and pagination.

Source code in packages/rahcp-client/src/rahcp_client/s3.py
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
async def list_objects(
    self,
    bucket: str,
    prefix: str = "",
    *,
    max_keys: int = 1000,
    continuation_token: str | None = None,
    delimiter: str | None = None,
) -> dict[str, Any]:
    """List objects in a bucket with optional prefix and pagination."""
    params: dict[str, Any] = {"prefix": prefix, "max_keys": max_keys}
    if continuation_token:
        params["continuation_token"] = continuation_token
    if delimiter:
        params["delimiter"] = delimiter
    resp = await self._client.request(
        "GET",
        f"/buckets/{bucket}/objects",
        params=params,
    )
    return resp.json()

delete(bucket, key) async

Delete a single object.

Source code in packages/rahcp-client/src/rahcp_client/s3.py
364
365
366
async def delete(self, bucket: str, key: str) -> None:
    """Delete a single object."""
    await self._client.request("DELETE", f"/buckets/{bucket}/objects/{key}")

delete_bulk(bucket, keys) async

Delete multiple objects.

Source code in packages/rahcp-client/src/rahcp_client/s3.py
368
369
370
371
372
373
374
375
async def delete_bulk(self, bucket: str, keys: list[str]) -> dict[str, Any]:
    """Delete multiple objects."""
    resp = await self._client.request(
        "POST",
        f"/buckets/{bucket}/objects/delete",
        json={"keys": keys},
    )
    return resp.json()

copy(bucket, key, source_bucket, source_key) async

Copy an object.

Source code in packages/rahcp-client/src/rahcp_client/s3.py
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
async def copy(
    self,
    bucket: str,
    key: str,
    source_bucket: str,
    source_key: str,
) -> None:
    """Copy an object."""
    await self._client.request(
        "POST",
        f"/buckets/{bucket}/objects/{key}/copy",
        json={
            "source_bucket": source_bucket,
            "source_key": source_key,
        },
    )

head(bucket, key) async

Get object metadata (content-length, content-type, etag, last-modified).

Source code in packages/rahcp-client/src/rahcp_client/s3.py
394
395
396
397
async def head(self, bucket: str, key: str) -> dict[str, Any]:
    """Get object metadata (content-length, content-type, etag, last-modified)."""
    resp = await self._client.request("HEAD", f"/buckets/{bucket}/objects/{key}")
    return dict(resp.headers)

commit_staging(bucket, staging_prefix, dest_prefix) async

Move objects from staging prefix to destination. Returns count.

Source code in packages/rahcp-client/src/rahcp_client/s3.py
401
402
403
404
405
406
407
408
409
410
411
412
async def commit_staging(
    self, bucket: str, staging_prefix: str, dest_prefix: str
) -> int:
    """Move objects from staging prefix to destination. Returns count."""
    count = 0
    async for obj in self._paginate_objects(bucket, staging_prefix):
        src_key = obj["Key"]
        dest_key = src_key.replace(staging_prefix, dest_prefix, 1)
        await self.copy(bucket, dest_key, bucket, src_key)
        await self.delete(bucket, src_key)
        count += 1
    return count

cleanup_staging(bucket, staging_prefix) async

Delete all objects under a staging prefix. Paginates. Returns count.

Source code in packages/rahcp-client/src/rahcp_client/s3.py
414
415
416
417
418
419
420
421
422
423
424
425
426
427
async def cleanup_staging(self, bucket: str, staging_prefix: str) -> int:
    """Delete all objects under a staging prefix. Paginates. Returns count."""
    total = 0
    batch: list[str] = []
    async for obj in self._paginate_objects(bucket, staging_prefix):
        batch.append(obj["Key"])
        if len(batch) >= 1000:
            await self.delete_bulk(bucket, batch)
            total += len(batch)
            batch.clear()
    if batch:
        await self.delete_bulk(bucket, batch)
        total += len(batch)
    return total

MapiOps

MapiOps(client)

MAPI management-plane — tenant admin and namespace operations only.

This is intentionally thin: user/group CRUD, statistics, chargeback, and system-level admin are handled by the backend directly.

HTTP method mapping (matches HCP MAPI conventions): GET — read PUT — create POST — update/modify DELETE — delete

Source code in packages/rahcp-client/src/rahcp_client/mapi.py
27
28
def __init__(self, client: HCPClient) -> None:
    self._client = client

list_namespaces(tenant, *, verbose=False) async

List all namespaces for a tenant.

Returns the MAPI response shape {"name": ["ns1", "ns2", ...]}.

Source code in packages/rahcp-client/src/rahcp_client/mapi.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
async def list_namespaces(
    self, tenant: str, *, verbose: bool = False
) -> dict[str, Any]:
    """List all namespaces for a tenant.

    Returns the MAPI response shape ``{"name": ["ns1", "ns2", ...]}``.
    """
    params: dict[str, Any] = {}
    if verbose:
        params["verbose"] = "true"
    resp = await self._client.request(
        "GET", f"/mapi/tenants/{tenant}/namespaces", params=params
    )
    return resp.json()

get_namespace(tenant, ns, *, verbose=False) async

Get namespace details.

Source code in packages/rahcp-client/src/rahcp_client/mapi.py
45
46
47
48
49
50
51
52
53
54
55
async def get_namespace(
    self, tenant: str, ns: str, *, verbose: bool = False
) -> dict[str, Any]:
    """Get namespace details."""
    params: dict[str, Any] = {}
    if verbose:
        params["verbose"] = "true"
    resp = await self._client.request(
        "GET", f"/mapi/tenants/{tenant}/namespaces/{ns}", params=params
    )
    return resp.json()

create_namespace(tenant, ns_data) async

Create a namespace. Uses PUT (MAPI convention).

Source code in packages/rahcp-client/src/rahcp_client/mapi.py
57
58
59
60
61
62
63
64
async def create_namespace(
    self, tenant: str, ns_data: dict[str, Any]
) -> dict[str, Any]:
    """Create a namespace. Uses PUT (MAPI convention)."""
    resp = await self._client.request(
        "PUT", f"/mapi/tenants/{tenant}/namespaces", json=ns_data
    )
    return resp.json()

update_namespace(tenant, ns, data) async

Update namespace settings. Uses POST (MAPI convention).

Source code in packages/rahcp-client/src/rahcp_client/mapi.py
66
67
68
69
70
71
72
async def update_namespace(
    self, tenant: str, ns: str, data: dict[str, Any]
) -> None:
    """Update namespace settings. Uses POST (MAPI convention)."""
    await self._client.request(
        "POST", f"/mapi/tenants/{tenant}/namespaces/{ns}", json=data
    )

delete_namespace(tenant, ns) async

Delete a namespace.

Source code in packages/rahcp-client/src/rahcp_client/mapi.py
74
75
76
async def delete_namespace(self, tenant: str, ns: str) -> None:
    """Delete a namespace."""
    await self._client.request("DELETE", f"/mapi/tenants/{tenant}/namespaces/{ns}")

export_namespace(tenant, ns) async

Export a namespace as a reusable template.

Source code in packages/rahcp-client/src/rahcp_client/mapi.py
78
79
80
81
82
83
async def export_namespace(self, tenant: str, ns: str) -> dict[str, Any]:
    """Export a namespace as a reusable template."""
    resp = await self._client.request(
        "GET", f"/mapi/tenants/{tenant}/namespaces/{ns}/export"
    )
    return resp.json()

export_namespaces(tenant, names) async

Export multiple namespaces as templates.

Source code in packages/rahcp-client/src/rahcp_client/mapi.py
85
86
87
88
89
90
91
92
async def export_namespaces(self, tenant: str, names: list[str]) -> dict[str, Any]:
    """Export multiple namespaces as templates."""
    resp = await self._client.request(
        "GET",
        f"/mapi/tenants/{tenant}/namespaces/export",
        params={"names": ",".join(names)},
    )
    return resp.json()

Configuration

HCPSettings

Bases: BaseSettings

Configuration for the HCP client.

All values can be set via HCP_-prefixed environment variables, e.g. HCP_ENDPOINT, HCP_USERNAME, HCP_TENANT.

Bulk Transfers

config

Configuration and stats models for bulk transfers.

ConflictPolicy

Bases: StrEnum

What to do when an upload's destination key already exists in the bucket.

  • overwrite — upload unconditionally, replacing the remote object (no pre-flight HEAD).
  • skip — keep the remote object and count the item as skipped; a size mismatch between local and remote is logged as a warning.
  • error — keep the remote object and record a conflict error for the item (visible in the tracker and error callbacks).

TransferStats

Bases: BaseModel

Snapshot of a running bulk transfer.

done property

Total files processed (ok + skipped + errors).

files_per_sec property

Throughput in files per second.

mb_per_sec property

Throughput in megabytes per second.

BulkStreamConfig

Bases: BaseModel

Configuration for a streaming upload job (bytes fetched on the fly).

Like BulkUploadConfig but with no source_dir — each object's bytes come from a caller-supplied fetch callable instead of a local file, so nothing is staged to disk. Validation is byte-based (validate_bytes).

conflict_policy()

Effective policy — explicit on_conflict wins over legacy skip_existing.

Source code in packages/rahcp-client/src/rahcp_client/bulk/config.py
89
90
91
92
93
def conflict_policy(self) -> ConflictPolicy:
    """Effective policy — explicit ``on_conflict`` wins over legacy ``skip_existing``."""
    if self.on_conflict is not None:
        return self.on_conflict
    return ConflictPolicy.skip if self.skip_existing else ConflictPolicy.overwrite

BulkUploadConfig

Bases: BaseModel

Configuration for a bulk upload job.

conflict_policy()

Effective policy — explicit on_conflict wins over legacy skip_existing.

Source code in packages/rahcp-client/src/rahcp_client/bulk/config.py
121
122
123
124
125
def conflict_policy(self) -> ConflictPolicy:
    """Effective policy — explicit ``on_conflict`` wins over legacy ``skip_existing``."""
    if self.on_conflict is not None:
        return self.on_conflict
    return ConflictPolicy.skip if self.skip_existing else ConflictPolicy.overwrite

BulkDownloadConfig

Bases: BaseModel

Configuration for a bulk download job.

upload

Bulk upload — producer-consumer pipeline with batch presigning.

bulk_upload(cfg) async

Upload a directory to S3 with batch presigning and connection pooling.

Source code in packages/rahcp-client/src/rahcp_client/bulk/upload.py
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
async def bulk_upload(cfg: BulkUploadConfig) -> TransferStats:
    """Upload a directory to S3 with batch presigning and connection pooling."""
    src = cfg.source_dir
    policy = cfg.conflict_policy()
    counters = Counters(done_keys=await asyncio.to_thread(cfg.tracker.done_keys))

    verify_ssl, pool_timeout, multipart_threshold = pool_settings(cfg.client)
    pool = make_pool(cfg.workers, verify_ssl=verify_ssl, timeout=pool_timeout)

    log.info(
        "Bulk upload: %s → s3://%s/%s (%d workers, presign batch %d, %d done)",
        src,
        cfg.bucket,
        cfg.prefix,
        cfg.workers,
        cfg.presign_batch_size,
        len(counters.done_keys),
    )

    # ── Guard helpers ────────────────────────────────────────────

    async def _upload_and_verify(
        key: str,
        file_path: Path,
        presigned_url: str | None,
        local_size: int,
        validated: bool,
    ) -> tuple[str, int]:
        """Perform the actual upload and optional post-upload verification."""

        async def _put() -> str:
            if presigned_url and local_size < multipart_threshold:
                return await pool_upload(
                    pool, presigned_url, file_path, cfg.bucket, key
                )
            return await cfg.client.s3.upload(cfg.bucket, key, file_path)

        etag, short_circuit = await put_with_conflict_policy(
            _put,
            cfg.client.s3,
            cfg.bucket,
            key,
            local_size,
            policy=policy,
            tracker=cfg.tracker,
            done_keys=counters.done_keys,
            on_error=cfg.on_error,
        )
        if short_circuit is not None:
            return short_circuit

        try:
            verified = False
            if cfg.verify_upload:
                await verify_remote_size(cfg.client.s3, cfg.bucket, key, local_size)
                verified = True

            mark_done(
                cfg.tracker,
                counters.done_keys,
                key,
                local_size,
                etag=etag or None,
                validated=validated,
                verified=verified,
            )
            return "ok", local_size
        except Exception as exc:
            mark_error(cfg.tracker, key, local_size, exc, cfg.on_error, phase="verify")
            return "error", 0

    # ── Per-file transfer logic ───────────────────────────────────

    async def transfer_one(
        file_path: Path, key: str, presigned_url: str | None
    ) -> tuple[str, int]:
        if key in counters.done_keys:
            return "skipped", 0

        local_size = file_path.stat().st_size

        if not await run_validation(
            cfg.validate_file, cfg.tracker, key, file_path, local_size, cfg.on_error
        ):
            return "error", 0

        guard = await apply_conflict_policy(
            cfg.client.s3,
            cfg.bucket,
            key,
            local_size,
            policy=policy,
            retry_errors=cfg.retry_errors,
            tracker=cfg.tracker,
            done_keys=counters.done_keys,
            on_error=cfg.on_error,
            validated=cfg.validate_file is not None,
        )
        if guard is not None:
            return guard

        return await _upload_and_verify(
            key,
            file_path,
            presigned_url,
            local_size,
            validated=cfg.validate_file is not None,
        )

    # ── Producer ──────────────────────────────────────────────────

    async def produce(queue: asyncio.Queue) -> None:
        if cfg.retry_errors:
            for key, _ in await asyncio.to_thread(cfg.tracker.error_entries):
                await queue.put((src / key, key, None))
            return

        file_list = await asyncio.to_thread(
            _scan_files, src, cfg.prefix, cfg.include, cfg.exclude, counters.done_keys
        )

        pending: list[tuple[Path, str]] = []

        async def flush() -> None:
            if not pending:
                return
            keys = [k for _, k in pending]
            try:
                url_map = await cfg.client.s3.presign_bulk(
                    cfg.bucket, keys, method="put_object"
                )
            except Exception:
                log.warning("Bulk presign failed, falling back to per-file")
                url_map = {}
            for fp, k in pending:
                await queue.put((fp, k, url_map.get(k)))
            pending.clear()

        for fp, key in file_list:
            pending.append((fp, key))
            if len(pending) >= cfg.presign_batch_size:
                await flush()
        await flush()

    # ── Run ───────────────────────────────────────────────────────

    return await run_pipeline(
        workers=cfg.workers,
        queue_depth=cfg.queue_depth,
        pool=pool,
        counters=counters,
        tracker=cfg.tracker,
        on_progress=cfg.on_progress,
        progress_interval=cfg.progress_interval,
        transfer_fn=transfer_one,
        produce_fn=produce,
    )

download

Bulk download — producer-consumer pipeline with batch presigning.

bulk_download(cfg) async

Download objects from S3 with batch presigning and connection pooling.

Source code in packages/rahcp-client/src/rahcp_client/bulk/download.py
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
async def bulk_download(cfg: BulkDownloadConfig) -> TransferStats:
    """Download objects from S3 with batch presigning and connection pooling."""
    dest = cfg.dest_dir
    dest.mkdir(parents=True, exist_ok=True)
    counters = Counters(done_keys=await asyncio.to_thread(cfg.tracker.done_keys))

    verify_ssl, pool_timeout, _ = pool_settings(cfg.client)
    pool = make_pool(cfg.workers, verify_ssl=verify_ssl, timeout=pool_timeout)

    log.info(
        "Bulk download: s3://%s/%s%s (%d workers, presign batch %d, %d done)",
        cfg.bucket,
        cfg.prefix,
        dest,
        cfg.workers,
        cfg.presign_batch_size,
        len(counters.done_keys),
    )

    # ── Per-file transfer logic ───────────────────────────────────

    async def transfer_one(
        key: str, size: int, presigned_url: str | None
    ) -> tuple[str, int]:
        if key in counters.done_keys:
            return "skipped", 0

        file_path = dest / key
        tmp_path = file_path.with_suffix(file_path.suffix + ".tmp")
        file_path.parent.mkdir(parents=True, exist_ok=True)

        # Guard: skip if already downloaded and size matches
        if file_path.exists() and file_path.stat().st_size == size:
            mark_done(cfg.tracker, counters.done_keys, key, size)
            return "skipped", 0

        try:
            if presigned_url:
                downloaded_bytes = await pool_download(
                    pool,
                    presigned_url,
                    tmp_path,
                    cfg.bucket,
                    key,
                    size=size,
                    chunk_size=cfg.chunk_size,
                    stream_threshold=cfg.stream_threshold,
                )
            else:
                downloaded_bytes = await cfg.client.s3.download(
                    cfg.bucket, key, tmp_path
                )

            if cfg.verify_download and tmp_path.stat().st_size != size:
                tmp_path.unlink(missing_ok=True)
                raise ValueError(
                    f"Size mismatch: expected={size}, got={tmp_path.stat().st_size}"
                )
            tmp_path.rename(file_path)

        except Exception as exc:
            tmp_path.unlink(missing_ok=True)
            mark_error(cfg.tracker, key, size, exc, cfg.on_error)
            return "error", 0

        # Post-download validation
        if not await run_validation(
            cfg.validate_file, cfg.tracker, key, file_path, size, cfg.on_error
        ):
            file_path.unlink(missing_ok=True)
            return "error", 0

        mark_done(
            cfg.tracker,
            counters.done_keys,
            key,
            size,
            validated=cfg.validate_file is not None,
        )
        return "ok", downloaded_bytes

    # ── Producer ──────────────────────────────────────────────────

    async def produce(queue: asyncio.Queue) -> None:
        if cfg.retry_errors:
            for key, size in await asyncio.to_thread(cfg.tracker.error_entries):
                await queue.put((key, size, None))
            return

        pending: list[tuple[str, int]] = []

        async def flush() -> None:
            if not pending:
                return
            keys = [k for k, _ in pending]
            try:
                url_map = await cfg.client.s3.presign_bulk(
                    cfg.bucket, keys, method="get_object"
                )
            except Exception:
                log.warning("Bulk presign failed, falling back to per-file")
                url_map = {}
            for k, sz in pending:
                await queue.put((k, sz, url_map.get(k)))
            pending.clear()

        async for obj in paginate_objects(cfg.client.s3, cfg.bucket, cfg.prefix):
            key = obj["Key"]
            if key.endswith("/") and obj.get("Size", 0) == 0:
                continue
            filename = key.rsplit("/", 1)[-1]
            if not matches_filters(filename, cfg.include, cfg.exclude):
                continue
            if key in counters.done_keys:
                continue
            pending.append((key, obj.get("Size", 0)))
            if len(pending) >= cfg.presign_batch_size:
                await flush()
        await flush()

    # ── Run ───────────────────────────────────────────────────────

    return await run_pipeline(
        workers=cfg.workers,
        queue_depth=cfg.queue_depth,
        pool=pool,
        counters=counters,
        tracker=cfg.tracker,
        on_progress=cfg.on_progress,
        progress_interval=cfg.progress_interval,
        transfer_fn=transfer_one,
        produce_fn=produce,
    )

stream

Streaming bulk upload — fetch each object's bytes on the fly, never staged to disk.

Shares the producer/consumer pipeline, batched presigning, HEAD skip/verify guards, and tracker bookkeeping with bulk_upload; only the source differs (a caller fetch coroutine returning bytes instead of a local file).

bulk_stream_upload(cfg, items, fetch) async

Upload objects whose bytes are fetched on demand (no local staging).

Parameters:

Name Type Description Default
cfg BulkStreamConfig

Streaming upload configuration.

required
items list[tuple[str, str]]

(key, fetch_id) pairs — key is the full S3 object key, fetch_id is whatever fetch needs to produce the bytes.

required
fetch Callable[[str], Awaitable[bytes]]

Async callable fetch_id -> bytes (e.g. a retrying IIIF download). It is the caller's job to make it resilient.

required

Per item (unless already done / already in the bucket): skip-HEAD → fetch → optional byte validation → presigned PUT → optional verify → tracker mark. Reuses the same engine and guards as bulk_upload.

Source code in packages/rahcp-client/src/rahcp_client/bulk/stream.py
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
async def bulk_stream_upload(
    cfg: BulkStreamConfig,
    items: list[tuple[str, str]],
    fetch: Callable[[str], Awaitable[bytes]],
) -> TransferStats:
    """Upload objects whose bytes are fetched on demand (no local staging).

    Args:
        cfg: Streaming upload configuration.
        items: ``(key, fetch_id)`` pairs — ``key`` is the full S3 object key,
            ``fetch_id`` is whatever ``fetch`` needs to produce the bytes.
        fetch: Async callable ``fetch_id -> bytes`` (e.g. a retrying IIIF
            download). It is the caller's job to make it resilient.

    Per item (unless already done / already in the bucket): skip-HEAD →
    ``fetch`` → optional byte validation → presigned PUT → optional verify →
    tracker mark. Reuses the same engine and guards as ``bulk_upload``.
    """

    policy = cfg.conflict_policy()
    counters = Counters(done_keys=await asyncio.to_thread(cfg.tracker.done_keys))
    verify_ssl, pool_timeout, _ = pool_settings(cfg.client)
    pool = make_pool(cfg.workers, verify_ssl=verify_ssl, timeout=pool_timeout)

    log.info(
        "Stream upload: %d items → s3://%s (%d workers, presign batch %d, %d done)",
        len(items),
        cfg.bucket,
        cfg.workers,
        cfg.presign_batch_size,
        len(counters.done_keys),
    )

    async def transfer_one(
        key: str, fetch_id: str, presigned_url: str | None
    ) -> tuple[str, int]:
        if key in counters.done_keys:
            return "skipped", 0

        # Guard before downloading — don't re-fetch what's already in the bucket.
        guard = await apply_conflict_policy(
            cfg.client.s3,
            cfg.bucket,
            key,
            None,
            policy=policy,
            retry_errors=cfg.retry_errors,
            tracker=cfg.tracker,
            done_keys=counters.done_keys,
            on_error=cfg.on_error,
        )
        if guard is not None:
            return guard

        try:
            data = await fetch(fetch_id)
        except Exception as exc:
            mark_error(cfg.tracker, key, 0, exc, cfg.on_error, phase="download")
            return "error", 0

        size = len(data)

        if cfg.validate_bytes is not None:
            try:
                cfg.validate_bytes(data, PurePosixPath(key).suffix)
            except Exception as exc:
                mark_error(cfg.tracker, key, size, exc, cfg.on_error, phase="validate")
                return "error", 0

        async def _put() -> str:
            if presigned_url:
                return await pool_put_bytes(pool, presigned_url, data, cfg.bucket, key)
            return await cfg.client.s3.upload(cfg.bucket, key, data)

        etag, short_circuit = await put_with_conflict_policy(
            _put,
            cfg.client.s3,
            cfg.bucket,
            key,
            size,
            policy=policy,
            tracker=cfg.tracker,
            done_keys=counters.done_keys,
            on_error=cfg.on_error,
        )
        if short_circuit is not None:
            return short_circuit

        verified = False
        if cfg.verify_upload:
            try:
                await verify_remote_size(cfg.client.s3, cfg.bucket, key, size)
                verified = True
            except Exception as exc:
                mark_error(cfg.tracker, key, size, exc, cfg.on_error, phase="verify")
                return "error", 0

        mark_done(
            cfg.tracker,
            counters.done_keys,
            key,
            size,
            etag=etag or None,
            validated=cfg.validate_bytes is not None,
            verified=verified,
        )
        return "ok", size

    async def produce(queue: asyncio.Queue) -> None:
        if cfg.retry_errors:
            error_keys = {
                k for k, _ in await asyncio.to_thread(cfg.tracker.error_entries)
            }
            todo = [(k, fid) for k, fid in items if k in error_keys]
        else:
            todo = [(k, fid) for k, fid in items if k not in counters.done_keys]

        pending: list[tuple[str, str]] = []

        async def flush() -> None:
            if not pending:
                return
            keys = [k for k, _ in pending]
            try:
                url_map = await cfg.client.s3.presign_bulk(
                    cfg.bucket, keys, method="put_object"
                )
            except Exception:
                log.warning("Bulk presign failed, falling back to per-key presign")
                url_map = {}
            for key, fetch_id in pending:
                await queue.put((key, fetch_id, url_map.get(key)))
            pending.clear()

        for key, fetch_id in todo:
            pending.append((key, fetch_id))
            if len(pending) >= cfg.presign_batch_size:
                await flush()
        await flush()

    return await run_pipeline(
        workers=cfg.workers,
        queue_depth=cfg.queue_depth,
        pool=pool,
        counters=counters,
        tracker=cfg.tracker,
        on_progress=cfg.on_progress,
        progress_interval=cfg.progress_interval,
        transfer_fn=transfer_one,
        produce_fn=produce,
    )

Errors

errors

HCPError hierarchy — maps HTTP status codes to typed exceptions.

HCPError(message, *, status_code=None)

Bases: Exception

Base error for all rahcp operations.

Source code in packages/rahcp-client/src/rahcp_client/errors.py
 9
10
11
12
def __init__(self, message: str, *, status_code: int | None = None) -> None:
    super().__init__(message)
    self.message = message
    self.status_code = status_code

AuthenticationError(message, *, status_code=None)

Bases: HCPError

401 — bad credentials or expired token.

Source code in packages/rahcp-client/src/rahcp_client/errors.py
 9
10
11
12
def __init__(self, message: str, *, status_code: int | None = None) -> None:
    super().__init__(message)
    self.message = message
    self.status_code = status_code

NotFoundError(message, *, status_code=None)

Bases: HCPError

404 — tenant, namespace, or object not found.

Source code in packages/rahcp-client/src/rahcp_client/errors.py
 9
10
11
12
def __init__(self, message: str, *, status_code: int | None = None) -> None:
    super().__init__(message)
    self.message = message
    self.status_code = status_code

ConflictError(message, *, status_code=None)

Bases: HCPError

409 — resource already exists.

Source code in packages/rahcp-client/src/rahcp_client/errors.py
 9
10
11
12
def __init__(self, message: str, *, status_code: int | None = None) -> None:
    super().__init__(message)
    self.message = message
    self.status_code = status_code

RetryableError(message, *, status_code=None)

Bases: HCPError

408, 429, 500, 502, 503, 504 — transient failure after all retries exhausted.

Source code in packages/rahcp-client/src/rahcp_client/errors.py
 9
10
11
12
def __init__(self, message: str, *, status_code: int | None = None) -> None:
    super().__init__(message)
    self.message = message
    self.status_code = status_code

UpstreamError(message, *, status_code=None)

Bases: RetryableError

502 — backend gateway/upstream error. Transient: retried, then raised.

Source code in packages/rahcp-client/src/rahcp_client/errors.py
 9
10
11
12
def __init__(self, message: str, *, status_code: int | None = None) -> None:
    super().__init__(message)
    self.message = message
    self.status_code = status_code

error_for_status(status_code, message)

Map an HTTP status code to the appropriate HCPError subclass.

Source code in packages/rahcp-client/src/rahcp_client/errors.py
40
41
42
43
def error_for_status(status_code: int, message: str) -> HCPError:
    """Map an HTTP status code to the appropriate HCPError subclass."""
    cls = _STATUS_MAP.get(status_code, HCPError)
    return cls(message, status_code=status_code)

Tracing

tracing

Optional OpenTelemetry tracing — no-op when OTEL is not installed.

Automatically initializes the OTEL SDK when OTEL_EXPORTER_OTLP_ENDPOINT is set (or when configure_tracing() is called explicitly).

Supports both grpc and http/protobuf protocols via the standard OTEL_EXPORTER_OTLP_PROTOCOL env var.

Usage::

from rahcp_client.tracing import tracer

with tracer.start_as_current_span("s3.upload") as span:
    span.set_attribute("s3.bucket", bucket)
    ...

If opentelemetry-api is not installed, tracer is a no-op.

configure_tracing(service_name='rahcp-client', endpoint=None, protocol=None)

Initialize the OTEL SDK with OTLP exporter.

Called automatically if OTEL_EXPORTER_OTLP_ENDPOINT env var is set. Safe to call multiple times — only initializes once.

Parameters:

Name Type Description Default
service_name str

Service name in traces (default: rahcp-client).

'rahcp-client'
endpoint str | None

OTLP endpoint. Defaults to OTEL_EXPORTER_OTLP_ENDPOINT env var.

None
protocol str | None

grpc or http/protobuf. Defaults to OTEL_EXPORTER_OTLP_PROTOCOL env var, then http/protobuf.

None
Source code in packages/rahcp-client/src/rahcp_client/tracing.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def configure_tracing(
    service_name: str = "rahcp-client",
    endpoint: str | None = None,
    protocol: str | None = None,
) -> None:
    """Initialize the OTEL SDK with OTLP exporter.

    Called automatically if ``OTEL_EXPORTER_OTLP_ENDPOINT`` env var is set.
    Safe to call multiple times — only initializes once.

    Args:
        service_name: Service name in traces (default: rahcp-client).
        endpoint: OTLP endpoint. Defaults to ``OTEL_EXPORTER_OTLP_ENDPOINT`` env var.
        protocol: ``grpc`` or ``http/protobuf``. Defaults to
            ``OTEL_EXPORTER_OTLP_PROTOCOL`` env var, then ``http/protobuf``.
    """
    global _initialized  # noqa: PLW0603
    if _initialized or not OTEL_AVAILABLE:
        return

    otlp_endpoint = endpoint or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "")
    if not otlp_endpoint:
        return

    otlp_protocol = protocol or os.environ.get(
        "OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf"
    )

    resource = Resource.create(
        {
            "service.name": os.environ.get("OTEL_SERVICE_NAME", service_name),
        }
    )
    provider = TracerProvider(resource=resource)

    try:
        if otlp_protocol == "grpc":
            from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
                OTLPSpanExporter,
            )
        else:
            from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
                OTLPSpanExporter,
            )

        exporter = OTLPSpanExporter(endpoint=otlp_endpoint)
        provider.add_span_processor(BatchSpanProcessor(exporter))
        trace.set_tracer_provider(provider)
        _initialized = True
        log.info("OTEL tracing enabled: %s (%s)", otlp_endpoint, otlp_protocol)
    except Exception:
        log.warning("Failed to initialize OTEL exporter", exc_info=True)

rahcp-tracker

Tracker Protocol

protocol

TrackerProtocol — the interface all tracker backends must implement.

TrackerProtocol

Bases: Protocol

Interface for transfer state tracking.

Any object implementing these methods can be used as a tracker in bulk transfer operations. The default implementation is :class:~rahcp_tracker.sqlite.SqliteTracker.

delete(key)

Flush the buffer, then delete the entry for key if present.

Source code in packages/rahcp-tracker/src/rahcp_tracker/protocol.py
25
26
27
def delete(self, key: str) -> None:
    """Flush the buffer, then delete the entry for ``key`` if present."""
    ...

SQLite Tracker

sqlite

SqliteTracker — thread-safe, buffered transfer tracker backed by SQLite.

SqliteTracker(db_path, *, flush_every=200)

Bases: _BufferedSqlTracker

Thread-safe, buffered transfer tracker backed by SQLite.

Marks are buffered in memory and flushed to the database either explicitly via flush() or automatically when the buffer reaches flush_every entries.

Usage::

tracker = SqliteTracker(Path(".upload-tracker.db"))
tracker.mark("folder/file.jpg", 12345, TransferStatus.done, etag='"abc123"')
tracker.flush()
done = tracker.done_keys()
tracker.close()
Source code in packages/rahcp-tracker/src/rahcp_tracker/sqlite.py
35
36
37
38
39
40
41
42
43
44
45
46
def __init__(self, db_path: Path, *, flush_every: int = 200) -> None:
    engine = create_engine(
        f"sqlite:///{db_path}",
        connect_args={"check_same_thread": False},
        echo=False,
    )
    with engine.connect() as conn:
        conn.exec_driver_sql("PRAGMA journal_mode=WAL")
        conn.exec_driver_sql("PRAGMA synchronous=NORMAL")
        conn.commit()
    super().__init__(engine, sqlite_insert, flush_every=flush_every)
    self._migrate()

PostgreSQL Tracker

postgres

PostgresTracker — thread-safe, buffered transfer tracker backed by PostgreSQL.

PostgresTracker(dsn, *, flush_every=200)

Bases: _BufferedSqlTracker

Thread-safe, buffered transfer tracker backed by PostgreSQL.

Same behavior and interface as :class:~rahcp_tracker.sqlite.SqliteTracker, but connects to a PostgreSQL server instead of a local file — use it when several machines share one tracker, or tracker state must outlive the host.

Requires the postgres extra: pip install "rahcp-tracker[postgres]".

Usage::

tracker = PostgresTracker("postgresql://user:pass@host:5432/rahcp")
tracker.mark("folder/file.jpg", 12345, TransferStatus.done)
tracker.close()
Source code in packages/rahcp-tracker/src/rahcp_tracker/postgres.py
35
36
37
38
39
40
41
42
43
def __init__(self, dsn: str, *, flush_every: int = 200) -> None:
    try:
        engine = create_engine(_normalize_dsn(dsn), echo=False)
    except ModuleNotFoundError as exc:
        raise ImportError(
            "PostgresTracker requires the PostgreSQL driver — install it "
            'with: pip install "rahcp-tracker[postgres]"'
        ) from exc
    super().__init__(engine, pg_insert, flush_every=flush_every)

Backend Selection

factory

Select a tracker backend from a SQLite file path or a PostgreSQL DSN.

redact_dsn(dsn)

Render a DSN with the password masked — safe for console output and logs.

Source code in packages/rahcp-tracker/src/rahcp_tracker/factory.py
18
19
20
def redact_dsn(dsn: str) -> str:
    """Render a DSN with the password masked — safe for console output and logs."""
    return make_url(dsn).render_as_string(hide_password=True)

create_tracker(target, *, flush_every=200)

Create a tracker from target.

Targets with a PostgreSQL scheme (postgres://, postgresql://, postgresql+<driver>://) select :class:PostgresTracker; any other URL scheme is rejected loudly; everything else is treated as a SQLite database file path.

Source code in packages/rahcp-tracker/src/rahcp_tracker/factory.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
def create_tracker(target: str | Path, *, flush_every: int = 200) -> TrackerProtocol:
    """Create a tracker from ``target``.

    Targets with a PostgreSQL scheme (``postgres://``, ``postgresql://``,
    ``postgresql+<driver>://``) select :class:`PostgresTracker`; any other
    URL scheme is rejected loudly; everything else is treated as a SQLite
    database file path.
    """
    target_str = str(target)
    match = _SCHEME_RE.match(target_str)
    if match is None:
        return SqliteTracker(Path(target), flush_every=flush_every)

    scheme = match.group(1).lower()
    if scheme in ("postgres", "postgresql") or scheme.startswith("postgresql+"):
        # Rebuild '<scheme>://' — Path() normalizes '//' to '/', and this
        # also repairs a hand-typed single-slash DSN.
        rest = target_str.split(":", 1)[1].lstrip("/")
        return PostgresTracker(f"{scheme}://{rest}", flush_every=flush_every)

    raise ValueError(
        f"Unsupported tracker DSN scheme {scheme!r} — "
        "expected a SQLite file path or a postgresql:// DSN"
    )

rahcp-iiif

Manifest Helpers

manifest

IIIF manifest parsing — extract image IDs from Riksarkivet manifests.

fetch_with_retry(client, url, *, attempts=4, base_delay=0.5) async

GET url, retrying transient failures with exponential backoff + jitter.

Retries connection/timeout errors and 408/425/429/5xx responses. Any other 4xx (e.g. 404) is treated as terminal and raised immediately — retrying it would only waste time.

Parameters:

Name Type Description Default
client AsyncClient

An open httpx.AsyncClient.

required
url str

The URL to GET.

required
attempts int

Maximum number of attempts (1 disables retrying).

4
base_delay float

Base backoff delay in seconds; also bounds the jitter, so 0.0 retries with no wait (useful in tests).

0.5
Source code in packages/rahcp-iiif/src/rahcp_iiif/manifest.py
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
async def fetch_with_retry(
    client: httpx.AsyncClient,
    url: str,
    *,
    attempts: int = 4,
    base_delay: float = 0.5,
) -> httpx.Response:
    """GET ``url``, retrying transient failures with exponential backoff + jitter.

    Retries connection/timeout errors and 408/425/429/5xx responses. Any other
    4xx (e.g. 404) is treated as terminal and raised immediately — retrying it
    would only waste time.

    Args:
        client: An open ``httpx.AsyncClient``.
        url: The URL to GET.
        attempts: Maximum number of attempts (1 disables retrying).
        base_delay: Base backoff delay in seconds; also bounds the jitter, so
            ``0.0`` retries with no wait (useful in tests).
    """
    async for attempt in AsyncRetrying(
        stop=stop_after_attempt(max(1, attempts)),
        wait=wait_exponential_jitter(initial=base_delay, max=30.0, jitter=base_delay),
        retry=retry_if_exception_type((httpx.TransportError, _TransientStatus)),
        reraise=True,
    ):
        with attempt:
            resp = await client.get(url)
            if resp.status_code in _RETRYABLE_STATUS:
                raise _TransientStatus(f"HTTP {resp.status_code} for {url}")
            resp.raise_for_status()
            return resp
    raise RuntimeError("unreachable")  # pragma: no cover

get_image_ids(batch_id, *, base_url=IIIF_URL, timeout=IIIF_TIMEOUT, attempts=4, base_delay=0.5, headers=None) async

Fetch a IIIF manifest and extract image IDs.

Parameters:

Name Type Description Default
batch_id str

Volume/batch identifier (e.g. "C0074667").

required
base_url str

IIIF server base URL.

IIIF_URL
timeout float

HTTP request timeout in seconds.

IIIF_TIMEOUT
attempts int

Maximum manifest-fetch attempts (transient failures retried).

4
base_delay float

Base backoff delay in seconds between retries.

0.5
headers dict[str, str] | None

Extra HTTP headers sent with every request (e.g. Referer).

None

Returns:

Type Description
list[str]

Sorted list of image IDs (e.g. ["C0074667_00001", "C0074667_00002", ...]).

Source code in packages/rahcp-iiif/src/rahcp_iiif/manifest.py
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
async def get_image_ids(
    batch_id: str,
    *,
    base_url: str = IIIF_URL,
    timeout: float = IIIF_TIMEOUT,
    attempts: int = 4,
    base_delay: float = 0.5,
    headers: dict[str, str] | None = None,
) -> list[str]:
    """Fetch a IIIF manifest and extract image IDs.

    Args:
        batch_id: Volume/batch identifier (e.g. "C0074667").
        base_url: IIIF server base URL.
        timeout: HTTP request timeout in seconds.
        attempts: Maximum manifest-fetch attempts (transient failures retried).
        base_delay: Base backoff delay in seconds between retries.
        headers: Extra HTTP headers sent with every request (e.g. Referer).

    Returns:
        Sorted list of image IDs (e.g. ["C0074667_00001", "C0074667_00002", ...]).
    """
    manifest_url = f"{base_url}/arkis!{batch_id}/manifest"
    log.info("Fetching manifest: %s", manifest_url)

    async with httpx.AsyncClient(timeout=timeout, headers=headers) as client:
        resp = await fetch_with_retry(
            client, manifest_url, attempts=attempts, base_delay=base_delay
        )
        data = resp.json()

    image_ids = []
    for item in data.get("items", []):
        raw_id = item.get("id", "")
        if "!" in raw_id:
            image_id = raw_id.split("!")[1][:14].upper()
            image_ids.append(image_id)

    log.info("Found %d images in batch %s", len(image_ids), batch_id)
    return sorted(image_ids)

build_image_url(image_id, *, base_url=IIIF_URL, query_params=IIIF_QUERY_PARAMS)

Build the IIIF image URL for a given image ID.

Parameters:

Name Type Description Default
image_id str

Image identifier (e.g. "C0074667_00001").

required
base_url str

IIIF server base URL.

IIIF_URL
query_params str

IIIF image API parameters (region/size/rotation/quality.format).

IIIF_QUERY_PARAMS

Returns:

Type Description
str

Full image URL.

Source code in packages/rahcp-iiif/src/rahcp_iiif/manifest.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
def build_image_url(
    image_id: str,
    *,
    base_url: str = IIIF_URL,
    query_params: str = IIIF_QUERY_PARAMS,
) -> str:
    """Build the IIIF image URL for a given image ID.

    Args:
        image_id: Image identifier (e.g. "C0074667_00001").
        base_url: IIIF server base URL.
        query_params: IIIF image API parameters (region/size/rotation/quality.format).

    Returns:
        Full image URL.
    """
    return f"{base_url}/arkis!{image_id}/{query_params}"

file_extension(query_params=IIIF_QUERY_PARAMS)

Extract file extension from IIIF query params (e.g. '.jpg').

Source code in packages/rahcp-iiif/src/rahcp_iiif/manifest.py
123
124
125
def file_extension(query_params: str = IIIF_QUERY_PARAMS) -> str:
    """Extract file extension from IIIF query params (e.g. '.jpg')."""
    return "." + query_params.rsplit(".", 1)[-1]

Downloader

downloader

Async bulk IIIF image downloader with tracker-based resumability.

DownloadStats()

Running counters for a bulk IIIF download.

Source code in packages/rahcp-iiif/src/rahcp_iiif/downloader.py
39
40
41
42
43
44
def __init__(self) -> None:
    self.ok: int = 0
    self.skipped: int = 0
    self.errors: int = 0
    self.total_bytes: int = 0
    self._t0: float = time.monotonic()

download_batch(batch_id, output_dir, tracker, *, base_url=IIIF_URL, query_params=IIIF_QUERY_PARAMS, timeout=IIIF_TIMEOUT, headers=None, workers=4, max_images=None, validate_file=None, max_attempts=4, retry_base_delay=0.5, on_progress=None, on_error=None, progress_interval=5.0) async

Download all images from a IIIF batch to output_dir with parallel workers.

For streaming images straight to S3 without staging to disk, use rahcp_client.bulk.bulk_stream_upload (the rahcp iiif upload command).

Parameters:

Name Type Description Default
batch_id str

Volume/batch identifier (e.g. "C0074667").

required
output_dir Path

Local directory to save images into.

required
tracker TrackerProtocol

Transfer tracker for resumability.

required
base_url str

IIIF server base URL.

IIIF_URL
query_params str

IIIF image API parameters (region/size/rotation/quality.format).

IIIF_QUERY_PARAMS
timeout float

HTTP request timeout per image.

IIIF_TIMEOUT
headers dict[str, str] | None

Extra HTTP headers sent with every request (e.g. Referer).

None
workers int

Number of concurrent download workers.

4
max_images int | None

Limit number of images to download (None = all).

None
validate_file Callable[[Path], None] | None

Optional callback to validate each downloaded file.

None
max_attempts int

Max attempts per network fetch (manifest and each image). Transient failures (connection/timeout, 408/429/5xx) are retried; terminal ones (e.g. 404) are not. 1 disables retrying.

4
retry_base_delay float

Base backoff delay in seconds between retries.

0.5
on_progress Callable[[DownloadStats], None] | None

Optional callback for periodic progress updates.

None
on_error Callable[[str, Exception], None] | None

Optional callback for per-file errors.

None
progress_interval float

Minimum seconds between progress callbacks.

5.0

Returns:

Type Description
DownloadStats

Download statistics.

Source code in packages/rahcp-iiif/src/rahcp_iiif/downloader.py
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
async def download_batch(
    batch_id: str,
    output_dir: Path,
    tracker: TrackerProtocol,
    *,
    base_url: str = IIIF_URL,
    query_params: str = IIIF_QUERY_PARAMS,
    timeout: float = IIIF_TIMEOUT,
    headers: dict[str, str] | None = None,
    workers: int = 4,
    max_images: int | None = None,
    validate_file: Callable[[Path], None] | None = None,
    max_attempts: int = 4,
    retry_base_delay: float = 0.5,
    on_progress: Callable[[DownloadStats], None] | None = None,
    on_error: Callable[[str, Exception], None] | None = None,
    progress_interval: float = 5.0,
) -> DownloadStats:
    """Download all images from a IIIF batch to ``output_dir`` with parallel workers.

    For streaming images straight to S3 without staging to disk, use
    ``rahcp_client.bulk.bulk_stream_upload`` (the ``rahcp iiif upload`` command).

    Args:
        batch_id: Volume/batch identifier (e.g. "C0074667").
        output_dir: Local directory to save images into.
        tracker: Transfer tracker for resumability.
        base_url: IIIF server base URL.
        query_params: IIIF image API parameters (region/size/rotation/quality.format).
        timeout: HTTP request timeout per image.
        headers: Extra HTTP headers sent with every request (e.g. Referer).
        workers: Number of concurrent download workers.
        max_images: Limit number of images to download (None = all).
        validate_file: Optional callback to validate each downloaded file.
        max_attempts: Max attempts per network fetch (manifest and each image).
            Transient failures (connection/timeout, 408/429/5xx) are retried;
            terminal ones (e.g. 404) are not. 1 disables retrying.
        retry_base_delay: Base backoff delay in seconds between retries.
        on_progress: Optional callback for periodic progress updates.
        on_error: Optional callback for per-file errors.
        progress_interval: Minimum seconds between progress callbacks.

    Returns:
        Download statistics.
    """
    batch_dir = output_dir / batch_id
    batch_dir.mkdir(parents=True, exist_ok=True)

    image_ids = await get_image_ids(
        batch_id,
        base_url=base_url,
        timeout=timeout,
        attempts=max_attempts,
        base_delay=retry_base_delay,
        headers=headers,
    )
    if max_images:
        image_ids = image_ids[:max_images]

    ext = file_extension(query_params)

    done_keys = await asyncio.to_thread(tracker.done_keys)
    stats = DownloadStats()
    last_report = time.monotonic()
    queue: asyncio.Queue[str | _Signal] = asyncio.Queue(maxsize=workers * 8)

    log.info(
        "Downloading batch %s: %d images, %d already done, %d workers",
        batch_id,
        len(image_ids),
        len(done_keys),
        workers,
    )

    async def download_one(client: httpx.AsyncClient, image_id: str) -> None:
        """Download and optionally validate a single image."""
        key = f"{batch_id}/{image_id}{ext}"

        if key in done_keys:
            stats.skipped += 1
            return

        url = build_image_url(image_id, base_url=base_url, query_params=query_params)

        try:
            resp = await fetch_with_retry(
                client, url, attempts=max_attempts, base_delay=retry_base_delay
            )

            data = resp.content
            file_size = len(data)
            validated = False

            dest = batch_dir / f"{image_id}{ext}"
            await asyncio.to_thread(dest.write_bytes, data)

            # Post-download validation
            if validate_file:
                try:
                    await asyncio.to_thread(validate_file, dest)
                    validated = True
                except Exception as exc:
                    await asyncio.to_thread(dest.unlink, True)
                    tracker.mark(
                        key,
                        file_size,
                        TransferStatus.error,
                        error=f"validation: {exc!s}"[:200],
                    )
                    log.warning("Validation failed: %s%s", key, exc)
                    if on_error:
                        on_error(key, exc)
                    stats.errors += 1
                    return

            tracker.mark(
                key,
                file_size,
                TransferStatus.done,
                validated=validated,
            )
            stats.ok += 1
            stats.total_bytes += file_size

        except Exception as exc:
            tracker.mark(key, 0, TransferStatus.error, error=str(exc)[:200])
            log.warning("Download failed: %s%s", key, exc)
            if on_error:
                on_error(key, exc)
            stats.errors += 1

    async def worker(client: httpx.AsyncClient) -> None:
        while True:
            item = await queue.get()
            if item is _DONE:
                queue.task_done()
                break
            try:
                await download_one(client, item)
                nonlocal last_report
                now = time.monotonic()
                if on_progress and now - last_report >= progress_interval:
                    last_report = now
                    on_progress(stats)
            finally:
                queue.task_done()

    async def produce() -> None:
        for image_id in image_ids:
            await queue.put(image_id)
        for _ in range(workers):
            await queue.put(_DONE)

    async with httpx.AsyncClient(timeout=timeout, headers=headers) as client:
        worker_tasks = [asyncio.create_task(worker(client)) for _ in range(workers)]
        await asyncio.create_task(produce())
        await asyncio.gather(*worker_tasks)

    await asyncio.to_thread(tracker.commit)

    accounted = stats.ok + stats.skipped
    if accounted < len(image_ids):
        log.error(
            "batch %s incomplete: %d/%d images present (%d errors) — "
            "rerun, or use `rahcp iiif verify` to reconcile against the manifest",
            batch_id,
            accounted,
            len(image_ids),
            stats.errors,
        )

    return stats

download_batches(batch_ids, output_dir, tracker, **kwargs) async

Download multiple batches sequentially, sharing one tracker.

Parameters:

Name Type Description Default
batch_ids list[str]

List of batch identifiers.

required
output_dir Path

Root output directory.

required
tracker TrackerProtocol

Shared transfer tracker.

required
**kwargs

Forwarded to :func:download_batch.

{}

Returns:

Type Description
DownloadStats

Aggregated download statistics.

Source code in packages/rahcp-iiif/src/rahcp_iiif/downloader.py
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
async def download_batches(
    batch_ids: list[str],
    output_dir: Path,
    tracker: TrackerProtocol,
    **kwargs,
) -> DownloadStats:
    """Download multiple batches sequentially, sharing one tracker.

    Args:
        batch_ids: List of batch identifiers.
        output_dir: Root output directory.
        tracker: Shared transfer tracker.
        **kwargs: Forwarded to :func:`download_batch`.

    Returns:
        Aggregated download statistics.
    """
    combined = DownloadStats()

    for batch_id in batch_ids:
        batch_id = batch_id.strip()
        if not batch_id:
            continue

        stats = await download_batch(
            batch_id,
            output_dir,
            tracker,
            **kwargs,
        )
        combined.ok += stats.ok
        combined.skipped += stats.skipped
        combined.errors += stats.errors
        combined.total_bytes += stats.total_bytes

    await asyncio.to_thread(tracker.commit)
    return combined

rahcp-transkribus

Client

client

Minimal async Transkribus TRP REST client (httpx + tenacity).

Reimplements just the handful of read calls an export needs — login, list documents, list pages, fetch transcript/image bytes — against the Transkribus REST API. Auth is a JSESSIONID cookie obtained from /auth/login.

TranskribusClient(username, password, *, base_url=TRANSKRIBUS_URL, timeout=TRANSKRIBUS_TIMEOUT, verify_ssl=None, max_attempts=4, retry_base_delay=0.5)

Async client for the Transkribus TRP REST API.

Use as an async context manager — it logs in on entry and out on exit::

async with TranskribusClient(user, pw) as client:
    docs = await client.list_docs(collection_id)

Parameters:

Name Type Description Default
username str

Transkribus login (email).

required
password str

Transkribus password.

required
base_url str

TRP REST base URL.

TRANSKRIBUS_URL
timeout float

Per-request timeout in seconds.

TRANSKRIBUS_TIMEOUT
verify_ssl bool | None

TLS verification; None honours SKIP_SSL_VERIFY.

None
max_attempts int

Max attempts per request (transient failures retried).

4
retry_base_delay float

Base backoff delay in seconds between retries.

0.5
Source code in packages/rahcp-transkribus/src/rahcp_transkribus/client.py
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def __init__(
    self,
    username: str,
    password: str,
    *,
    base_url: str = TRANSKRIBUS_URL,
    timeout: float = TRANSKRIBUS_TIMEOUT,
    verify_ssl: bool | None = None,
    max_attempts: int = 4,
    retry_base_delay: float = 0.5,
) -> None:
    self._username = username
    self._password = password
    self._base_url = base_url.rstrip("/")
    self._timeout = timeout
    self._verify_ssl = resolve_verify_ssl(verify_ssl)
    self._max_attempts = max_attempts
    self._retry_base_delay = retry_base_delay
    self._client: httpx.AsyncClient | None = None
    self._session_id: str | None = None

base_url property

The TRP REST base URL (no trailing slash).

session_id property

The active JSESSIONID, or None before login.

list_docs(collection_id) async

List all documents in a collection.

Source code in packages/rahcp-transkribus/src/rahcp_transkribus/client.py
194
195
196
197
198
199
200
201
async def list_docs(self, collection_id: int) -> list[Document]:
    """List all documents in a collection."""
    resp = await self._request(
        "GET",
        f"/collections/{collection_id}/list",
        params={"index": 0, "nValues": 0},
    )
    return [Document.model_validate(d) for d in resp.json()]

get_pages(collection_id, doc_id, *, status=None, skip_pages_with_missing_status=False, pages=None) async

List pages of a document, filtered server-side by transcript status.

Parameters:

Name Type Description Default
collection_id int

Collection ID.

required
doc_id int

Document ID.

required
status str | None

Transcript status filter (e.g. "GT", "FINAL"); each returned page's tsList is restricted to versions with it.

None
skip_pages_with_missing_status bool

Omit pages that have no transcript with status.

False
pages str | None

Optional page-range string (e.g. "1-5,8").

None
Source code in packages/rahcp-transkribus/src/rahcp_transkribus/client.py
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
async def get_pages(
    self,
    collection_id: int,
    doc_id: int,
    *,
    status: str | None = None,
    skip_pages_with_missing_status: bool = False,
    pages: str | None = None,
) -> list[Page]:
    """List pages of a document, filtered server-side by transcript status.

    Args:
        collection_id: Collection ID.
        doc_id: Document ID.
        status: Transcript status filter (e.g. ``"GT"``, ``"FINAL"``); each
            returned page's ``tsList`` is restricted to versions with it.
        skip_pages_with_missing_status: Omit pages that have no transcript
            with ``status``.
        pages: Optional page-range string (e.g. ``"1-5,8"``).
    """
    params: dict[str, object] = {
        "skipPagesWithMissingStatus": skip_pages_with_missing_status
    }
    if status:
        params["status"] = status
    if pages:
        params["pages"] = pages
    resp = await self._request(
        "GET", f"/collections/{collection_id}/{doc_id}/pages", params=params
    )
    return [Page.model_validate(p) for p in resp.json()]

get_transcript_text(collection_id, doc_id, page_nr) async

Fetch the current PAGE-XML transcript for a page via the /text endpoint.

Source code in packages/rahcp-transkribus/src/rahcp_transkribus/client.py
235
236
237
238
239
240
241
242
async def get_transcript_text(
    self, collection_id: int, doc_id: int, page_nr: int
) -> bytes:
    """Fetch the current PAGE-XML transcript for a page via the ``/text`` endpoint."""
    resp = await self._request(
        "GET", f"/collections/{collection_id}/{doc_id}/{page_nr}/text"
    )
    return resp.content

fetch_bytes(url) async

GET raw bytes from a URL (absolute or base-relative).

Used for page images and transcript file-store URLs. The session cookie rides along automatically for same-host (REST) URLs and is harmlessly ignored by the file store.

Source code in packages/rahcp-transkribus/src/rahcp_transkribus/client.py
244
245
246
247
248
249
250
251
252
async def fetch_bytes(self, url: str) -> bytes:
    """GET raw bytes from a URL (absolute or base-relative).

    Used for page images and transcript file-store URLs. The session cookie
    rides along automatically for same-host (REST) URLs and is harmlessly
    ignored by the file store.
    """
    resp = await self._request("GET", url)
    return resp.content

Exporter

exporter

Plan and run Transkribus collection exports (local dir or, via the CLI, a bucket).

plan_collection_export walks a collection into a flat list of :class:ExportItem (destination key + source URL). export_collection runs that plan to a local directory with parallel, tracker-resumable workers. The bucket path lives in the CLI, which feeds the same items to rahcp_client.bulk.bulk_stream_upload — this package stays HCP-agnostic.

ExportFormat

Bases: StrEnum

Transcript output format.

sanitize_filename(title)

Make a document title safe for use as a path segment.

Source code in packages/rahcp-transkribus/src/rahcp_transkribus/exporter.py
43
44
45
46
def sanitize_filename(title: str) -> str:
    """Make a document title safe for use as a path segment."""
    cleaned = "".join(c if c.isalnum() or c in " _-" else "_" for c in title).strip()
    return cleaned or "untitled"

plan_collection_export(client, collection_id, *, status='GT', fmt=ExportFormat.page, skip_missing=True, include_images=True, doc_ids=None) async

Resolve a collection into a flat list of export items.

Parameters:

Name Type Description Default
client TranskribusClient

An entered :class:TranskribusClient.

required
collection_id int

Collection to export.

required
status str | None

Transcript status filter (e.g. "GT"; None = any).

'GT'
fmt ExportFormat

Transcript output format (page or alto).

page
skip_missing bool

Skip pages with no transcript at status.

True
include_images bool

Also export each page's image.

True
doc_ids Iterable[int] | None

Restrict to these document IDs (None = whole collection).

None

Returns:

Type Description
list[ExportItem]

Export items across all matching documents. Keys are relative

list[ExportItem]

({collection}/{doc_slug}/{page|alto|images}/{name}).

Source code in packages/rahcp-transkribus/src/rahcp_transkribus/exporter.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
async def plan_collection_export(
    client: TranskribusClient,
    collection_id: int,
    *,
    status: str | None = "GT",
    fmt: ExportFormat = ExportFormat.page,
    skip_missing: bool = True,
    include_images: bool = True,
    doc_ids: Iterable[int] | None = None,
) -> list[ExportItem]:
    """Resolve a collection into a flat list of export items.

    Args:
        client: An entered :class:`TranskribusClient`.
        collection_id: Collection to export.
        status: Transcript status filter (e.g. ``"GT"``; ``None`` = any).
        fmt: Transcript output format (``page`` or ``alto``).
        skip_missing: Skip pages with no transcript at ``status``.
        include_images: Also export each page's image.
        doc_ids: Restrict to these document IDs (``None`` = whole collection).

    Returns:
        Export items across all matching documents. Keys are relative
        (``{collection}/{doc_slug}/{page|alto|images}/{name}``).
    """
    docs = await client.list_docs(collection_id)
    if doc_ids is not None:
        wanted = set(doc_ids)
        docs = [d for d in docs if d.doc_id in wanted]

    items: list[ExportItem] = []
    for doc in docs:
        slug = f"{doc.doc_id}_{sanitize_filename(doc.title)}"
        pages = await client.get_pages(
            collection_id,
            doc.doc_id,
            status=status,
            skip_pages_with_missing_status=skip_missing,
        )
        for page in pages:
            items.extend(
                _plan_page(
                    page,
                    collection_id=collection_id,
                    doc_id=doc.doc_id,
                    slug=slug,
                    fmt=fmt,
                    include_images=include_images,
                    base_url=client.base_url,
                )
            )
    log.info(
        "Planned %d export items for collection %d (%d docs)",
        len(items),
        collection_id,
        len(docs),
    )
    return items

fetch_item_bytes(client, item) async

Fetch an item's bytes, converting PAGE→ALTO when the item requests it.

Source code in packages/rahcp-transkribus/src/rahcp_transkribus/exporter.py
167
168
169
170
171
172
async def fetch_item_bytes(client: TranskribusClient, item: ExportItem) -> bytes:
    """Fetch an item's bytes, converting PAGE→ALTO when the item requests it."""
    data = await client.fetch_bytes(item.url)
    if item.convert_alto:
        data = await asyncio.to_thread(page_bytes_to_alto, data)
    return data

compute_stale_keys(items, synced, *, prefix='')

Return the tracker keys of transcripts whose version changed since last sync.

A transcript is stale when it was synced before (synced has its key) but its current ts_id differs — i.e. it was re-corrected in Transkribus. New transcripts (never synced) are not stale; the normal done-keys logic uploads them. Images carry no ts_id and are never stale.

Source code in packages/rahcp-transkribus/src/rahcp_transkribus/exporter.py
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
def compute_stale_keys(
    items: list[ExportItem],
    synced: dict[str, str],
    *,
    prefix: str = "",
) -> set[str]:
    """Return the tracker keys of transcripts whose version changed since last sync.

    A transcript is *stale* when it was synced before (``synced`` has its key) but
    its current ``ts_id`` differs — i.e. it was re-corrected in Transkribus. New
    transcripts (never synced) are not stale; the normal done-keys logic uploads
    them. Images carry no ``ts_id`` and are never stale.
    """
    stale: set[str] = set()
    for item in items:
        if item.kind is not ItemKind.transcript or item.ts_id is None:
            continue
        key = _tracker_key(item, prefix)
        previous = synced.get(key)
        if previous is not None and previous != str(item.ts_id):
            stale.add(key)
    return stale

transcript_versions(items, done_keys, *, prefix='')

Map {tracker_key: ts_id} for every transcript that is now done.

Used to update the version store after a run so the next run can detect the next round of corrections.

Source code in packages/rahcp-transkribus/src/rahcp_transkribus/exporter.py
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
def transcript_versions(
    items: list[ExportItem],
    done_keys: set[str],
    *,
    prefix: str = "",
) -> dict[str, str]:
    """Map ``{tracker_key: ts_id}`` for every transcript that is now ``done``.

    Used to update the version store after a run so the next run can detect the
    next round of corrections.
    """
    versions: dict[str, str] = {}
    for item in items:
        if item.kind is not ItemKind.transcript or item.ts_id is None:
            continue
        key = _tracker_key(item, prefix)
        if key in done_keys:
            versions[key] = str(item.ts_id)
    return versions

refresh_stale_transcripts(items, tracker, store, done_keys, *, prefix='') async

Drop changed transcripts from the tracker so a re-run re-fetches them.

Compares each transcript's current ts_id to the version store, deletes the stale keys from tracker and the in-memory done_keys set, and returns the stale keys. Call before running the transfer; pair with an overwrite conflict policy so the bucket object is actually replaced.

Source code in packages/rahcp-transkribus/src/rahcp_transkribus/exporter.py
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
async def refresh_stale_transcripts(
    items: list[ExportItem],
    tracker: TrackerProtocol,
    store: TranscriptVersionStore,
    done_keys: set[str],
    *,
    prefix: str = "",
) -> set[str]:
    """Drop changed transcripts from the tracker so a re-run re-fetches them.

    Compares each transcript's current ``ts_id`` to the version store, deletes the
    stale keys from ``tracker`` and the in-memory ``done_keys`` set, and returns
    the stale keys. Call before running the transfer; pair with an *overwrite*
    conflict policy so the bucket object is actually replaced.
    """
    synced = await asyncio.to_thread(store.get_all)
    stale = compute_stale_keys(items, synced, prefix=prefix)
    for key in stale:
        await asyncio.to_thread(tracker.delete, key)
        done_keys.discard(key)
    if stale:
        log.info(
            "Change detection: %d transcript(s) changed since last sync", len(stale)
        )
    return stale

export_collection(client, collection_id, output_dir, tracker, *, status='GT', fmt=ExportFormat.page, skip_missing=True, include_images=True, prefix='', workers=8, doc_ids=None, validate_file=None, version_db=None, on_progress=None, on_error=None, progress_interval=5.0) async

Export a Transkribus collection to a local directory, resumably.

Files already recorded done in tracker are skipped instantly. Each item is fetched (transcripts optionally converted to ALTO), written under output_dir/<prefix><key>, and marked in the tracker.

Parameters:

Name Type Description Default
client TranskribusClient

An entered :class:TranskribusClient.

required
collection_id int

Collection to export.

required
output_dir Path

Local root directory to write into.

required
tracker TrackerProtocol

Transfer tracker for resumability.

required
status str | None

Transcript status filter (e.g. "GT").

'GT'
fmt ExportFormat

Transcript output format (page or alto).

page
skip_missing bool

Skip pages with no transcript at status.

True
include_images bool

Also export each page's image.

True
prefix str

Key prefix prepended to every item key.

''
workers int

Number of concurrent workers.

8
doc_ids Iterable[int] | None

Restrict to these document IDs (None = whole collection).

None
validate_file Callable[[Path], None] | None

Optional per-file validation callback.

None
version_db Path | None

Enable change detection — a transcript re-corrected in Transkribus (new tsId) is re-fetched even though its path is unchanged. State is kept in this SQLite file.

None
on_progress Callable[[ExportStats], None] | None

Optional periodic progress callback.

None
on_error Callable[[str, Exception], None] | None

Optional per-item error callback.

None
progress_interval float

Minimum seconds between progress callbacks.

5.0

Returns:

Type Description
ExportStats

Export statistics.

Source code in packages/rahcp-transkribus/src/rahcp_transkribus/exporter.py
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
async def export_collection(
    client: TranskribusClient,
    collection_id: int,
    output_dir: Path,
    tracker: TrackerProtocol,
    *,
    status: str | None = "GT",
    fmt: ExportFormat = ExportFormat.page,
    skip_missing: bool = True,
    include_images: bool = True,
    prefix: str = "",
    workers: int = 8,
    doc_ids: Iterable[int] | None = None,
    validate_file: Callable[[Path], None] | None = None,
    version_db: Path | None = None,
    on_progress: Callable[[ExportStats], None] | None = None,
    on_error: Callable[[str, Exception], None] | None = None,
    progress_interval: float = 5.0,
) -> ExportStats:
    """Export a Transkribus collection to a local directory, resumably.

    Files already recorded ``done`` in ``tracker`` are skipped instantly.
    Each item is fetched (transcripts optionally converted to ALTO), written
    under ``output_dir/<prefix><key>``, and marked in the tracker.

    Args:
        client: An entered :class:`TranskribusClient`.
        collection_id: Collection to export.
        output_dir: Local root directory to write into.
        tracker: Transfer tracker for resumability.
        status: Transcript status filter (e.g. ``"GT"``).
        fmt: Transcript output format (``page`` or ``alto``).
        skip_missing: Skip pages with no transcript at ``status``.
        include_images: Also export each page's image.
        prefix: Key prefix prepended to every item key.
        workers: Number of concurrent workers.
        doc_ids: Restrict to these document IDs (``None`` = whole collection).
        validate_file: Optional per-file validation callback.
        version_db: Enable change detection — a transcript re-corrected in
            Transkribus (new ``tsId``) is re-fetched even though its path is
            unchanged. State is kept in this SQLite file.
        on_progress: Optional periodic progress callback.
        on_error: Optional per-item error callback.
        progress_interval: Minimum seconds between progress callbacks.

    Returns:
        Export statistics.
    """
    items = await plan_collection_export(
        client,
        collection_id,
        status=status,
        fmt=fmt,
        skip_missing=skip_missing,
        include_images=include_images,
        doc_ids=doc_ids,
    )

    done_keys = await asyncio.to_thread(tracker.done_keys)

    store = TranscriptVersionStore(version_db) if version_db is not None else None
    if store is not None:
        await refresh_stale_transcripts(items, tracker, store, done_keys, prefix=prefix)
    t0 = time.monotonic()
    stats = ExportStats()
    last_report = time.monotonic()
    queue: asyncio.Queue[ExportItem | _Signal] = asyncio.Queue(maxsize=workers * 8)

    log.info(
        "Exporting collection %d%s: %d items, %d already done, %d workers",
        collection_id,
        output_dir,
        len(items),
        len(done_keys),
        workers,
    )

    def _key(item: ExportItem) -> str:
        return f"{prefix}{item.key}" if prefix else item.key

    async def export_one(item: ExportItem) -> None:
        key = _key(item)
        if key in done_keys:
            stats.skipped += 1
            return

        dest = output_dir / key
        try:
            data = await fetch_item_bytes(client, item)
        except Exception as exc:
            tracker.mark(key, 0, TransferStatus.error, error=f"download: {exc!s}"[:200])
            log.warning("Export failed: %s%s", key, exc)
            if on_error:
                on_error(key, exc)
            stats.errors += 1
            return

        file_size = len(data)
        await asyncio.to_thread(dest.parent.mkdir, parents=True, exist_ok=True)
        await asyncio.to_thread(dest.write_bytes, data)

        validated = False
        if validate_file:
            try:
                await asyncio.to_thread(validate_file, dest)
                validated = True
            except Exception as exc:
                await asyncio.to_thread(dest.unlink, True)
                tracker.mark(
                    key,
                    file_size,
                    TransferStatus.error,
                    error=f"validation: {exc!s}"[:200],
                )
                log.warning("Validation failed: %s%s", key, exc)
                if on_error:
                    on_error(key, exc)
                stats.errors += 1
                return

        tracker.mark(key, file_size, TransferStatus.done, validated=validated)
        done_keys.add(key)
        stats.ok += 1
        stats.total_bytes += file_size

    async def worker() -> None:
        nonlocal last_report
        while True:
            item = await queue.get()
            if isinstance(item, _Signal):
                queue.task_done()
                break
            try:
                await export_one(item)
                now = time.monotonic()
                if on_progress and now - last_report >= progress_interval:
                    last_report = now
                    stats.elapsed = now - t0
                    on_progress(stats)
            finally:
                queue.task_done()

    async def produce() -> None:
        for item in items:
            await queue.put(item)
        for _ in range(workers):
            await queue.put(_DONE)

    worker_tasks = [asyncio.create_task(worker()) for _ in range(workers)]
    await produce()
    await asyncio.gather(*worker_tasks)

    await asyncio.to_thread(tracker.commit)
    stats.elapsed = time.monotonic() - t0

    if store is not None:
        await asyncio.to_thread(
            store.set_many, transcript_versions(items, done_keys, prefix=prefix)
        )
        store.close()

    accounted = stats.ok + stats.skipped
    if accounted < len(items):
        log.error(
            "collection %d export incomplete: %d/%d items present (%d errors) — rerun to resume",
            collection_id,
            accounted,
            len(items),
            stats.errors,
        )
    return stats

Models

models

Pydantic models for the Transkribus REST API and export planning.

Transcript

Bases: BaseModel

One transcript version attached to a page (newest matching first).

TsList

Bases: BaseModel

Wrapper around a page's transcript versions.

Page

Bases: BaseModel

A single page in a Transkribus document.

Document

Bases: BaseModel

Document metadata as returned by the collection listing.

ItemKind

Bases: StrEnum

What an export item holds — a page transcript or its page image.

ExportItem

Bases: BaseModel

One planned export unit: a destination key and where to fetch its bytes.

key is a relative object key (no bucket prefix) usable both as a local path under an output directory and as an S3 key. url is the source URL to GET. When convert_alto is set, the fetched PAGE-XML is converted to ALTO before being written/uploaded.

ts_id = None class-attribute instance-attribute

Transkribus transcript version id — set for transcripts, drives change detection.

ExportStats

Bases: BaseModel

Counters for a local Transkribus export.

done property

Total items processed (ok + skipped + errors).

mb_per_sec property

Throughput in megabytes per second.

Change Detection

versions

Persistent record of the last-synced Transkribus transcript version per key.

Transkribus versions transcripts server-side: re-correcting a page's ground truth creates a new transcript version with a new tsId, but the exported object key (derived from the page filename) is unchanged. The transfer tracker keys on that path, so it would treat the corrected transcript as already-done and skip it.

This store remembers the tsId last synced for each object key. Comparing the current tsId (from tsList.transcripts[0]) to the stored one tells a scheduled re-run which transcripts changed and must be re-fetched — see rahcp_transkribus.exporter.compute_stale_keys.

TranscriptVersionStore(db_path)

SQLite-backed map of object key → last-synced transcript tsId.

A tiny sidecar DB (independent of the transfer tracker) so change detection adds no coupling to the generic tracker/bulk-transfer packages.

Source code in packages/rahcp-transkribus/src/rahcp_transkribus/versions.py
28
29
30
31
32
33
34
35
36
37
38
39
def __init__(self, db_path: Path) -> None:
    self._path = db_path
    db_path.parent.mkdir(parents=True, exist_ok=True)
    # check_same_thread=False: calls are serialized (plan/record boundaries,
    # never concurrent), but may arrive from an asyncio.to_thread worker.
    self._conn = sqlite3.connect(str(db_path), check_same_thread=False)
    self._conn.execute("PRAGMA journal_mode=WAL")
    self._conn.execute(
        "CREATE TABLE IF NOT EXISTS transcript_versions ("
        "key TEXT PRIMARY KEY, ts_id TEXT NOT NULL)"
    )
    self._conn.commit()

path property

Path to the backing SQLite file.

get_all()

Return the full {key: ts_id} map of previously-synced transcripts.

Source code in packages/rahcp-transkribus/src/rahcp_transkribus/versions.py
46
47
48
49
def get_all(self) -> dict[str, str]:
    """Return the full ``{key: ts_id}`` map of previously-synced transcripts."""
    cursor = self._conn.execute("SELECT key, ts_id FROM transcript_versions")
    return dict(cursor.fetchall())

set_many(versions)

Upsert {key: ts_id} (last write wins per key).

Source code in packages/rahcp-transkribus/src/rahcp_transkribus/versions.py
51
52
53
54
55
56
57
58
59
60
def set_many(self, versions: dict[str, str]) -> None:
    """Upsert ``{key: ts_id}`` (last write wins per key)."""
    if not versions:
        return
    self._conn.executemany(
        "INSERT INTO transcript_versions(key, ts_id) VALUES(?, ?) "
        "ON CONFLICT(key) DO UPDATE SET ts_id=excluded.ts_id",
        list(versions.items()),
    )
    self._conn.commit()

close()

Close the backing connection.

Source code in packages/rahcp-transkribus/src/rahcp_transkribus/versions.py
62
63
64
def close(self) -> None:
    """Close the backing connection."""
    self._conn.close()

ALTO Conversion

alto

Optional PAGE-XML → ALTO-XML conversion via the page-to-alto CLI.

Requires the alto extra (pip install "rahcp-transkribus[alto]"), which pulls in ocrd-page-to-alto and its page-to-alto console script. The conversion shells out to that binary — a one-way PAGE→ALTO transform.

alto_available()

Return whether the page-to-alto binary is on PATH.

Source code in packages/rahcp-transkribus/src/rahcp_transkribus/alto.py
23
24
25
def alto_available() -> bool:
    """Return whether the ``page-to-alto`` binary is on ``PATH``."""
    return shutil.which(_ALTO_BINARY) is not None

page_bytes_to_alto(page_xml, *, alto_version='4.2')

Convert PAGE-XML bytes to ALTO-XML bytes.

Parameters:

Name Type Description Default
page_xml bytes

PAGE-XML document bytes.

required
alto_version str

Target ALTO schema version.

'4.2'

Returns:

Type Description
bytes

ALTO-XML document bytes.

Raises:

Type Description
TranskribusError

If page-to-alto is not installed or the conversion fails.

Source code in packages/rahcp-transkribus/src/rahcp_transkribus/alto.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
def page_bytes_to_alto(page_xml: bytes, *, alto_version: str = "4.2") -> bytes:
    """Convert PAGE-XML bytes to ALTO-XML bytes.

    Args:
        page_xml: PAGE-XML document bytes.
        alto_version: Target ALTO schema version.

    Returns:
        ALTO-XML document bytes.

    Raises:
        TranskribusError: If ``page-to-alto`` is not installed or the
            conversion fails.
    """
    if not alto_available():
        raise TranskribusError(
            "page-to-alto not found — install the ALTO extra: "
            'pip install "rahcp-transkribus[alto]"'
        )

    with tempfile.TemporaryDirectory(prefix="rahcp-alto-") as tmp:
        page_path = Path(tmp) / "page.xml"
        page_path.write_bytes(page_xml)
        try:
            result = subprocess.run(  # noqa: S603 — fixed binary, no shell
                [
                    _ALTO_BINARY,
                    "--alto-version",
                    alto_version,
                    "--no-check-words",
                    "--no-check-border",
                    "--dummy-word",
                    str(page_path),
                ],
                capture_output=True,
                check=False,
            )
        except OSError as exc:
            raise TranskribusError(f"page-to-alto failed to run: {exc}") from exc

    if result.returncode != 0:
        stderr = result.stderr.decode("utf-8", "replace").strip()
        raise TranskribusError(f"page-to-alto conversion failed: {stderr}")
    return result.stdout

rahcp-cli

S3 Commands

s3

S3 CLI subcommands — thin wrappers over rahcp_client.

ls(ctx, bucket=typer.Argument(None), prefix=typer.Option('', '--prefix', '-p', help='Filter by key prefix'), max_keys=typer.Option(100, '--max-keys', '-n', help='Max results per page'), page=typer.Option(None, '--page', help='Continuation token for next page'), delimiter=typer.Option(None, '--delimiter', '-d', help='Group by delimiter (e.g. /)'), filter_key=typer.Option(None, '--filter', '-f', help='Filter keys containing this string'))

List buckets (no args) or objects in a bucket.

Source code in packages/rahcp-cli/src/rahcp_cli/s3.py
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
@app.command("ls")
def ls(
    ctx: typer.Context,
    bucket: str = typer.Argument(None),
    prefix: str = typer.Option("", "--prefix", "-p", help="Filter by key prefix"),
    max_keys: int = typer.Option(100, "--max-keys", "-n", help="Max results per page"),
    page: str = typer.Option(None, "--page", help="Continuation token for next page"),
    delimiter: str = typer.Option(
        None, "--delimiter", "-d", help="Group by delimiter (e.g. /)"
    ),
    filter_key: str = typer.Option(
        None, "--filter", "-f", help="Filter keys containing this string"
    ),
) -> None:
    """List buckets (no args) or objects in a bucket."""

    async def _run() -> None:
        async with make_client(ctx) as client:
            if bucket is None:
                data = await client.s3.list_buckets()
                if ctx.obj["json"]:
                    print_json(data)
                else:
                    buckets = data.get("buckets") or data.get("Buckets") or []
                    print_table(buckets, title="Buckets")
                return

            data = await client.s3.list_objects(
                bucket,
                prefix,
                max_keys=max_keys,
                continuation_token=page,
                delimiter=delimiter,
            )
            if ctx.obj["json"]:
                print_json(data)
                return

            rows = _format_object_rows(data)
            if filter_key:
                rows = [r for r in rows if filter_key in r.get("Key", "")]
            title = f"s3://{bucket}/{prefix}" if prefix else f"s3://{bucket}"
            print_table(rows, columns=["Key", "Size", "LastModified"], title=title)

            next_token = data.get("next_continuation_token")
            if next_token:
                console.print(
                    f"\n[dim]More results — next page:[/dim]\n"
                    f"  rahcp s3 ls {bucket} --page {next_token}"
                )

    run(_run())

upload(ctx, bucket=typer.Argument(...), key=typer.Argument(...), file=typer.Argument(..., exists=True))

Upload a local file (auto multipart if large).

Source code in packages/rahcp-cli/src/rahcp_cli/s3.py
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
@app.command()
def upload(
    ctx: typer.Context,
    bucket: str = typer.Argument(...),
    key: str = typer.Argument(...),
    file: Path = typer.Argument(..., exists=True),
) -> None:
    """Upload a local file (auto multipart if large)."""

    async def _run() -> None:
        async with make_client(ctx) as client:
            etag = await client.s3.upload(bucket, key, file)
            console.print(f"[green]Uploaded[/green] s3://{bucket}/{key} (etag: {etag})")

    run(_run())

download(ctx, bucket=typer.Argument(...), key=typer.Argument(...), output=typer.Option(None, '--output', '-o'))

Download an object via presigned URL.

Source code in packages/rahcp-cli/src/rahcp_cli/s3.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
@app.command()
def download(
    ctx: typer.Context,
    bucket: str = typer.Argument(...),
    key: str = typer.Argument(...),
    output: Path = typer.Option(None, "--output", "-o"),
) -> None:
    """Download an object via presigned URL."""
    dest = output or Path(key.split("/")[-1])

    async def _run() -> None:
        async with make_client(ctx) as client:
            size = await client.s3.download(bucket, key, dest)
            console.print(f"[green]Downloaded[/green] {dest} ({size:,} bytes)")

    run(_run())

rm(ctx, bucket=typer.Argument(...), keys=typer.Argument(...))

Delete one or more objects.

Source code in packages/rahcp-cli/src/rahcp_cli/s3.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
@app.command()
def rm(
    ctx: typer.Context,
    bucket: str = typer.Argument(...),
    keys: list[str] = typer.Argument(...),
) -> None:
    """Delete one or more objects."""

    async def _run() -> None:
        async with make_client(ctx) as client:
            if len(keys) == 1:
                await client.s3.delete(bucket, keys[0])
            else:
                await client.s3.delete_bulk(bucket, keys)
            console.print(f"[green]Deleted[/green] {len(keys)} object(s)")

    run(_run())

presign(ctx, bucket=typer.Argument(...), key=typer.Argument(...), expires=typer.Option(3600, '--expires'))

Get a presigned download URL.

Source code in packages/rahcp-cli/src/rahcp_cli/s3.py
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
@app.command()
def presign(
    ctx: typer.Context,
    bucket: str = typer.Argument(...),
    key: str = typer.Argument(...),
    expires: int = typer.Option(3600, "--expires"),
) -> None:
    """Get a presigned download URL."""

    async def _run() -> None:
        async with make_client(ctx) as client:
            url = await client.s3.presign_get(bucket, key, expires=expires)
            if ctx.obj["json"]:
                print_json({"url": url})
            else:
                console.print(url)

    run(_run())

upload_all(ctx, bucket=typer.Argument(...), source_dir=typer.Argument(..., help='Local directory to upload'), prefix=typer.Option('', '--prefix', '-p', help='Key prefix to prepend'), workers=typer.Option(0, '--workers', '-w', help='Concurrent workers (0 = use config)'), skip_existing=typer.Option(True, '--skip-existing/--overwrite', help='Skip files that already exist with matching size'), retry_errors=typer.Option(False, '--retry-errors', help='Only retry previously failed files'), include=typer.Option([], '--include', '-I', help="Only upload files matching these glob patterns (e.g. '*.jpg')"), exclude=typer.Option([], '--exclude', '-E', help="Skip files matching these glob patterns (e.g. '*.tmp')"), validate=typer.Option(False, '--validate', help='Validate each file before upload (auto-detects format by extension)'), verify=typer.Option(False, '--verify', help='Verify each upload by checking remote size after transfer'), tracker_db=typer.Option(None, '--tracker-db', envvar='RAHCP_TRACKER_DB', help='Tracker DB: file path or postgresql:// DSN (overrides prefix and default)'), tracker_prefix=typer.Option(None, '--tracker-prefix', help="Prefix for tracker DB name (e.g. 'andraarkiv' → andraarkiv.upload-tracker.db)"), presign_batch_size=typer.Option(0, '--presign-batch-size', help='Presign URLs in batches of this size (0 = use config default)'))

Upload a directory to S3 with tracked resume and parallel workers.

Source code in packages/rahcp-cli/src/rahcp_cli/s3.py
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
@app.command("upload-all")
def upload_all(
    ctx: typer.Context,
    bucket: str = typer.Argument(...),
    source_dir: str = typer.Argument(..., help="Local directory to upload"),
    prefix: str = typer.Option("", "--prefix", "-p", help="Key prefix to prepend"),
    workers: int = typer.Option(
        0, "--workers", "-w", help="Concurrent workers (0 = use config)"
    ),
    skip_existing: bool = typer.Option(
        True,
        "--skip-existing/--overwrite",
        help="Skip files that already exist with matching size",
    ),
    retry_errors: bool = typer.Option(
        False, "--retry-errors", help="Only retry previously failed files"
    ),
    include: list[str] = typer.Option(
        [],
        "--include",
        "-I",
        help="Only upload files matching these glob patterns (e.g. '*.jpg')",
    ),
    exclude: list[str] = typer.Option(
        [],
        "--exclude",
        "-E",
        help="Skip files matching these glob patterns (e.g. '*.tmp')",
    ),
    validate: bool = typer.Option(
        False,
        "--validate",
        help="Validate each file before upload (auto-detects format by extension)",
    ),
    verify: bool = typer.Option(
        False,
        "--verify",
        help="Verify each upload by checking remote size after transfer",
    ),
    tracker_db: str | None = typer.Option(
        None,
        "--tracker-db",
        envvar="RAHCP_TRACKER_DB",
        help="Tracker DB: file path or postgresql:// DSN (overrides prefix and default)",
    ),
    tracker_prefix: str | None = typer.Option(
        None,
        "--tracker-prefix",
        help="Prefix for tracker DB name (e.g. 'andraarkiv' → andraarkiv.upload-tracker.db)",
    ),
    presign_batch_size: int = typer.Option(
        0,
        "--presign-batch-size",
        help="Presign URLs in batches of this size (0 = use config default)",
    ),
) -> None:
    """Upload a directory to S3 with tracked resume and parallel workers."""

    async def _run() -> None:
        from rahcp_client.bulk import BulkUploadConfig, bulk_upload

        src = Path(source_dir)
        if not src.is_dir():
            console.print(f"[red]Not a directory: {src}[/red]")
            raise SystemExit(1)

        validate_fn = _get_validator() if validate else None

        tracker, db_path = _resolve_tracker(
            ctx, tracker_db, ".upload-tracker.db", prefix=tracker_prefix
        )
        effective_workers = _resolve_workers(workers, ctx)

        done_count = len(await asyncio.to_thread(tracker.done_keys))
        console.print(f"Tracker: {db_path}{done_count} already done")
        flags = []
        if include:
            flags.append(f"include={include}")
        if exclude:
            flags.append(f"exclude={exclude}")
        if validate:
            flags.append("validate")
        if verify:
            flags.append("verify")
        flag_str = f" [{', '.join(flags)}]" if flags else ""
        console.print(
            f"Uploading {src}/ → s3://{bucket}/{prefix} ({effective_workers} workers){flag_str}"
        )

        async with make_client(ctx) as client:
            stats = await bulk_upload(
                BulkUploadConfig(
                    client=client,
                    bucket=bucket,
                    source_dir=src,
                    tracker=tracker,
                    prefix=prefix,
                    workers=effective_workers,
                    queue_depth=ctx.obj.get("bulk_queue_depth", 8),
                    skip_existing=skip_existing,
                    retry_errors=retry_errors,
                    include=include,
                    exclude=exclude,
                    validate_file=validate_fn,
                    verify_upload=verify,
                    presign_batch_size=presign_batch_size
                    or ctx.obj.get("bulk_presign_batch_size", 200),
                    chunk_size=ctx.obj.get("bulk_chunk_size", 4 * 1024 * 1024),
                    progress_interval=ctx.obj.get("bulk_progress_interval", 5.0),
                    on_progress=_print_progress,
                    on_error=_print_error,
                )
            )

        tracker.close()
        _print_summary("Uploaded", stats, db_path)

    run(_run())

download_all(ctx, bucket=typer.Argument(...), prefix=typer.Option('', '--prefix', '-p', help='Only download keys under this prefix'), dest_dir=typer.Option('.', '--output', '-o', help='Local destination directory'), workers=typer.Option(0, '--workers', '-w', help='Concurrent workers (0 = use config)'), retry_errors=typer.Option(False, '--retry-errors', help='Only retry previously failed files'), include=typer.Option([], '--include', '-I', help="Only download keys matching these glob patterns (e.g. '*.jpg')"), exclude=typer.Option([], '--exclude', '-E', help="Skip keys matching these glob patterns (e.g. '*.tmp')"), validate=typer.Option(False, '--validate', help='Validate each file after download (auto-detects format by extension)'), verify=typer.Option(False, '--verify', help='Verify each download by checking file size after transfer'), tracker_db=typer.Option(None, '--tracker-db', envvar='RAHCP_TRACKER_DB', help='Tracker DB: file path or postgresql:// DSN (overrides prefix and default)'), tracker_prefix=typer.Option(None, '--tracker-prefix', help="Prefix for tracker DB name (e.g. 'backup' → backup.download-tracker.db)"), presign_batch_size=typer.Option(0, '--presign-batch-size', help='Presign URLs in batches of this size (0 = use config default)'))

Download a bucket to a local directory with tracked resume and parallel workers.

Source code in packages/rahcp-cli/src/rahcp_cli/s3.py
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
@app.command("download-all")
def download_all(
    ctx: typer.Context,
    bucket: str = typer.Argument(...),
    prefix: str = typer.Option(
        "", "--prefix", "-p", help="Only download keys under this prefix"
    ),
    dest_dir: str = typer.Option(
        ".", "--output", "-o", help="Local destination directory"
    ),
    workers: int = typer.Option(
        0, "--workers", "-w", help="Concurrent workers (0 = use config)"
    ),
    retry_errors: bool = typer.Option(
        False, "--retry-errors", help="Only retry previously failed files"
    ),
    include: list[str] = typer.Option(
        [],
        "--include",
        "-I",
        help="Only download keys matching these glob patterns (e.g. '*.jpg')",
    ),
    exclude: list[str] = typer.Option(
        [],
        "--exclude",
        "-E",
        help="Skip keys matching these glob patterns (e.g. '*.tmp')",
    ),
    validate: bool = typer.Option(
        False,
        "--validate",
        help="Validate each file after download (auto-detects format by extension)",
    ),
    verify: bool = typer.Option(
        False,
        "--verify",
        help="Verify each download by checking file size after transfer",
    ),
    tracker_db: str | None = typer.Option(
        None,
        "--tracker-db",
        envvar="RAHCP_TRACKER_DB",
        help="Tracker DB: file path or postgresql:// DSN (overrides prefix and default)",
    ),
    tracker_prefix: str | None = typer.Option(
        None,
        "--tracker-prefix",
        help="Prefix for tracker DB name (e.g. 'backup' → backup.download-tracker.db)",
    ),
    presign_batch_size: int = typer.Option(
        0,
        "--presign-batch-size",
        help="Presign URLs in batches of this size (0 = use config default)",
    ),
) -> None:
    """Download a bucket to a local directory with tracked resume and parallel workers."""

    async def _run() -> None:
        from rahcp_client.bulk import BulkDownloadConfig, bulk_download

        dest = Path(dest_dir)
        validate_fn = _get_validator() if validate else None
        tracker, db_path = _resolve_tracker(
            ctx, tracker_db, ".download-tracker.db", prefix=tracker_prefix
        )
        effective_workers = _resolve_workers(workers, ctx)

        done_count = len(await asyncio.to_thread(tracker.done_keys))
        console.print(f"Tracker: {db_path}{done_count} already done")
        flags = []
        if include:
            flags.append(f"include={include}")
        if exclude:
            flags.append(f"exclude={exclude}")
        if validate:
            flags.append("validate")
        if verify:
            flags.append("verify")
        flag_str = f" [{', '.join(flags)}]" if flags else ""
        console.print(
            f"Downloading s3://{bucket}/{prefix}{dest}/ ({effective_workers} workers){flag_str}"
        )

        async with make_client(ctx) as client:
            stats = await bulk_download(
                BulkDownloadConfig(
                    client=client,
                    bucket=bucket,
                    dest_dir=dest,
                    tracker=tracker,
                    prefix=prefix,
                    workers=effective_workers,
                    queue_depth=ctx.obj.get("bulk_queue_depth", 8),
                    retry_errors=retry_errors,
                    include=include,
                    exclude=exclude,
                    validate_file=validate_fn,
                    verify_download=verify,
                    presign_batch_size=presign_batch_size
                    or ctx.obj.get("bulk_presign_batch_size", 200),
                    chunk_size=ctx.obj.get("bulk_chunk_size", 4 * 1024 * 1024),
                    stream_threshold=ctx.obj.get(
                        "bulk_stream_threshold", 100 * 1024 * 1024
                    ),
                    progress_interval=ctx.obj.get("bulk_progress_interval", 5.0),
                    on_progress=_print_progress,
                    on_error=_print_error,
                )
            )

        tracker.close()
        _print_summary("Downloaded", stats, db_path)

    run(_run())

verify(ctx, bucket=typer.Argument(...), source_dir=typer.Argument(..., help='Local directory to compare against'), prefix=typer.Option('', '--prefix', '-p', help='Key prefix (same as upload-all)'))

Verify all local files exist in the bucket with matching sizes.

Source code in packages/rahcp-cli/src/rahcp_cli/s3.py
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
@app.command()
def verify(
    ctx: typer.Context,
    bucket: str = typer.Argument(...),
    source_dir: str = typer.Argument(..., help="Local directory to compare against"),
    prefix: str = typer.Option(
        "", "--prefix", "-p", help="Key prefix (same as upload-all)"
    ),
) -> None:
    """Verify all local files exist in the bucket with matching sizes."""

    async def _run() -> None:
        src = Path(source_dir)
        if not src.is_dir():
            console.print(f"[red]Not a directory: {src}[/red]")
            raise SystemExit(1)

        files = [f for f in src.rglob("*") if f.is_file()]
        if not files:
            console.print("[dim]No files found.[/dim]")
            return

        console.print(f"Listing remote objects in s3://{bucket}/{prefix}...")
        async with make_client(ctx) as client:
            remote = await _list_all_remote_objects(client, bucket, prefix)

        ok = 0
        missing: list[str] = []
        size_mismatch: list[tuple[str, int, int]] = []

        for f in files:
            key = _build_key(prefix, f.relative_to(src))
            local_size = f.stat().st_size
            if key not in remote:
                missing.append(key)
            elif remote[key] != local_size:
                size_mismatch.append((key, local_size, remote[key]))
            else:
                ok += 1

        console.print(
            f"\n[bold]Verification:[/bold] {len(files)} local, {len(remote)} remote\n"
        )
        console.print(f"  [green]{ok} OK[/green]")

        if missing:
            console.print(f"  [red]{len(missing)} MISSING[/red]:")
            for key in missing[:20]:
                console.print(f"    {key}")
            if len(missing) > 20:
                console.print(f"    ... and {len(missing) - 20} more")

        if size_mismatch:
            console.print(f"  [yellow]{len(size_mismatch)} SIZE MISMATCH[/yellow]:")
            for key, local, remote_size in size_mismatch[:10]:
                console.print(
                    f"    {key}: local={_human_size(local)}, remote={_human_size(remote_size)}"
                )

        if missing or size_mismatch:
            raise SystemExit(1)
        console.print("\n  [bold green]All files verified.[/bold green]")

    run(_run())

IIIF Commands

iiif

IIIF CLI subcommands — download images from Riksarkivet IIIF endpoints.

download(ctx, batch_id=typer.Argument(..., help='Volume/batch ID (e.g. C0074667)'), output_dir=typer.Option('.', '--output', '-o', help='Output directory'), workers=typer.Option(0, '--workers', '-w', help='Concurrent downloads'), query_params=typer.Option(None, '--query-params', '-q', help="IIIF params (e.g. 'full/,1200/0/default.jpg')"), iiif_url=typer.Option(None, '--iiif-url', envvar='IIIF_URL', help='IIIF server base URL'), referer=typer.Option(None, '--referer', envvar='IIIF_REFERER', help='Referer header for servers that require one (e.g. https://sok.riksarkivet.se/)'), max_images=typer.Option(None, '--max-images', '-n', help='Limit number of images'), validate=typer.Option(False, '--validate', help='Validate each image after download'), tracker_db=typer.Option(None, '--tracker-db', envvar='RAHCP_TRACKER_DB', help='Tracker DB: file path or postgresql:// DSN'), tracker_prefix=typer.Option(None, '--tracker-prefix', help="Prefix for tracker DB name (e.g. 'familysearch' → familysearch.iiif-download.db)"))

Download all images from a single IIIF batch.

Source code in packages/rahcp-cli/src/rahcp_cli/iiif.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
@app.command("download")
def download(
    ctx: typer.Context,
    batch_id: str = typer.Argument(..., help="Volume/batch ID (e.g. C0074667)"),
    output_dir: str = typer.Option(".", "--output", "-o", help="Output directory"),
    workers: int = typer.Option(0, "--workers", "-w", help="Concurrent downloads"),
    query_params: str = typer.Option(
        None,
        "--query-params",
        "-q",
        help="IIIF params (e.g. 'full/,1200/0/default.jpg')",
    ),
    iiif_url: str = typer.Option(
        None, "--iiif-url", envvar="IIIF_URL", help="IIIF server base URL"
    ),
    referer: str = typer.Option(
        None,
        "--referer",
        envvar="IIIF_REFERER",
        help="Referer header for servers that require one (e.g. https://sok.riksarkivet.se/)",
    ),
    max_images: int = typer.Option(
        None, "--max-images", "-n", help="Limit number of images"
    ),
    validate: bool = typer.Option(
        False, "--validate", help="Validate each image after download"
    ),
    tracker_db: str | None = typer.Option(
        None,
        "--tracker-db",
        envvar="RAHCP_TRACKER_DB",
        help="Tracker DB: file path or postgresql:// DSN",
    ),
    tracker_prefix: str | None = typer.Option(
        None,
        "--tracker-prefix",
        help="Prefix for tracker DB name (e.g. 'familysearch' → familysearch.iiif-download.db)",
    ),
) -> None:
    """Download all images from a single IIIF batch."""

    async def _run() -> None:
        from rahcp_iiif import download_batch

        dest = Path(output_dir)
        effective_url = iiif_url or ctx.obj.get(
            "iiif_url", "https://iiifintern-ai.ra.se"
        )
        effective_params = query_params or ctx.obj.get(
            "iiif_query_params", "full/max/0/default.jpg"
        )
        effective_timeout = ctx.obj.get("iiif_timeout", 60.0)
        effective_workers = workers or ctx.obj.get("iiif_workers", 4)
        effective_referer = referer or ctx.obj.get("iiif_referer")
        headers = {"Referer": effective_referer} if effective_referer else None
        validate_fn = _get_validator() if validate else None

        tracker, db_path = _resolve_iiif_tracker(ctx, tracker_db, prefix=tracker_prefix)

        done_count = len(await asyncio.to_thread(tracker.done_keys))
        console.print(f"Tracker: {db_path}{done_count} already done")
        flags = []
        if validate:
            flags.append("validate")
        if max_images:
            flags.append(f"max={max_images}")
        flag_str = f" [{', '.join(flags)}]" if flags else ""
        console.print(
            f"Downloading batch [bold]{batch_id}[/bold] → {dest}/"
            f" ({effective_workers} workers){flag_str}"
        )
        console.print(f"  IIIF: {effective_url}  params: {effective_params}")

        stats = await download_batch(
            batch_id,
            dest,
            tracker,
            base_url=effective_url,
            query_params=effective_params,
            timeout=effective_timeout,
            headers=headers,
            workers=effective_workers,
            max_images=max_images,
            validate_file=validate_fn,
            on_progress=_print_progress,
            on_error=_print_error,
            progress_interval=ctx.obj.get("bulk_progress_interval", 5.0),
        )

        tracker.close()
        _print_summary(stats, db_path)

    run(_run())

download_batches(ctx, job_file=typer.Argument(..., help='Text file with batch IDs (one per line)'), output_dir=typer.Option('.', '--output', '-o', help='Output directory'), workers=typer.Option(0, '--workers', '-w', help='Concurrent downloads'), query_params=typer.Option(None, '--query-params', '-q', help="IIIF params (e.g. 'full/,1200/0/default.jpg')"), iiif_url=typer.Option(None, '--iiif-url', envvar='IIIF_URL', help='IIIF server base URL'), referer=typer.Option(None, '--referer', envvar='IIIF_REFERER', help='Referer header for servers that require one (e.g. https://sok.riksarkivet.se/)'), max_images=typer.Option(None, '--max-images', '-n', help='Limit images per batch'), validate=typer.Option(False, '--validate', help='Validate each image after download'), tracker_db=typer.Option(None, '--tracker-db', envvar='RAHCP_TRACKER_DB', help='Tracker DB: file path or postgresql:// DSN'), tracker_prefix=typer.Option(None, '--tracker-prefix', help="Prefix for tracker DB name (e.g. 'familysearch' → familysearch.iiif-download.db)"))

Download images from multiple IIIF batches listed in a text file.

Source code in packages/rahcp-cli/src/rahcp_cli/iiif.py
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
@app.command("download-batches")
def download_batches(
    ctx: typer.Context,
    job_file: str = typer.Argument(..., help="Text file with batch IDs (one per line)"),
    output_dir: str = typer.Option(".", "--output", "-o", help="Output directory"),
    workers: int = typer.Option(0, "--workers", "-w", help="Concurrent downloads"),
    query_params: str = typer.Option(
        None,
        "--query-params",
        "-q",
        help="IIIF params (e.g. 'full/,1200/0/default.jpg')",
    ),
    iiif_url: str = typer.Option(
        None, "--iiif-url", envvar="IIIF_URL", help="IIIF server base URL"
    ),
    referer: str = typer.Option(
        None,
        "--referer",
        envvar="IIIF_REFERER",
        help="Referer header for servers that require one (e.g. https://sok.riksarkivet.se/)",
    ),
    max_images: int = typer.Option(
        None, "--max-images", "-n", help="Limit images per batch"
    ),
    validate: bool = typer.Option(
        False, "--validate", help="Validate each image after download"
    ),
    tracker_db: str | None = typer.Option(
        None,
        "--tracker-db",
        envvar="RAHCP_TRACKER_DB",
        help="Tracker DB: file path or postgresql:// DSN",
    ),
    tracker_prefix: str | None = typer.Option(
        None,
        "--tracker-prefix",
        help="Prefix for tracker DB name (e.g. 'familysearch' → familysearch.iiif-download.db)",
    ),
) -> None:
    """Download images from multiple IIIF batches listed in a text file."""

    async def _run() -> None:
        from rahcp_iiif import download_batches as _download_batches

        job_path = Path(job_file)
        if not job_path.exists():
            console.print(f"[red]File not found: {job_path}[/red]")
            raise SystemExit(1)

        batch_ids = [
            line.strip()
            for line in job_path.read_text().splitlines()
            if line.strip() and not line.startswith("#")
        ]
        if not batch_ids:
            console.print("[red]No batch IDs found in file[/red]")
            raise SystemExit(1)

        dest = Path(output_dir)
        effective_url = iiif_url or ctx.obj.get(
            "iiif_url", "https://iiifintern-ai.ra.se"
        )
        effective_params = query_params or ctx.obj.get(
            "iiif_query_params", "full/max/0/default.jpg"
        )
        effective_timeout = ctx.obj.get("iiif_timeout", 60.0)
        effective_workers = workers or ctx.obj.get("iiif_workers", 4)
        effective_referer = referer or ctx.obj.get("iiif_referer")
        headers = {"Referer": effective_referer} if effective_referer else None
        validate_fn = _get_validator() if validate else None

        tracker, db_path = _resolve_iiif_tracker(ctx, tracker_db, prefix=tracker_prefix)

        done_count = len(await asyncio.to_thread(tracker.done_keys))
        console.print(f"Tracker: {db_path}{done_count} already done")
        console.print(
            f"Downloading {len(batch_ids)} batches from [bold]{job_path.name}[/bold]"
            f" → {dest}/ ({effective_workers} workers)"
        )
        console.print(f"  IIIF: {effective_url}  params: {effective_params}")

        stats = await _download_batches(
            batch_ids,
            dest,
            tracker,
            base_url=effective_url,
            query_params=effective_params,
            timeout=effective_timeout,
            headers=headers,
            workers=effective_workers,
            max_images=max_images,
            validate_file=validate_fn,
            on_progress=_print_progress,
            on_error=_print_error,
            progress_interval=ctx.obj.get("bulk_progress_interval", 5.0),
        )

        tracker.close()
        _print_summary(stats, db_path)

    run(_run())

verify(ctx, job_file=typer.Argument(..., help='Text file with batch IDs (one per line)'), iiif_url=typer.Option(None, '--iiif-url', envvar='IIIF_URL', help='IIIF server base URL'), referer=typer.Option(None, '--referer', envvar='IIIF_REFERER', help='Referer header for servers that require one'), workers=typer.Option(16, '--workers', '-w', help='Concurrent manifest fetches'), tracker_db=typer.Option(None, '--tracker-db', envvar='RAHCP_TRACKER_DB', help='Tracker DB: file path or postgresql:// DSN'), tracker_prefix=typer.Option(None, '--tracker-prefix', help='Prefix for tracker DB name'), write_deficient=typer.Option(None, '--write-deficient', help='Write deficient batch IDs (one per line) to this file for re-running'))

Reconcile downloaded images against live manifests; flag short/missing batches.

Re-fetches each batch's manifest and compares its image count to the tracker. Exits non-zero if any batch is short or missing, so it is usable as a gate.

Source code in packages/rahcp-cli/src/rahcp_cli/iiif.py
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
@app.command("verify")
def verify(
    ctx: typer.Context,
    job_file: str = typer.Argument(..., help="Text file with batch IDs (one per line)"),
    iiif_url: str = typer.Option(
        None, "--iiif-url", envvar="IIIF_URL", help="IIIF server base URL"
    ),
    referer: str = typer.Option(
        None,
        "--referer",
        envvar="IIIF_REFERER",
        help="Referer header for servers that require one",
    ),
    workers: int = typer.Option(
        16, "--workers", "-w", help="Concurrent manifest fetches"
    ),
    tracker_db: str | None = typer.Option(
        None,
        "--tracker-db",
        envvar="RAHCP_TRACKER_DB",
        help="Tracker DB: file path or postgresql:// DSN",
    ),
    tracker_prefix: str | None = typer.Option(
        None, "--tracker-prefix", help="Prefix for tracker DB name"
    ),
    write_deficient: str | None = typer.Option(
        None,
        "--write-deficient",
        help="Write deficient batch IDs (one per line) to this file for re-running",
    ),
) -> None:
    """Reconcile downloaded images against live manifests; flag short/missing batches.

    Re-fetches each batch's manifest and compares its image count to the tracker.
    Exits non-zero if any batch is short or missing, so it is usable as a gate.
    """

    async def _run() -> None:
        from rahcp_iiif import verify_batches

        job_path = Path(job_file)
        if not job_path.exists():
            console.print(f"[red]File not found: {job_path}[/red]")
            raise SystemExit(1)

        batch_ids = [
            line.strip()
            for line in job_path.read_text().splitlines()
            if line.strip() and not line.startswith("#")
        ]
        if not batch_ids:
            console.print("[red]No batch IDs found in file[/red]")
            raise SystemExit(1)

        effective_url = iiif_url or ctx.obj.get(
            "iiif_url", "https://iiifintern-ai.ra.se"
        )
        effective_timeout = ctx.obj.get("iiif_timeout", 60.0)
        effective_referer = referer or ctx.obj.get("iiif_referer")
        headers = {"Referer": effective_referer} if effective_referer else None

        tracker, db_path = _resolve_iiif_tracker(ctx, tracker_db, prefix=tracker_prefix)
        done_keys = await asyncio.to_thread(tracker.done_keys)
        tracker.close()

        console.print(
            f"Verifying {len(batch_ids)} batches against [bold]{effective_url}[/bold]"
            f" (tracker: {db_path})"
        )
        report = await verify_batches(
            batch_ids,
            done_keys,
            base_url=effective_url,
            timeout=effective_timeout,
            headers=headers,
            workers=workers,
        )

        console.print(
            f"\n[bold]Verify:[/bold] {len(report.complete)} complete,"
            f" [red]{len(report.short)} short[/red],"
            f" [red]{len(report.missing)} missing[/red],"
            f" {len(report.fetch_errors)} fetch-error"
        )
        for r in sorted(report.deficient, key=lambda x: -x.missing_count):
            console.print(
                f"  [red]{r.batch_id}[/red]  expected {r.expected}, got {r.got}"
                f"  (missing {r.missing_count})"
            )
        if report.fetch_errors:
            errs = ", ".join(r.batch_id for r in report.fetch_errors)
            console.print(f"  [yellow]fetch-error (recheck):[/yellow] {errs}")

        if report.deficient:
            console.print(
                f"\n[bold red]{report.total_missing_images} images missing across"
                f" {len(report.deficient)} batches.[/bold red]"
            )
            if write_deficient:
                Path(write_deficient).write_text(
                    "\n".join(r.batch_id for r in report.deficient) + "\n"
                )
                console.print(
                    f"  Deficient batch IDs written to [bold]{write_deficient}[/bold]"
                )
            raise SystemExit(1)

        console.print("[green]All batches complete.[/green]")

    run(_run())

upload(ctx, bucket=typer.Argument(..., help='Target S3 bucket'), batch_ids=typer.Argument(None, help='Batch IDs to stream (e.g. C0074667). Omit when using --job-file.'), prefix=typer.Option('', '--prefix', '-p', help="Key prefix prepended to '<batch>/<image>'"), job_file=typer.Option(None, '--job-file', '-f', help='Text file with batch IDs (one per line)'), workers=typer.Option(0, '--workers', '-w', help='Concurrent download+upload workers'), skip_existing=typer.Option(True, '--skip-existing/--overwrite', help='Skip images already present in the bucket (HEAD check before download)'), validate=typer.Option(False, '--validate', help="Validate each image's bytes before upload"), verify=typer.Option(False, '--verify', help='Verify each upload by checking remote size after'), query_params=typer.Option(None, '--query-params', '-q', help="IIIF params (e.g. 'full/,1200/0/default.jpg')"), iiif_url=typer.Option(None, '--iiif-url', envvar='IIIF_URL', help='IIIF server base URL'), referer=typer.Option(None, '--referer', envvar='IIIF_REFERER', help='Referer header for servers that require one (e.g. https://sok.riksarkivet.se/)'), max_images=typer.Option(None, '--max-images', '-n', help='Limit images per batch'), tracker_db=typer.Option(None, '--tracker-db', envvar='RAHCP_TRACKER_DB', help='Tracker DB: file path or postgresql:// DSN'), tracker_prefix=typer.Option(None, '--tracker-prefix', help="Prefix for tracker DB name (e.g. 'familysearch' → familysearch.iiif-download.db)"))

Stream IIIF images straight to S3 — download and upload in one pass (no local disk).

Bytes are fetched on the fly and pushed through the shared bulk transfer engine, so this skips images already in the bucket (--skip-existing), can validate bytes before upload (--validate) and verify size after (--verify) — the same guarantees as s3 upload-all, without staging anything to disk.

Source code in packages/rahcp-cli/src/rahcp_cli/iiif.py
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
@app.command("upload")
def upload(
    ctx: typer.Context,
    bucket: str = typer.Argument(..., help="Target S3 bucket"),
    batch_ids: list[str] | None = typer.Argument(
        None, help="Batch IDs to stream (e.g. C0074667). Omit when using --job-file."
    ),
    prefix: str = typer.Option(
        "", "--prefix", "-p", help="Key prefix prepended to '<batch>/<image>'"
    ),
    job_file: str | None = typer.Option(
        None, "--job-file", "-f", help="Text file with batch IDs (one per line)"
    ),
    workers: int = typer.Option(
        0, "--workers", "-w", help="Concurrent download+upload workers"
    ),
    skip_existing: bool = typer.Option(
        True,
        "--skip-existing/--overwrite",
        help="Skip images already present in the bucket (HEAD check before download)",
    ),
    validate: bool = typer.Option(
        False, "--validate", help="Validate each image's bytes before upload"
    ),
    verify: bool = typer.Option(
        False, "--verify", help="Verify each upload by checking remote size after"
    ),
    query_params: str = typer.Option(
        None,
        "--query-params",
        "-q",
        help="IIIF params (e.g. 'full/,1200/0/default.jpg')",
    ),
    iiif_url: str = typer.Option(
        None, "--iiif-url", envvar="IIIF_URL", help="IIIF server base URL"
    ),
    referer: str = typer.Option(
        None,
        "--referer",
        envvar="IIIF_REFERER",
        help="Referer header for servers that require one (e.g. https://sok.riksarkivet.se/)",
    ),
    max_images: int = typer.Option(
        None, "--max-images", "-n", help="Limit images per batch"
    ),
    tracker_db: str | None = typer.Option(
        None,
        "--tracker-db",
        envvar="RAHCP_TRACKER_DB",
        help="Tracker DB: file path or postgresql:// DSN",
    ),
    tracker_prefix: str | None = typer.Option(
        None,
        "--tracker-prefix",
        help="Prefix for tracker DB name (e.g. 'familysearch' → familysearch.iiif-download.db)",
    ),
) -> None:
    """Stream IIIF images straight to S3 — download and upload in one pass (no local disk).

    Bytes are fetched on the fly and pushed through the shared bulk transfer
    engine, so this skips images already in the bucket (--skip-existing), can
    validate bytes before upload (--validate) and verify size after (--verify) —
    the same guarantees as `s3 upload-all`, without staging anything to disk.
    """

    async def _run() -> None:
        import httpx

        from rahcp_client import BulkStreamConfig, bulk_stream_upload
        from rahcp_iiif.manifest import (
            build_image_url,
            fetch_with_retry,
            file_extension,
            get_image_ids,
        )

        ids: list[str] = list(batch_ids or [])
        if job_file:
            job_path = Path(job_file)
            if not job_path.exists():
                console.print(f"[red]File not found: {job_path}[/red]")
                raise SystemExit(1)
            ids += [
                line.strip()
                for line in job_path.read_text().splitlines()
                if line.strip() and not line.startswith("#")
            ]
        if not ids:
            console.print(
                "[red]No batch IDs given — pass batch IDs as arguments or use --job-file[/red]"
            )
            raise SystemExit(1)

        effective_url = iiif_url or ctx.obj.get(
            "iiif_url", "https://iiifintern-ai.ra.se"
        )
        effective_params = query_params or ctx.obj.get(
            "iiif_query_params", "full/max/0/default.jpg"
        )
        effective_timeout = ctx.obj.get("iiif_timeout", 60.0)
        effective_workers = workers or ctx.obj.get("iiif_workers", 8)
        effective_referer = referer or ctx.obj.get("iiif_referer")
        headers = {"Referer": effective_referer} if effective_referer else None
        ext = file_extension(effective_params)
        validate_fn = _get_bytes_validator() if validate else None

        tracker, db_path = _resolve_iiif_tracker(ctx, tracker_db, prefix=tracker_prefix)
        done_count = len(await asyncio.to_thread(tracker.done_keys))
        console.print(f"Tracker: {db_path}{done_count} already done")

        async with make_client(ctx) as client:
            # 1. Enumerate manifests -> (s3_key, image_url) items (cheap: strings).
            console.print(f"Fetching {len(ids)} manifest(s) from {effective_url} …")
            sem = asyncio.Semaphore(max(4, effective_workers))

            async def _enumerate(batch_id: str) -> list[tuple[str, str]]:
                sentinel_key = f"{prefix}{batch_id}/__manifest__"
                async with sem:
                    try:
                        image_ids = await get_image_ids(
                            batch_id,
                            base_url=effective_url,
                            timeout=effective_timeout,
                            headers=headers,
                        )
                    except Exception as exc:
                        console.print(
                            f"  [red]manifest {batch_id} failed: {str(exc)[:100]}[/red]"
                        )
                        await asyncio.to_thread(
                            tracker.mark,
                            sentinel_key,
                            0,
                            TransferStatus.error,
                            error=f"manifest: {str(exc)[:300]}",
                        )
                        return []
                await asyncio.to_thread(tracker.delete, sentinel_key)
                if max_images:
                    image_ids = image_ids[:max_images]
                return [
                    (
                        f"{prefix}{batch_id}/{image_id}{ext}",
                        build_image_url(
                            image_id,
                            base_url=effective_url,
                            query_params=effective_params,
                        ),
                    )
                    for image_id in image_ids
                ]

            per_batch = await asyncio.gather(*[_enumerate(b) for b in ids])
            items = [item for sub in per_batch for item in sub]
            if not items:
                tracker.close()
                console.print("[red]No images found in the given batches[/red]")
                raise SystemExit(1)

            flags = [
                label
                for label, on in (
                    ("skip-existing", skip_existing),
                    ("validate", validate),
                    ("verify", verify),
                )
                if on
            ]
            flag_str = f" [{', '.join(flags)}]" if flags else ""
            console.print(
                f"Streaming {len(items)} images from {len(ids)} batch(es)"
                f" → s3://{bucket}/{prefix} ({effective_workers} workers){flag_str}"
            )

            # 2. fetch = retrying IIIF download over a shared pooled client.
            async with httpx.AsyncClient(
                timeout=effective_timeout, headers=headers
            ) as iiif_http:

                async def fetch(url: str) -> bytes:
                    resp = await fetch_with_retry(iiif_http, url)
                    return resp.content

                stats = await bulk_stream_upload(
                    BulkStreamConfig(
                        client=client,
                        bucket=bucket,
                        tracker=tracker,
                        workers=effective_workers,
                        queue_depth=ctx.obj.get("bulk_queue_depth", 8),
                        skip_existing=skip_existing,
                        validate_bytes=validate_fn,
                        verify_upload=verify,
                        presign_batch_size=ctx.obj.get("bulk_presign_batch_size", 200),
                        on_progress=_print_progress,
                        on_error=_print_error,
                        progress_interval=ctx.obj.get("bulk_progress_interval", 5.0),
                    ),
                    items,
                    fetch,
                )

        tracker.close()
        _print_summary(stats, db_path, verb="Uploaded")

    run(_run())

Transkribus Commands

transkribus

Transkribus CLI subcommands — export ground-truth collections to disk or a bucket.

export(ctx, collection_id=typer.Argument(..., help='Transkribus collection ID'), output_dir=typer.Option('.', '--output', '-o', help='Output directory'), status=typer.Option('GT', '--status', '-s', help='Transcript status filter (e.g. GT, FINAL)'), fmt=typer.Option('page', '--format', '-f', help='Output format: page or alto'), images=typer.Option(True, '--images/--no-images', help='Also export page images'), doc_ids=typer.Option(None, '--doc-ids', help='Restrict to these document IDs (comma-separated)'), workers=typer.Option(0, '--workers', '-w', help='Concurrent workers'), validate=typer.Option(False, '--validate', help='Validate each image after download'), transkribus_url=typer.Option(None, '--transkribus-url', envvar='TRANSKRIBUS_URL', help='TRP REST base URL'), username=typer.Option(None, '--username', '-U', envvar='TRANSKRIBUS_USERNAME', help='Transkribus login'), password=typer.Option(None, '--password', '-P', envvar='TRANSKRIBUS_PASSWORD', help='Transkribus password'), tracker_db=typer.Option(None, '--tracker-db', envvar='RAHCP_TRACKER_DB', help='Tracker DB: file path or postgresql:// DSN'), tracker_prefix=typer.Option(None, '--tracker-prefix', help='Prefix for tracker DB name'), check_updates=typer.Option(False, '--check-updates', help='Re-fetch transcripts corrected in Transkribus since last sync (tsId change)'), version_db=typer.Option(None, '--version-db', help='Change-detection state DB (default: <tracker-db>.versions.db)'), fail_on_error=typer.Option(True, '--fail-on-error/--no-fail-on-error', help='Exit non-zero if any item failed (cron-friendly; re-run resumes)'))

Export a Transkribus collection (PAGE/ALTO XML + images) to a local directory.

Source code in packages/rahcp-cli/src/rahcp_cli/transkribus.py
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
@app.command("export")
def export(
    ctx: typer.Context,
    collection_id: int = typer.Argument(..., help="Transkribus collection ID"),
    output_dir: str = typer.Option(".", "--output", "-o", help="Output directory"),
    status: str = typer.Option(
        "GT", "--status", "-s", help="Transcript status filter (e.g. GT, FINAL)"
    ),
    fmt: str = typer.Option(
        "page", "--format", "-f", help="Output format: page or alto"
    ),
    images: bool = typer.Option(
        True, "--images/--no-images", help="Also export page images"
    ),
    doc_ids: str | None = typer.Option(
        None, "--doc-ids", help="Restrict to these document IDs (comma-separated)"
    ),
    workers: int = typer.Option(0, "--workers", "-w", help="Concurrent workers"),
    validate: bool = typer.Option(
        False, "--validate", help="Validate each image after download"
    ),
    transkribus_url: str = typer.Option(
        None, "--transkribus-url", envvar="TRANSKRIBUS_URL", help="TRP REST base URL"
    ),
    username: str = typer.Option(
        None,
        "--username",
        "-U",
        envvar="TRANSKRIBUS_USERNAME",
        help="Transkribus login",
    ),
    password: str = typer.Option(
        None,
        "--password",
        "-P",
        envvar="TRANSKRIBUS_PASSWORD",
        help="Transkribus password",
    ),
    tracker_db: str | None = typer.Option(
        None,
        "--tracker-db",
        envvar="RAHCP_TRACKER_DB",
        help="Tracker DB: file path or postgresql:// DSN",
    ),
    tracker_prefix: str | None = typer.Option(
        None, "--tracker-prefix", help="Prefix for tracker DB name"
    ),
    check_updates: bool = typer.Option(
        False,
        "--check-updates",
        help="Re-fetch transcripts corrected in Transkribus since last sync (tsId change)",
    ),
    version_db: str | None = typer.Option(
        None,
        "--version-db",
        help="Change-detection state DB (default: <tracker-db>.versions.db)",
    ),
    fail_on_error: bool = typer.Option(
        True,
        "--fail-on-error/--no-fail-on-error",
        help="Exit non-zero if any item failed (cron-friendly; re-run resumes)",
    ),
) -> None:
    """Export a Transkribus collection (PAGE/ALTO XML + images) to a local directory."""

    async def _run() -> None:
        from rahcp_transkribus import export_collection

        export_fmt = _resolve_format(fmt)
        validate_fn = _get_validator() if validate else None
        doc_id_list = _parse_doc_ids(doc_ids)
        effective_workers = workers or ctx.obj.get("transkribus_workers", 8)
        dest = Path(output_dir)

        tracker, db_path = _resolve_transkribus_tracker(
            ctx, tracker_db, prefix=tracker_prefix
        )
        vdb = _resolve_version_db(db_path, version_db, check_updates=check_updates)
        done_count = len(await asyncio.to_thread(tracker.done_keys))
        console.print(f"Tracker: {db_path}{done_count} already done")

        flags = [f"status={status}", f"format={export_fmt}"]
        if not images:
            flags.append("no-images")
        if validate:
            flags.append("validate")
        if check_updates:
            flags.append("check-updates")
        console.print(
            f"Exporting collection [bold]{collection_id}[/bold] → {dest}/"
            f" ({effective_workers} workers) [{', '.join(flags)}]"
        )

        client = _make_transkribus_client(
            ctx, url=transkribus_url, username=username, password=password
        )
        async with client:
            stats = await export_collection(
                client,
                collection_id,
                dest,
                tracker,
                status=status,
                fmt=export_fmt,
                include_images=images,
                doc_ids=doc_id_list,
                workers=effective_workers,
                validate_file=validate_fn,
                version_db=vdb,
                on_progress=_print_progress,
                on_error=_print_error,
                progress_interval=ctx.obj.get("bulk_progress_interval", 5.0),
            )

        tracker.close()
        _print_summary(stats, db_path)

        if fail_on_error and stats.errors:
            raise SystemExit(1)

    run(_run())

upload(ctx, collection_id=typer.Argument(..., help='Transkribus collection ID'), bucket=typer.Argument(..., help='Target S3 bucket'), prefix=typer.Option('', '--prefix', '-p', help='Key prefix prepended to the export keys'), status=typer.Option('GT', '--status', '-s', help='Transcript status filter (e.g. GT, FINAL)'), fmt=typer.Option('page', '--format', '-f', help='Output format: page or alto'), images=typer.Option(True, '--images/--no-images', help='Also upload page images'), doc_ids=typer.Option(None, '--doc-ids', help='Restrict to these document IDs (comma-separated)'), on_conflict=typer.Option('skip', '--on-conflict', help='When a key already exists in the bucket: overwrite | skip | error'), archive_dir=typer.Option(None, '--archive-dir', help='Also write each file to this local directory (keeps a copy while streaming)'), workers=typer.Option(0, '--workers', '-w', help='Concurrent workers'), validate=typer.Option(False, '--validate', help="Validate each image's bytes before upload"), verify=typer.Option(False, '--verify', help='Verify each upload by checking remote size after'), fail_on_error=typer.Option(True, '--fail-on-error/--no-fail-on-error', help='Exit non-zero if any item failed (cron-friendly; re-run resumes)'), transkribus_url=typer.Option(None, '--transkribus-url', envvar='TRANSKRIBUS_URL', help='TRP REST base URL'), username=typer.Option(None, '--username', '-U', envvar='TRANSKRIBUS_USERNAME', help='Transkribus login'), password=typer.Option(None, '--password', '-P', envvar='TRANSKRIBUS_PASSWORD', help='Transkribus password'), tracker_db=typer.Option(None, '--tracker-db', envvar='RAHCP_TRACKER_DB', help='Tracker DB: file path or postgresql:// DSN'), tracker_prefix=typer.Option(None, '--tracker-prefix', help='Prefix for tracker DB name'), check_updates=typer.Option(False, '--check-updates', help='Re-upload transcripts corrected in Transkribus since last sync (implies overwrite)'), version_db=typer.Option(None, '--version-db', help='Change-detection state DB (default: <tracker-db>.versions.db)'))

Stream a Transkribus collection straight to an HCP bucket, resumably.

Transcripts (and, unless --no-images, page images) are fetched from Transkribus and pushed through the shared bulk transfer engine in one pass. Built for unattended/cron use: the transfer tracker makes it idempotent (a re-run skips everything already done), --validate/--verify guard content, --on-conflict decides what happens for keys already in the bucket (overwrite | skip | error), and --fail-on-error (default) exits non-zero if anything failed so a cron can alert. Pass --archive-dir to also keep a local copy of every file as it streams, so one run does both the export and the bucket upload.

Source code in packages/rahcp-cli/src/rahcp_cli/transkribus.py
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
@app.command("upload")
def upload(
    ctx: typer.Context,
    collection_id: int = typer.Argument(..., help="Transkribus collection ID"),
    bucket: str = typer.Argument(..., help="Target S3 bucket"),
    prefix: str = typer.Option(
        "", "--prefix", "-p", help="Key prefix prepended to the export keys"
    ),
    status: str = typer.Option(
        "GT", "--status", "-s", help="Transcript status filter (e.g. GT, FINAL)"
    ),
    fmt: str = typer.Option(
        "page", "--format", "-f", help="Output format: page or alto"
    ),
    images: bool = typer.Option(
        True, "--images/--no-images", help="Also upload page images"
    ),
    doc_ids: str | None = typer.Option(
        None, "--doc-ids", help="Restrict to these document IDs (comma-separated)"
    ),
    on_conflict: str = typer.Option(
        "skip",
        "--on-conflict",
        help="When a key already exists in the bucket: overwrite | skip | error",
    ),
    archive_dir: str | None = typer.Option(
        None,
        "--archive-dir",
        help="Also write each file to this local directory (keeps a copy while streaming)",
    ),
    workers: int = typer.Option(0, "--workers", "-w", help="Concurrent workers"),
    validate: bool = typer.Option(
        False, "--validate", help="Validate each image's bytes before upload"
    ),
    verify: bool = typer.Option(
        False, "--verify", help="Verify each upload by checking remote size after"
    ),
    fail_on_error: bool = typer.Option(
        True,
        "--fail-on-error/--no-fail-on-error",
        help="Exit non-zero if any item failed (cron-friendly; re-run resumes)",
    ),
    transkribus_url: str = typer.Option(
        None, "--transkribus-url", envvar="TRANSKRIBUS_URL", help="TRP REST base URL"
    ),
    username: str = typer.Option(
        None,
        "--username",
        "-U",
        envvar="TRANSKRIBUS_USERNAME",
        help="Transkribus login",
    ),
    password: str = typer.Option(
        None,
        "--password",
        "-P",
        envvar="TRANSKRIBUS_PASSWORD",
        help="Transkribus password",
    ),
    tracker_db: str | None = typer.Option(
        None,
        "--tracker-db",
        envvar="RAHCP_TRACKER_DB",
        help="Tracker DB: file path or postgresql:// DSN",
    ),
    tracker_prefix: str | None = typer.Option(
        None, "--tracker-prefix", help="Prefix for tracker DB name"
    ),
    check_updates: bool = typer.Option(
        False,
        "--check-updates",
        help="Re-upload transcripts corrected in Transkribus since last sync (implies overwrite)",
    ),
    version_db: str | None = typer.Option(
        None,
        "--version-db",
        help="Change-detection state DB (default: <tracker-db>.versions.db)",
    ),
) -> None:
    """Stream a Transkribus collection straight to an HCP bucket, resumably.

    Transcripts (and, unless --no-images, page images) are fetched from
    Transkribus and pushed through the shared bulk transfer engine in one pass.
    Built for unattended/cron use: the transfer tracker makes it idempotent
    (a re-run skips everything already done), --validate/--verify guard content,
    --on-conflict decides what happens for keys already in the bucket
    (overwrite | skip | error), and --fail-on-error (default) exits non-zero if
    anything failed so a cron can alert. Pass --archive-dir to also keep a local
    copy of every file as it streams, so one run does both the export and the
    bucket upload.
    """

    async def _run() -> None:
        from rahcp_client import BulkStreamConfig, ConflictPolicy, bulk_stream_upload
        from rahcp_transkribus import (
            TranscriptVersionStore,
            fetch_item_bytes,
            plan_collection_export,
            refresh_stale_transcripts,
            transcript_versions,
        )

        export_fmt = _resolve_format(fmt)
        if on_conflict not in _CONFLICT_CHOICES:
            console.print(
                f"[red]Invalid --on-conflict '{on_conflict}'"
                f" (use: {', '.join(_CONFLICT_CHOICES)})[/red]"
            )
            raise SystemExit(1)
        policy = ConflictPolicy(on_conflict)
        doc_id_list = _parse_doc_ids(doc_ids)
        effective_workers = workers or ctx.obj.get("transkribus_workers", 8)
        validate_fn = _get_bytes_validator() if validate else None
        archive_root = Path(archive_dir) if archive_dir else None

        tracker, db_path = _resolve_transkribus_tracker(
            ctx, tracker_db, prefix=tracker_prefix
        )
        vdb = _resolve_version_db(db_path, version_db, check_updates=check_updates)
        if vdb is not None and policy is not ConflictPolicy.overwrite:
            # A changed transcript already has an (old) object in the bucket, so it
            # must be replaced — skip/error would leave the stale copy in place.
            console.print(
                "[dim]--check-updates re-uploads changed transcripts; using"
                " --on-conflict overwrite[/dim]"
            )
            policy = ConflictPolicy.overwrite
        done_count = len(await asyncio.to_thread(tracker.done_keys))
        console.print(f"Tracker: {db_path}{done_count} already done")

        client = _make_transkribus_client(
            ctx, url=transkribus_url, username=username, password=password
        )
        async with client:
            console.print(
                f"Planning export of collection [bold]{collection_id}[/bold]"
                f" (status={status}, format={export_fmt}) …"
            )
            items = await plan_collection_export(
                client,
                collection_id,
                status=status,
                fmt=export_fmt,
                include_images=images,
                doc_ids=doc_id_list,
            )
            if not items:
                tracker.close()
                console.print("[red]No exportable items found in the collection[/red]")
                raise SystemExit(1)

            store = TranscriptVersionStore(vdb) if vdb is not None else None
            if store is not None:
                stale = await refresh_stale_transcripts(
                    items, tracker, store, set(), prefix=prefix
                )
                if stale:
                    console.print(
                        f"Change detection: [bold]{len(stale)}[/bold]"
                        " transcript(s) changed since last sync — re-uploading"
                    )

            items_by_id = {item.key: item for item in items}
            stream_items = [
                (f"{prefix}{item.key}" if prefix else item.key, item.key)
                for item in items
            ]

            flags = [f"on-conflict={policy}"]
            if validate:
                flags.append("validate")
            if verify:
                flags.append("verify")
            if check_updates:
                flags.append("check-updates")
            if archive_root is not None:
                flags.append(f"archive={archive_root}")
            console.print(
                f"Streaming {len(stream_items)} files → s3://{bucket}/{prefix}"
                f" ({effective_workers} workers) [{', '.join(flags)}]"
            )

            async def fetch(fetch_id: str) -> bytes:
                item = items_by_id[fetch_id]
                data = await fetch_item_bytes(client, item)
                # Tee to a local archive in the same pass — the "export" copy.
                if archive_root is not None:
                    dest = archive_root / item.key
                    await asyncio.to_thread(
                        dest.parent.mkdir, parents=True, exist_ok=True
                    )
                    await asyncio.to_thread(dest.write_bytes, data)
                return data

            async with make_client(ctx) as hcp_client:
                stats = await bulk_stream_upload(
                    BulkStreamConfig(
                        client=hcp_client,
                        bucket=bucket,
                        tracker=tracker,
                        workers=effective_workers,
                        queue_depth=ctx.obj.get("bulk_queue_depth", 8),
                        on_conflict=policy,
                        validate_bytes=validate_fn,
                        verify_upload=verify,
                        presign_batch_size=ctx.obj.get("bulk_presign_batch_size", 200),
                        on_progress=_print_progress,
                        on_error=_print_error,
                        progress_interval=ctx.obj.get("bulk_progress_interval", 5.0),
                    ),
                    stream_items,
                    fetch,
                )

            if store is not None:
                final_done = await asyncio.to_thread(tracker.done_keys)
                await asyncio.to_thread(
                    store.set_many,
                    transcript_versions(items, final_done, prefix=prefix),
                )
                store.close()

        tracker.close()
        _print_summary(stats, db_path, verb="Uploaded")

        if fail_on_error and stats.errors:
            raise SystemExit(1)

    run(_run())

Auth Commands

auth

Auth commands — whoami.

whoami(ctx)

Show current user info by decoding the JWT token.

Source code in packages/rahcp-cli/src/rahcp_cli/auth.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@app.command()
def whoami(ctx: typer.Context) -> None:
    """Show current user info by decoding the JWT token."""

    async def _whoami() -> None:
        async with make_client(ctx) as client:
            token = client.token
            if not token:
                console.print("[red]Not authenticated. Check your config.[/red]")
                raise typer.Exit(1)
            try:
                payload_b64 = token.split(".")[1]
                payload_b64 += "=" * (4 - len(payload_b64) % 4)
                payload = json.loads(base64.urlsafe_b64decode(payload_b64))
            except (IndexError, json.JSONDecodeError, Exception) as exc:
                console.print(f"[red]Invalid token format:[/red] {exc}")
                raise typer.Exit(1) from exc
            if ctx.obj["json"]:
                print_json(payload)
            else:
                console.print(f"User: [bold]{payload.get('sub', '?')}[/bold]")
                console.print(f"Tenant: {payload.get('tenant', '(system)')}")

    run(_whoami())

Namespace Commands

namespace

Namespace subcommands.

list_namespaces(ctx, tenant=typer.Argument(...), verbose=typer.Option(False, '--verbose', '-v'))

List namespaces for a tenant.

Source code in packages/rahcp-cli/src/rahcp_cli/namespace.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@app.command("list")
def list_namespaces(
    ctx: typer.Context,
    tenant: str = typer.Argument(...),
    verbose: bool = typer.Option(False, "--verbose", "-v"),
) -> None:
    """List namespaces for a tenant."""

    async def _run() -> None:
        async with make_client(ctx) as client:
            data = await client.mapi.list_namespaces(tenant, verbose=verbose)
            if ctx.obj["json"]:
                print_json(data)
            else:
                if isinstance(data, dict) and "name" in data:
                    names = data["name"]
                    rows = (
                        [{"name": n} for n in names] if isinstance(names, list) else []
                    )
                elif isinstance(data, list):
                    rows = data
                else:
                    rows = []
                print_table(rows, title=f"Namespaces ({tenant})")

    run(_run())

get_namespace(ctx, tenant=typer.Argument(...), ns=typer.Argument(...), verbose=typer.Option(False, '--verbose', '-v'))

Get namespace details.

Source code in packages/rahcp-cli/src/rahcp_cli/namespace.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@app.command("get")
def get_namespace(
    ctx: typer.Context,
    tenant: str = typer.Argument(...),
    ns: str = typer.Argument(...),
    verbose: bool = typer.Option(False, "--verbose", "-v"),
) -> None:
    """Get namespace details."""

    async def _run() -> None:
        async with make_client(ctx) as client:
            data = await client.mapi.get_namespace(tenant, ns, verbose=verbose)
            print_json(data)

    run(_run())

create_namespace(ctx, tenant=typer.Argument(...), name=typer.Option(..., '--name'), quota=typer.Option(None, '--quota'))

Create a namespace.

Source code in packages/rahcp-cli/src/rahcp_cli/namespace.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
@app.command("create")
def create_namespace(
    ctx: typer.Context,
    tenant: str = typer.Argument(...),
    name: str = typer.Option(..., "--name"),
    quota: str = typer.Option(None, "--quota"),
) -> None:
    """Create a namespace."""
    ns_data: dict[str, Any] = {"name": name}
    if quota:
        ns_data["hardQuota"] = quota

    async def _run() -> None:
        async with make_client(ctx) as client:
            result = await client.mapi.create_namespace(tenant, ns_data)
            if ctx.obj["json"]:
                print_json(result)
            else:
                console.print(f"[green]Created namespace[/green] {name}")

    run(_run())

delete_namespace(ctx, tenant=typer.Argument(...), ns=typer.Argument(...))

Delete a namespace.

Source code in packages/rahcp-cli/src/rahcp_cli/namespace.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
@app.command("delete")
def delete_namespace(
    ctx: typer.Context,
    tenant: str = typer.Argument(...),
    ns: str = typer.Argument(...),
) -> None:
    """Delete a namespace."""

    async def _run() -> None:
        async with make_client(ctx) as client:
            await client.mapi.delete_namespace(tenant, ns)
            console.print(f"[green]Deleted namespace[/green] {ns}")

    run(_run())

import_namespace(ctx, tenant=typer.Argument(...), file=typer.Argument(..., exists=True, help='Exported template JSON file'))

Create namespace(s) from an exported template file.

Examples:

rahcp ns export dev-ai my-ns > template.json rahcp ns import prod-tenant template.json

Source code in packages/rahcp-cli/src/rahcp_cli/namespace.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
@app.command("import")
def import_namespace(
    ctx: typer.Context,
    tenant: str = typer.Argument(...),
    file: Path = typer.Argument(..., exists=True, help="Exported template JSON file"),
) -> None:
    """Create namespace(s) from an exported template file.

    Examples:
        rahcp ns export dev-ai my-ns > template.json
        rahcp ns import prod-tenant template.json
    """

    async def _run() -> None:
        template = json.loads(file.read_text())
        namespaces = template.get("namespaces", [])
        if not namespaces:
            console.print("[red]No namespaces found in template[/red]")
            raise typer.Exit(1)
        async with make_client(ctx) as client:
            for ns_data in namespaces:
                name = ns_data.get("name", "?")
                result = await client.mapi.create_namespace(tenant, ns_data)
                if ctx.obj["json"]:
                    print_json(result)
                else:
                    console.print(f"[green]Created namespace[/green] {name}")

    run(_run())

export_namespace(ctx, tenant=typer.Argument(...), ns=typer.Argument(...), output=typer.Option(None, '--output', '-o'))

Export namespace as a reusable template.

Source code in packages/rahcp-cli/src/rahcp_cli/namespace.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
@app.command("export")
def export_namespace(
    ctx: typer.Context,
    tenant: str = typer.Argument(...),
    ns: str = typer.Argument(...),
    output: str = typer.Option(None, "--output", "-o"),
) -> None:
    """Export namespace as a reusable template."""

    async def _run() -> None:
        async with make_client(ctx) as client:
            data = await client.mapi.export_namespace(tenant, ns)
            text = json.dumps(data, indent=2)
            if output:
                with open(output, "w") as f:
                    f.write(text)
                console.print(f"[green]Exported to[/green] {output}")
            else:
                sys.stdout.write(text + "\n")

    run(_run())

Configuration

config

YAML config file with named profiles.

Config file: ~/.rahcp/config.yaml (or --config / RAHCP_CONFIG)

Example::

default: dev

profiles:
  dev:
    endpoint: http://localhost:8000/api/v1
    username: admin
    password: secret
    tenant: dev-ai
    verify_ssl: false
  prod:
    endpoint: http://localhost:8000/api/v1
    username: prod-user
    password: secret
    tenant: prod-archive

Profile

Bases: BaseModel

A named connection profile.

CLIConfig

Bases: BaseModel

Config with named profiles.

resolve(name=None)

Resolve a profile by name, falling back to default.

Source code in packages/rahcp-cli/src/rahcp_cli/config.py
91
92
93
94
95
96
97
98
def resolve(self, name: str | None = None) -> Profile:
    """Resolve a profile by name, falling back to default."""
    key = name or self.default
    if key and key in self.profiles:
        return self.profiles[key]
    if len(self.profiles) == 1:
        return next(iter(self.profiles.values()))
    return Profile()

load_config(path=None)

Load config from a YAML file.

Resolution: explicit path > RAHCP_CONFIG env > ~/.rahcp/config.yaml

Source code in packages/rahcp-cli/src/rahcp_cli/config.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def load_config(path: str | None = None) -> CLIConfig:
    """Load config from a YAML file.

    Resolution: explicit path > RAHCP_CONFIG env > ~/.rahcp/config.yaml
    """
    config_path = Path(path) if path else CONFIG_PATH
    if not config_path.exists():
        return CLIConfig()
    try:
        raw = yaml.safe_load(config_path.read_text()) or {}
    except Exception:
        log.warning("Failed to parse config file: %s", config_path)
        return CLIConfig()

    # Multi-profile format
    if "profiles" in raw:
        return CLIConfig(
            default=raw.get("default", ""),
            profiles={
                name: Profile(**vals)
                for name, vals in raw["profiles"].items()
                if isinstance(vals, dict)
            },
        )

    # Flat format (backwards compat) — single "default" profile
    return CLIConfig(
        default="default",
        profiles={"default": Profile(**raw)},
    )

rahcp-etl

Pipeline

pipeline

Pipeline DAG — stage registration, retry policy, checkpoint resume.

Stage(name, handler, retries=3, backoff=2.0) dataclass

A single pipeline stage with retry configuration.

Pipeline(checkpoint_store=None)

Composable ETL pipeline with retry policy per stage.

Register stages with the @pipeline.stage() decorator, then run with pipeline.run(payload). Each stage receives the output of the previous stage. Checkpoints are saved after each successful stage so the pipeline can resume on failure.

Example::

pipeline = Pipeline()

@pipeline.stage("extract", retries=3)
async def extract(payload):
    ...
    return {"records": [...]}

@pipeline.stage("transform")
async def transform(payload):
    ...
    return {"transformed": [...]}

result = await pipeline.run({"source": "s3://..."})
Source code in packages/rahcp-etl/src/rahcp_etl/pipeline.py
52
53
54
def __init__(self, checkpoint_store: CheckpointStore | None = None) -> None:
    self._stages: list[Stage] = []
    self._checkpoint_store = checkpoint_store

stage(name, *, retries=3, backoff=2.0)

Decorator to register a pipeline stage.

Source code in packages/rahcp-etl/src/rahcp_etl/pipeline.py
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
def stage(
    self,
    name: str,
    *,
    retries: int = 3,
    backoff: float = 2.0,
) -> Callable:
    """Decorator to register a pipeline stage."""

    def decorator(
        fn: Callable[..., Coroutine[Any, Any, dict[str, Any]]],
    ) -> Callable[..., Coroutine[Any, Any, dict[str, Any]]]:
        self._stages.append(
            Stage(name=name, handler=fn, retries=retries, backoff=backoff)
        )
        return fn

    return decorator

run(payload, *, pipeline_id=None) async

Execute all stages in order with retry and checkpointing.

Source code in packages/rahcp-etl/src/rahcp_etl/pipeline.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
async def run(
    self,
    payload: dict[str, Any],
    *,
    pipeline_id: str | None = None,
) -> dict[str, Any]:
    """Execute all stages in order with retry and checkpointing."""
    state = dict(payload)
    start_index = 0

    # Resume from checkpoint if available
    if pipeline_id and self._checkpoint_store:
        checkpoint = await self._checkpoint_store.load(pipeline_id)
        if checkpoint:
            completed_stage = checkpoint["stage"]
            state = checkpoint["state"]
            for i, s in enumerate(self._stages):
                if s.name == completed_stage:
                    start_index = i + 1
                    break
            log.info(
                "Resuming pipeline %s from stage %d (%s)",
                pipeline_id,
                start_index,
                completed_stage,
            )

    for stage in self._stages[start_index:]:
        state = await self._run_stage(stage, state)
        if pipeline_id and self._checkpoint_store:
            await self._checkpoint_store.save(pipeline_id, stage.name, state)

    # Clear checkpoint on success
    if pipeline_id and self._checkpoint_store:
        await self._checkpoint_store.clear(pipeline_id)

    return state

Consumer

consumer

JetStream durable consumer for ETL work items.

ETLConsumer(nats_url, stream, subject, durable, *, max_deliver=5, ack_wait=30.0)

Durable JetStream consumer with auto-reconnect.

Connects to NATS, binds a durable consumer on the given stream/subject, and dispatches messages to the handler. Acks on success, naks on failure.

Source code in packages/rahcp-etl/src/rahcp_etl/consumer.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def __init__(
    self,
    nats_url: str,
    stream: str,
    subject: str,
    durable: str,
    *,
    max_deliver: int = 5,
    ack_wait: float = 30.0,
) -> None:
    self.nats_url = nats_url
    self.stream = stream
    self.subject = subject
    self.durable = durable
    self.max_deliver = max_deliver
    self.ack_wait = ack_wait
    self._nc: nats.NATS | None = None
    self._sub: Any = None
    self._running = False

start(handler) async

Connect and start consuming messages.

handler receives the message payload as bytes and should return a dict (success) or raise an exception (triggers nak/retry).

Source code in packages/rahcp-etl/src/rahcp_etl/consumer.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
async def start(self, handler: Callable) -> None:
    """Connect and start consuming messages.

    ``handler`` receives the message payload as bytes and should
    return a dict (success) or raise an exception (triggers nak/retry).
    """
    self._nc = await nats.connect(
        self.nats_url,
        reconnected_cb=self._on_reconnect,
        disconnected_cb=self._on_disconnect,
    )
    js = self._nc.jetstream()

    config = ConsumerConfig(
        durable_name=self.durable,
        deliver_policy=DeliverPolicy.ALL,
        max_deliver=self.max_deliver,
        ack_wait=self.ack_wait,
    )

    self._sub = await js.subscribe(
        self.subject,
        stream=self.stream,
        config=config,
    )
    self._running = True
    log.info(
        "ETL consumer started: stream=%s subject=%s durable=%s",
        self.stream,
        self.subject,
        self.durable,
    )

    async for msg in self._sub.messages:
        if not self._running:
            break
        try:
            await handler(msg.data)
            await msg.ack()
        except Exception:
            log.exception("Handler failed for message on %s", msg.subject)
            await msg.nak()

stop() async

Stop consuming and disconnect.

Source code in packages/rahcp-etl/src/rahcp_etl/consumer.py
85
86
87
88
89
90
91
92
async def stop(self) -> None:
    """Stop consuming and disconnect."""
    self._running = False
    if self._sub:
        await self._sub.unsubscribe()
    if self._nc:
        await self._nc.close()
    log.info("ETL consumer stopped")

Checkpointing

checkpointing

KV-backed checkpoint storage for pipeline state.

CheckpointStore(kv)

KV-backed progress checkpoints using NATS JetStream KV.

Each pipeline run gets a key: {pipeline_id} with value containing the last completed stage and its output state. On failure, the pipeline can resume from the last checkpoint instead of restarting.

Source code in packages/rahcp-etl/src/rahcp_etl/checkpointing.py
23
24
def __init__(self, kv: KeyValue) -> None:
    self._kv = kv

create(nc, bucket='etl-checkpoints') async classmethod

Create or bind to a KV bucket.

Source code in packages/rahcp-etl/src/rahcp_etl/checkpointing.py
26
27
28
29
30
31
32
33
34
35
@classmethod
async def create(
    cls,
    nc: nats.NATS,
    bucket: str = "etl-checkpoints",
) -> CheckpointStore:
    """Create or bind to a KV bucket."""
    js = nc.jetstream()
    kv = await js.create_key_value(bucket=bucket)
    return cls(kv)

save(pipeline_id, stage, state) async

Save a checkpoint after a successful stage.

Source code in packages/rahcp-etl/src/rahcp_etl/checkpointing.py
37
38
39
40
41
42
43
44
45
46
async def save(
    self,
    pipeline_id: str,
    stage: str,
    state: dict[str, Any],
) -> None:
    """Save a checkpoint after a successful stage."""
    payload = json.dumps({"stage": stage, "state": state}).encode()
    await self._kv.put(pipeline_id, payload)
    log.debug("Checkpoint saved: %s @ %s", pipeline_id, stage)

load(pipeline_id) async

Load the last checkpoint for a pipeline run.

Returns {"stage": str, "state": dict} or None if no checkpoint exists.

Source code in packages/rahcp-etl/src/rahcp_etl/checkpointing.py
48
49
50
51
52
53
54
55
56
57
58
async def load(self, pipeline_id: str) -> dict[str, Any] | None:
    """Load the last checkpoint for a pipeline run.

    Returns ``{"stage": str, "state": dict}`` or ``None`` if no
    checkpoint exists.
    """
    try:
        entry = await self._kv.get(pipeline_id)
        return json.loads(entry.value)
    except Exception:
        return None

clear(pipeline_id) async

Clear the checkpoint for a pipeline run.

Source code in packages/rahcp-etl/src/rahcp_etl/checkpointing.py
60
61
62
63
64
65
async def clear(self, pipeline_id: str) -> None:
    """Clear the checkpoint for a pipeline run."""
    try:
        await self._kv.delete(pipeline_id)
    except Exception:
        pass

Dead Letter Queue

dlq

Dead-letter queue handler — route failed messages for inspection/replay.

DeadLetterHandler(js)

Route permanently-failed messages to a DLQ stream for inspection/replay.

Messages in the DLQ include the original payload, the error message, and the original subject for targeted replay.

Source code in packages/rahcp-etl/src/rahcp_etl/dlq.py
29
30
def __init__(self, js: JetStreamContext) -> None:
    self._js = js

create(nc) async classmethod

Create the DLQ handler, ensuring the DLQ stream exists.

Source code in packages/rahcp-etl/src/rahcp_etl/dlq.py
32
33
34
35
36
37
@classmethod
async def create(cls, nc: nats.NATS) -> DeadLetterHandler:
    """Create the DLQ handler, ensuring the DLQ stream exists."""
    js = nc.jetstream()
    await js.find_stream_name_by_subject(DLQ_SUBJECT)
    return cls(js)

send(subject, payload, error) async

Send a failed message to the DLQ.

Source code in packages/rahcp-etl/src/rahcp_etl/dlq.py
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
async def send(
    self,
    subject: str,
    payload: bytes,
    error: str,
) -> None:
    """Send a failed message to the DLQ."""
    import json

    dlq_payload = json.dumps(
        {
            "original_subject": subject,
            "payload": payload.decode("utf-8", errors="replace"),
            "error": error,
        }
    ).encode()
    dlq_subject = f"etl.dlq.{subject}"
    await self._js.publish(dlq_subject, dlq_payload)
    log.warning("Message sent to DLQ: %s%s", subject, error)

replay(*, filter_subject=None) async

Replay DLQ messages back to their original subjects.

Returns the number of messages replayed.

Source code in packages/rahcp-etl/src/rahcp_etl/dlq.py
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
async def replay(self, *, filter_subject: str | None = None) -> int:
    """Replay DLQ messages back to their original subjects.

    Returns the number of messages replayed.
    """
    import json

    sub_subject = f"etl.dlq.{filter_subject}" if filter_subject else DLQ_SUBJECT
    sub = await self._js.subscribe(
        sub_subject, stream=DLQ_STREAM, ordered_consumer=True
    )

    count = 0
    async for msg in sub.messages:
        try:
            data = json.loads(msg.data)
            original_subject = data["original_subject"]
            original_payload = data["payload"].encode()
            await self._js.publish(original_subject, original_payload)
            count += 1
        except Exception:
            log.exception("Failed to replay DLQ message")
        # Stop after draining existing messages
        if msg.pending == 0:
            break

    await sub.unsubscribe()
    log.info("Replayed %d DLQ messages", count)
    return count

purge(*, older_than=None) async

Purge DLQ messages, optionally only those older than older_than.

Returns the number of messages purged.

Source code in packages/rahcp-etl/src/rahcp_etl/dlq.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
async def purge(self, *, older_than: timedelta | None = None) -> int:
    """Purge DLQ messages, optionally only those older than ``older_than``.

    Returns the number of messages purged.
    """
    info = await self._js.stream_info(DLQ_STREAM)
    before = info.state.messages
    if before == 0:
        return 0

    if older_than is None:
        await self._js.purge_stream(DLQ_STREAM)
        log.info("Purged %d DLQ messages", before)
        return before

    cutoff = datetime.now(UTC) - older_than
    first_keep = await self._first_seq_at_or_after(
        info.state.first_seq, info.state.last_seq, cutoff
    )
    if first_keep is None:
        await self._js.purge_stream(DLQ_STREAM)
        log.info("Purged %d DLQ messages (all older than %s)", before, older_than)
        return before
    if first_keep <= info.state.first_seq:
        return 0

    # JetStream purge with ``seq`` removes messages below that sequence.
    await self._js.purge_stream(DLQ_STREAM, seq=first_keep)
    after = (await self._js.stream_info(DLQ_STREAM)).state.messages
    purged = before - after
    log.info("Purged %d DLQ messages older than %s", purged, older_than)
    return purged

rahcp-validate

Image Validation

images

Image integrity checks — TIFF, JPEG and PNG corruption detection.

The checks are defined on bytes (magic markers + a full Pillow decode); the Path validators simply read the file and delegate, so disk and in-memory (streaming) callers share exactly one implementation.

ValidationError(source, reason)

Bases: Exception

Raised when an image fails validation.

Source code in packages/rahcp-validate/src/rahcp_validate/images.py
20
21
22
23
def __init__(self, source: Path | str, reason: str) -> None:
    super().__init__(f"{source}: {reason}")
    self.path = source
    self.reason = reason

validate_tiff_bytes(data, *, source='<bytes>')

Verify TIFF bytes: byte-order marker, version 42, and a full Pillow decode.

Source code in packages/rahcp-validate/src/rahcp_validate/images.py
38
39
40
41
42
43
44
45
46
47
def validate_tiff_bytes(data: bytes, *, source: Path | str = "<bytes>") -> None:
    """Verify TIFF bytes: byte-order marker, version 42, and a full Pillow decode."""
    if len(data) < 4:
        raise ValidationError(source, "File too small to be a valid TIFF")
    if data[:2] not in (b"II", b"MM"):
        raise ValidationError(source, f"Invalid TIFF byte order marker: {data[:2]!r}")
    version = int.from_bytes(data[2:4], "little" if data[:2] == b"II" else "big")
    if version != 42:
        raise ValidationError(source, f"Invalid TIFF version: {version} (expected 42)")
    _pil_decode(data, source, "TIFF")

validate_jpg_bytes(data, *, source='<bytes>')

Verify JPEG bytes: SOI/EOI markers and a full Pillow decode.

Source code in packages/rahcp-validate/src/rahcp_validate/images.py
50
51
52
53
54
55
56
def validate_jpg_bytes(data: bytes, *, source: Path | str = "<bytes>") -> None:
    """Verify JPEG bytes: SOI/EOI markers and a full Pillow decode."""
    if data[:2] != b"\xff\xd8":
        raise ValidationError(source, "Missing JPEG SOI marker")
    if data[-2:] != b"\xff\xd9":
        raise ValidationError(source, "Missing JPEG EOI marker (possibly truncated)")
    _pil_decode(data, source, "JPEG")

validate_png_bytes(data, *, source='<bytes>')

Verify PNG bytes: signature and a full Pillow decode.

Source code in packages/rahcp-validate/src/rahcp_validate/images.py
59
60
61
62
63
def validate_png_bytes(data: bytes, *, source: Path | str = "<bytes>") -> None:
    """Verify PNG bytes: signature and a full Pillow decode."""
    if data[:8] != b"\x89PNG\r\n\x1a\n":
        raise ValidationError(source, "Invalid PNG signature")
    _pil_decode(data, source, "PNG")

validate_tiff(path)

Verify a TIFF file is not corrupt. Raises ValidationError on failure.

Source code in packages/rahcp-validate/src/rahcp_validate/images.py
69
70
71
72
def validate_tiff(path: Path) -> None:
    """Verify a TIFF file is not corrupt. Raises ``ValidationError`` on failure."""
    _check_exists(path)
    validate_tiff_bytes(path.read_bytes(), source=path)

validate_jpg(path)

Verify a JPEG file is not corrupt. Raises ValidationError on failure.

Source code in packages/rahcp-validate/src/rahcp_validate/images.py
75
76
77
78
def validate_jpg(path: Path) -> None:
    """Verify a JPEG file is not corrupt. Raises ``ValidationError`` on failure."""
    _check_exists(path)
    validate_jpg_bytes(path.read_bytes(), source=path)

validate_png(path)

Verify a PNG file is not corrupt. Raises ValidationError on failure.

Source code in packages/rahcp-validate/src/rahcp_validate/images.py
81
82
83
84
def validate_png(path: Path) -> None:
    """Verify a PNG file is not corrupt. Raises ``ValidationError`` on failure."""
    _check_exists(path)
    validate_png_bytes(path.read_bytes(), source=path)

validate_by_extension(path)

Validate a file by its extension. Unknown extensions are skipped.

Supported: .jpg, .jpeg, .tif, .tiff, .png. Raises ValidationError if corrupt.

Source code in packages/rahcp-validate/src/rahcp_validate/images.py
106
107
108
109
110
111
112
113
def validate_by_extension(path: Path) -> None:
    """Validate a file by its extension. Unknown extensions are skipped.

    Supported: .jpg, .jpeg, .tif, .tiff, .png. Raises ``ValidationError`` if corrupt.
    """
    validator = _PATH_VALIDATORS.get(path.suffix.lower())
    if validator:
        validator(path)

validate_bytes_by_extension(data, ext, *, source='<bytes>')

Validate in-memory image bytes by extension (e.g. ".jpg").

Unknown extensions are skipped. Raises ValidationError if corrupt.

Source code in packages/rahcp-validate/src/rahcp_validate/images.py
116
117
118
119
120
121
122
123
124
125
def validate_bytes_by_extension(
    data: bytes, ext: str, *, source: Path | str = "<bytes>"
) -> None:
    """Validate in-memory image bytes by extension (e.g. ``".jpg"``).

    Unknown extensions are skipped. Raises ``ValidationError`` if corrupt.
    """
    validator = _BYTES_VALIDATORS.get(ext.lower())
    if validator:
        validator(data, source=source)

Rules

rules

Pluggable validation rules — size, dimensions, extensions.

Rule(name, check) dataclass

A named validation check.

max_file_size(limit_bytes)

Reject files larger than limit_bytes.

Source code in packages/rahcp-validate/src/rahcp_validate/rules.py
20
21
22
23
24
25
26
27
28
29
30
31
def max_file_size(limit_bytes: int) -> Rule:
    """Reject files larger than ``limit_bytes``."""

    def check(path: Path) -> None:
        size = path.stat().st_size
        if size > limit_bytes:
            raise ValidationError(
                path,
                f"File too large: {size:,} bytes (limit: {limit_bytes:,})",
            )

    return Rule(name=f"max_file_size({limit_bytes:,})", check=check)

image_dimensions(*, min_w=1, min_h=1, max_w=65535, max_h=65535)

Check image dimensions are within bounds.

Source code in packages/rahcp-validate/src/rahcp_validate/rules.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
def image_dimensions(
    *,
    min_w: int = 1,
    min_h: int = 1,
    max_w: int = 65535,
    max_h: int = 65535,
) -> Rule:
    """Check image dimensions are within bounds."""

    def check(path: Path) -> None:
        from PIL import Image  # ty: ignore[unresolved-import]

        try:
            with Image.open(path) as img:
                w, h = img.size
        except Exception as exc:
            raise ValidationError(path, f"Cannot read image dimensions: {exc}") from exc

        if w < min_w or h < min_h:
            raise ValidationError(
                path, f"Image too small: {w}x{h} (minimum: {min_w}x{min_h})"
            )
        if w > max_w or h > max_h:
            raise ValidationError(
                path, f"Image too large: {w}x{h} (maximum: {max_w}x{max_h})"
            )

    return Rule(name="image_dimensions", check=check)

allowed_extensions(*exts)

Only allow files with the given extensions (case-insensitive).

Source code in packages/rahcp-validate/src/rahcp_validate/rules.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
def allowed_extensions(*exts: str) -> Rule:
    """Only allow files with the given extensions (case-insensitive)."""
    normalized = {e.lower().lstrip(".") for e in exts}

    def check(path: Path) -> None:
        suffix = path.suffix.lower().lstrip(".")
        if suffix not in normalized:
            raise ValidationError(
                path,
                f"Extension '.{suffix}' not allowed (allowed: {', '.join(sorted(normalized))})",
            )

    return Rule(
        name=f"allowed_extensions({', '.join(sorted(normalized))})", check=check
    )

validate(path, rules)

Run all rules against a file, collecting all failures.

Does not stop on the first failure — returns all validation errors.

Source code in packages/rahcp-validate/src/rahcp_validate/rules.py
81
82
83
84
85
86
87
88
89
90
91
92
def validate(path: Path, rules: list[Rule]) -> list[ValidationError]:
    """Run all rules against a file, collecting all failures.

    Does not stop on the first failure — returns all validation errors.
    """
    errors: list[ValidationError] = []
    for rule in rules:
        try:
            rule.check(path)
        except ValidationError as exc:
            errors.append(exc)
    return errors