warmhub 0.14.2
Historical changelog shipped with this version. It may include earlier releases.
Changelog — warmhub
0.14.2
Repository export v3.
Breaking
Checkpoint generation is retired — take an export instead
What it was. Producing a fresh snapshot of a repository meant asking the
server to mint a checkpoint, polling status until the job completed,
requesting a short-lived signed URL, downloading the archive outside the SDK,
and verifying the bytes locally.
What it is now. One call that streams verified rows. Framing, canonical decode, per-row schema, ordering and the trailer are checked as the bytes arrive, and a response that dies mid-body is re-requested from the last verified row into the same reader — so a stream that arrived in four pieces still verifies against one digest and one record count.
Migration.
Before:
import time
import httpx
from warmhub import verify_repository_checkpoint_archive
status = client.repo.checkpoint.generate("acme", "catalog")
while status.state != "complete":
time.sleep(1)
status = client.repo.checkpoint.status(
"acme", "catalog", checkpoint_id=status.checkpoint_id
)
access = client.repo.checkpoint.get_access(
"acme", "catalog", checkpoint="latest", artifact="archive"
)
# Signed URL: fetched WITHOUT the WarmHub bearer token.
with open("catalog.zip", "wb") as handle:
handle.write(httpx.get(access.url).content)
verify_repository_checkpoint_archive("catalog.zip")
After:
from warmhub import open_repository_export
export = open_repository_export(client, "acme", "catalog")
for row in export.rows:
print(row["kind"], row["durableId"], row["version"])
# `reader.trailer` is None until `rows` is fully consumed: the export is
# verified at that moment, not before.
print(export.reader.record_count, export.reader.content_sha256)
If you were relying on generate returning before the download — starting the
build in one process and fetching it in another, or on another machine — that
shape survives as the token flow:
import time
from warmhub import (
RepositoryExportPending,
open_repository_export_token,
request_repository_export,
)
# Process A: ask for it, get a ticket, do not read a byte.
ticket = request_repository_export(client, "acme", "catalog")
print(ticket.export_token, ticket.at_repo_seq)
# Process B: redeem it. A redemption answered 202 comes back as
# RepositoryExportPending; poll it yourself.
while True:
opened = open_repository_export_token(
client, "acme", "catalog", ticket.export_token
)
if not isinstance(opened, RepositoryExportPending):
break
print("still", opened.state) # "pending" or "running"
time.sleep(1)
for row in opened.rows:
...
The token is redeemable only at that repository's URL, by a principal holding checkpoint read there.
Why. Export format v3 replaces checkpoint generation outright; the whole generation pipeline is deleted server-side (PR #10020).
repo.checkpoint.generate and .retry are gone
What it was. generate(org_name, repo_name, at_least_repo_seq=...) minted
a checkpoint and retry(org_name, repo_name, checkpoint_id) re-ran a failed
one, on the unbound namespace and on the repository-bound one, in both the sync
and async clients. The payload builders
checkpoint_generate_payload and checkpoint_retry_payload are gone from
warmhub.namespaces.repo_checkpoint with them.
What it is now. repo.checkpoint is a read plane: status, latest,
get_access. The procedures behind generate and retry are no longer served,
and the repo:checkpoint-generate scope no longer buys anything.
Migration.
Before:
fresh = client.repo.checkpoint.generate("acme", "catalog", at_least_repo_seq=42)
recovered = client.repository("acme/catalog").repo.checkpoint.retry(
failed.checkpoint_id
)
After:
from warmhub import open_repository_export
# `at_repo_seq` pins the fence the way `at_least_repo_seq` asked for a floor.
# Omit it and the server pins current and echoes it back in the header.
export = open_repository_export(client, "acme", "catalog", at_repo_seq=42)
for row in export.rows:
...
There is no retry to replace: an export is a read, so a failed one is re-read,
not re-queued — and the reader resumes a broken transport on its own, up to
resume_limit (4 by default).
Why. Same retirement (PR #10020).
Failed checkpoint statuses now all answer next_action="none"
What it was. RepositoryCheckpointFailedDeadlineExceeded and
RepositoryCheckpointFailedAttemptsExhausted carried
next_action="retry"; RepositoryCheckpointFailedInvalidSource carried
next_action="generate". Each named a method a reader could call.
What it is now. All three carry next_action="none". The dataclasses keep
their names and their failure_code values, so pattern-matching on
failure_code is unaffected; only branching on next_action changes.
Migration.
Before:
if status.state == "failed":
if status.next_action == "retry":
client.repo.checkpoint.retry("acme", "catalog", status.checkpoint_id)
elif status.next_action == "generate":
client.repo.checkpoint.generate("acme", "catalog")
elif status.next_action == "contact_support":
report(status.checkpoint_id, status.failure_code)
After:
if status.state == "failed":
if status.next_action == "contact_support":
report(status.checkpoint_id, status.failure_code)
else:
# Terminal. Take an export instead of reviving the checkpoint.
export = open_repository_export(client, "acme", "catalog")
for row in export.rows:
...
Why. Each retired value pointed at a route the server no longer serves; leaving them in the vocabulary would send readers to a 404 (PR #10020).
Added
-
open_repository_export(client, org_name, repo_name, *, mode="heads", since_repo_seq=0, at_repo_seq=UNSET, resume_limit=4)— returns aRepositoryExportStreamwith.readerand.rows.aopen_repository_exportis theasynctwin, returningAsyncRepositoryExportStreamwhose.rowsis anasynciterator. -
request_repository_export(...)/arequest_repository_export(...)— ask the server to build the export in the background. Returns aRepositoryExportTicketwithexport_tokenandat_repo_seq. -
open_repository_export_token(client, org_name, repo_name, export_token, *, resume_limit=4)and itsaopen_...twin — redeem a ticket. Returns either the stream or aRepositoryExportPendingwhosestateis"pending"or"running"; the first redemption does not poll for you. -
RepositoryExportReader— the verification state of one logical export across one or more transport segments. Readheader,trailer,record_count,content_sha256,after_durable_id, andis_complete. It is also usable directly on bytes you obtained yourself, viaread_segment(chunks)/feed(chunk)/end_segment()/finish(). -
verify_repository_export_file(source)— re-verify a saved export end to end with no network access. Acceptsbytes,bytearray, or a path, and returns aRepositoryExportVerificationwithheader,trailer, andrecord_count.from warmhub import verify_repository_export_file result = verify_repository_export_file("catalog.ndjson") print(result.header.at_repo_seq, result.record_count) -
apply_repository_export_delta(base, delta)— fold a delta export over a base by durable identity: active rows upsert, tombstones drop. Pure and order-preserving — rows already present keep their position, new rows land at the end — and it returns alist, so folds chain.from warmhub import apply_repository_export_delta, open_repository_export full = open_repository_export(client, "acme", "catalog") base = list(full.rows) fence = full.reader.header.at_repo_seq # Later: only what changed since that fence. changed = open_repository_export(client, "acme", "catalog", since_repo_seq=fence) current = apply_repository_export_delta(base, list(changed.rows))Durable identity is the key rather than
wref, because a rename changes the wref and this fold must survive one. -
RepositoryExportErrorwith a typedreason:"count_mismatch","digest_mismatch","non_canonical_line","record_invalid","request_failed","row_order","segment_mismatch","since_below_epoch_floor","stream_closed","truncated_stream","unexpected_response"."since_below_epoch_floor"is the only one that is not a fault: the requested delta base is older than the server retains, and the recovery is to re-run the export withsince_repo_seq=0. -
RepositoryExportHeader,RepositoryExportTrailer,RepositoryExportRow,RepositoryExportMode,RepositoryExportVerification— the record types. A row is a validated mapping, not a dataclass:dataholds your own Shape fields, so decoding it into a frozen class would only be something you had to undo. The envelope keys —kind,wref,durableId,version,active— are guaranteed present. -
stream=TrueonSyncTransport.send/AsyncTransport.send— returns before the body is read, for responses whose whole point is not to be buffered. The caller owns closing the response.
Behavior worth knowing
-
A broken transport recovers itself, differently on the two paths. A direct stream that ends short, or dies mid-body, is re-requested from
after_durable_idinto the same reader; the partial trailing line is never fed, because the nextbegin_segmentdrops it. A redeemed token has no cursor — its bytes come from storage rather than the API — so a dead transfer fetches the object again from the top and the reader restarts with it. Either way you are never handed the same row twice. Both are bounded byresume_limit(default 4) and raisetruncated_streamwhen it runs out — a server that keeps cutting the stream should surface, not spin. -
A redeemed token is downloaded from storage, then held to what the API said. A complete redemption answers with a short-lived presigned URL rather than the export bytes, and the SDK fetches that URL without your WarmHub bearer: the signature in the URL is the whole capability, and the storage host has no business seeing a credential. The record count and content digest the reader derives are then checked against the ones the API named, because the trailer inside the object cannot vouch for the object — a download that does not match raises
count_mismatchordigest_mismatchrather than handing you rows nothing independent vouched for. An expired URL re-redeems the token, which is a deterministic lookup, and retries. -
Ordering is verified, in
headsmode only. A heads export is a set emitted in ascending durable-identity order, and that order is part of what the digest proves; a row that does not advance the cursor raisesrow_order. Anopsexport is a log and repeats identities by design. -
A trailer describes its segment, not the export.
RepositoryExportTrailercarries therecord_countandcontent_sha256of the rows in the segment it closed — the whole export only when it arrived in one piece.reader.record_countandreader.content_sha256are the whole-stream values. -
202means different things on different paths. On a token redemption it is the job still building, and comes back asRepositoryExportPending. On a direct or resumed stream request it isunexpected_response— there, the stream itself was supposed to be the whole answer. -
Verification finishes with the rows.
reader.trailerisNoneuntilrowshas been fully consumed. Abandoning the iterator mid-way leaves the export unverified; do not treat partially-read rows as proven.
Still supported
The checkpoint read plane is untouched: repo.checkpoint.status,
.latest, .get_access, and verify_repository_checkpoint_archive all continue
to work against archives already in object storage. Nothing mints new ones.
Deprecation horizon: those read surfaces are supported until the announced cleanup, 30 days after GA. Their removal is a separately-filed post-GA change. Move download-and-verify workflows to the export surface before then.