warmhub 0.21.0

Changed

A resumed repository export now sends the format_version of the header the download opened with, and the server refuses a resume whose format is not the one it writes: HTTP 412 client-incompatible with the message "Restart the export; a resume must use the format it started with." The refusal raises RepositoryExportError (request_failed) and the reader writes nothing further. A download started before a server upgrade that changes the export format can no longer be silently finished under its old header; the export must be restarted.

A resume of a file that holds only the header (interrupted before the first row) sends no cursor, so the server cannot check it; the session now compares the new header's format to the saved one and raises RepositoryExportError (segment_mismatch) with the same restart message rather than appending new-format rows under the old header.

Upgrade with the server. An older SDK sends no formatVersion on a resume, so a server with this change refuses every resume from it, not only cross-format ones. Upgrade the SDK in the same release as the server.

Fixed

A repository export downloaded through an export token is now also checked against the redemption's at_repo_seq, as sdk-ts already does. A download pinned to a different fence than the one the API named raises RepositoryExportError with the new reason fence_mismatch; before, only the record count and content digest were checked.

Added

fence_mismatch is a new RepositoryExportErrorReason value. Code that matches every reason exhaustively should handle it.

Changed

thing.get and thing.get_many (async, sync, and bound forms) now type data_mode as CollectionReadDataMode ("auto" | "full"), which is all the server accepts on those reads. Previously the annotation was the head-family DataMode, so get_many(data_mode="none") type-checked and then failed with a 400. thing.head, head_iter, head_all and the changes reads keep DataMode ("full" | "summary" | "none"). For a metadata-only read, use thing.head(data_mode="none"). CollectionReadDataMode is exported from warmhub.

Breaking

NAME_TAKEN names the holder by durable id only and offers no replacement name

What it was. NameTaken.taken_by was a NameTakenBy with shape and durable_id, NameTaken.suggested_name could carry a replacement name, WarmHubError.suggested_name returned it, and the message said by a <Shape> Thing and for example "<name>".

What it is now. NameTakenBy has durable_id only; shape is removed. NameTaken.suggested_name and the WarmHubError.suggested_name property are removed, and no example name appears in the message or hint. The message names no Shape: Name "acme" is already taken in this repository by another Thing; choose another name. A caller who cannot read the holder still gets no taken_by. The decoder requires only takenBy.durableId.

Why it breaks. This is a client-visible breaking change, accepted. Released sdk-py 0.19.0 and 0.20.0 decode takenBy with shape required, so they refuse a name_taken result from a server that carries this change. It lands before the v2 floor release (0.21.0), so no v2 client sees shape.

Migration. Read the holder's Shapes with thing.get on details.taken_by.durable_id; a Thing can have several Shapes or none. Choose the replacement name yourself.

# Before
if isinstance(err.details, NameTaken):
    print(err.details.taken_by.shape, err.suggested_name)

# After
if isinstance(err.details, NameTaken) and err.details.taken_by:
    holder = client.thing.get(org, repo, err.details.taken_by.durable_id)

Added

Restore and OperationBuilder.restore() make a retracted Thing active again, by its durable ID. Pass new_name to restore it under another name.

In these notes, "old grammar" means the reference form used before this release (Deal/acme, operations with kind), and "new grammar" the form this release introduces (acme, operations with shapes).

Breaking

Write operations take a name and its Shapes

What it was. Add and Revise took keyword-only name=. Add took about; both took kind, type and members; Revise also took wref. Retract, Reaffirm and Rename took kind. An add name was spelled Shape/name, and the SDK split it and converted the operation.

What it is now. name is the full Thing name, taken literally, and can be passed positionally. Every add and revise states its Shapes in exactly one way: shapes=[...], shapeless=True, or (revise only) keep_shapes=True. Passing a removed keyword to a dataclass raises TypeError naming the new spelling. Passing it to a builder method raises Python's standard "unexpected keyword argument" TypeError.

Migration.

Before:

from warmhub import Add, Revise, Rename

repo.apply("seed", [
    Add(name="Deal", kind="shape", data={"fields": {"company": "string"}}),
    Add(name="Deal/acme", data={"company": "Acme"}),
    Add(name="Rating/r1", kind="assertion", about="Deal/acme", data={"score": 5}),
    Add(name="crew", kind="collection", type="set", members=["Deal/acme"]),
])
repo.apply("update", [
    Revise(wref="Deal/acme", data={"company": "Acme 2"}),
    Rename("Deal/acme", "acme-co", kind="thing"),
])

After:

from warmhub import Add, Revise, Rename, shape_name

repo.apply("seed", [
    Add(shape_name("Deal"), shapes=[], data={"fields": {"company": "string"}}),
    Add("acme", shapes=["Deal"], data={"company": "Acme"}),
    Add("r1", shapes=["Rating"], data={"about": "acme", "score": 5}),
    Add("crew", shapes=["Set"], data={"members": ["acme"]}),
])
repo.apply("update", [
    Revise("acme", keep_shapes=True, data={"company": "Acme 2"}),
    Rename("acme", "acme-co"),
])

Old Shape/name spellings are taken literally

What it was. "Deal/acme" meant the Thing acme under Shape Deal.

What it is now. It is a Thing name. This matters wherever a name is accepted:

Migration. Rename the argument as well as adding shapes: "Deal/acme" becomes "acme", and a Shape where any Thing can appear becomes shape_name("Deal").

Builder add and revise take the name first

What it was. batch.add(name=..., kind=..., ...) and batch.revise(name=..., wref=..., ...) were keyword-only. rename took kind=.

What it is now. add(name, *, shapes=|shapeless=, data=, ...) and revise(name, *, shapes=|shapeless=|keep_shapes=, data=, ...). name= as a keyword still works. retract, reaffirm and rename lose kind=. add and revise check the operation when it is queued and raise WriteOperationError there, instead of leaving it for validate(). validate() no longer reports MISSING_NAME or MISSING_WREF. A shapes= validation mapping validates data against every stated Shape.

Migration.

Before:

batch = repo.batch(message="seed")
batch.add(name="Reading/probe-1", data={"temp_celsius": 21.4})
batch.retract("Reading/probe-0", kind="thing")
batch.commit()

After:

batch = repo.batch(message="seed")
batch.add("probe-1", shapes=["Reading"], data={"temp_celsius": 21.4})
batch.retract("probe-0")
batch.commit()

Old-grammar mappings are refused, not converted

What it was. A mapping with kind, Shape/name, top-level about or collection type/members was converted before sending.

What it is now. A mapping with kind, shape, wref, declaredShapes, about, type or members, or without operation, raises WriteOperationError before any request. The message suggests a new form, with data values replaced by "...". An add or revise mapping with no shapes (for example {"operation": "add", "name": "Deal/acme", ...}) gets the "states no Shapes" message instead. A retract, reaffirm or rename mapping is not checked for old spellings; see the previous item.

Migration.

# Before: converted and sent
repo.apply("m", [{"operation": "add", "kind": "thing", "name": "Deal/acme", "data": {"x": 1}}])

# After: WriteOperationError
#   Invalid operation at index 0: this is the v1 write form ('kind').
#   Write it in the v2 form: {"operation":"add","name":"acme","shapes":["Deal"],"data":{"x":"..."}}
repo.apply("m", [{"operation": "add", "name": "acme", "shapes": ["Deal"], "data": {"x": 1}}])

Other local refusals: non-empty shapes on a hub/shapes/X name; a shapes entry that is not a bare Shape name; affirmed_targets on a shapeless write; renaming across the Shape/Thing line; a name under the reserved hub root (RESERVED_NAME); + in a name; the same name added twice in one submission (ILLEGAL_OP_SEQUENCE).

Local write refusals raise WriteOperationError

What it was. The builder raised ValueError (validation) and RuntimeError (already submitted, in flight). repo.apply raised StreamValidationError per operation. commit.validate raised a plain WarmHubError.

What it is now. All raise WriteOperationError, a WarmHubError with operation_index (None for a whole-batch refusal). StreamValidationError is raised only for an empty operation list or an invalid submission_id. except WarmHubError still catches everything; except ValueError, except RuntimeError and except StreamValidationError no longer catch operation refusals. warmhub.operations.to_backend_stream_operation(operation, index=0) now returns the new wire form and raises WriteOperationError.

Migration.

# Before
try:
    batch.commit()
except ValueError as e:
    ...

# After
from warmhub import WriteOperationError

try:
    batch.commit()
except WriteOperationError as e:
    print(e.code, e.operation_index)

thing.rename takes full names

What it was. thing.rename(org, repo, shape_name, old_name, new_name, *, event_request_id).

What it is now. thing.rename(org, repo, name, new_name, *, event_request_id), both full names.

Migration.

# Before
client.thing.rename("acme", "world", "Deal", "acme", "acme-co", event_request_id=req_id)

# After
client.thing.rename("acme", "world", "acme", "acme-co", event_request_id=req_id)

Read models drop the singular Shape fields

What it was. ThingItem.shape_name; ThingDetail.shape, .shape_name, .validated_shape.

What it is now. Removed. Use declared_shapes (the Shapes the version states) and validated_shapes (the Shapes it satisfies, including composed ones). Both hold version-pinned names such as "Deal@v3". Generated wire classes change the same way: WireThingDetail, ThingGetManyResult items, ThingGetWithLease, ThingGraphResult and Assertion lose all three; WireThingItem, component owned Things, history versions and view-evaluate items lose shape_name. Refs items and history's thing replace shape_name with declared_shapes (history also gains validated_shapes). SynthesizedRepoContent.shape becomes declared_shapes / validated_shapes. shape= as a read filter is unchanged.

Migration.

# Before
detail.shape_name            # "Deal"
# After
detail.declared_shapes       # ("Deal@v3",)
detail.validated_shapes      # ("Deal@v3",) plus any composed Shapes

Token scopes use grants

What it was. A scope entry carried allowed_matches and allowed_shapes.

What it is now. Restrictions are a list of (paths, Shapes) pairs in grants. The output models for token.create, token.get, token.list and whoami expose grants; each item has allowed_matches and allowed_shapes (shapes, shapes_all, shapes_not, shapeless), and the entry-level fields are gone. On input, TokenCreateInputScopesItem still has entry-level allowed_matches / allowed_shapes fields, but the server refuses them. The generated TokenCreateInputScopesItemAllowedShapes, TokenCreateResultScopesItemAllowedShapes and TokenMetadataScopesItemAllowedShapes are replaced by their ...GrantsItemAllowedShapes forms.

A pair must carry a non-empty allowed_matches, or an allowed_shapes with at least one key; an empty grants, an empty pair, and an empty allowed_shapes are refused. shapeless=True in a clause is not a restriction of its own: it lets Things with no Shape through. Alone it admits every Thing the pair's paths match, shaped or shapeless; beside shapes it adds shapeless Things to the Things the clause covers. No clause restricts a pair to shapeless Things only.

Migration.

# Before
for s in info.scopes:
    print(s.allowed_matches, s.allowed_shapes)

# After
for s in info.scopes:
    for g in s.grants or ():
        print(g.allowed_matches, g.allowed_shapes)

Repository exports are format 6, and the delta fold needs format versions

What it was. Exports were format 5, and apply_repository_export_delta(base, delta) took two arguments.

What it is now. The server writes format 6 (new-grammar references, one row per Shape version, retracted: true on retracted Shape rows). This SDK reads 5 and 6; older SDKs read only 5. apply_repository_export_delta takes two required keyword arguments, and folding across formats raises RepositoryExportError("format_mismatch"). In format 6 a Shape row is keyed by (durableId, version). RepositoryExportHeader has a required format_version.

Migration.

# Before
current = apply_repository_export_delta(base, list(changed.rows))

# After
current = apply_repository_export_delta(
    base,
    list(changed.rows),
    base_format_version=full.reader.header.format_version,
    delta_format_version=changed.reader.header.format_version,
)

Writes need a server running this release

What it was. The SDK wrote to any current server.

What it is now. Against an older server, writes raise WarmHubError with code CLIENT_INCOMPATIBLE and the message "warmhub-sdk-py requires write contract 3, but the backend provides 2."

Migration. Upgrade the SDK after the server.

Generated error-details classes renumbered

What it was. In warmhub._generated.wire, CommitDiagnosticDetails8 meant reason="invalid_token", and every other *Details8 class did too.

What it is now. CommitDiagnosticDetails8 means reason="legacy-wrefs-window-ended", and invalid_token moved to CommitDiagnosticDetails9. Every *Details8 class in warmhub._generated.wire shifts the same way. WarmHubErrorDetails9 (invalid_token) and WarmHubErrorDetails10 (legacy-wrefs-sdk) are new.

Migration. Compare reason instead of the class.

from warmhub._generated.wire import CommitDiagnosticDetails8

# Before
if isinstance(d, CommitDiagnosticDetails8):  # invalid_token
    ...
# After
if getattr(d, "reason", None) == "invalid_token":
    ...

Generated write-input unions reshaped

What it was. CommitValidateInputOperationsItem0 to Item11, with kind.

What it is now. They are replaced by Item00 (add) to Item04 (rename) and Item1 (remove). StreamAppendInput.operations uses new operation classes without kind. commit_validate_payload (in warmhub.namespaces.commit_validate) now takes resolved wire operations and no skip_existing; the new plan_commit_validation(operations, skip_existing=...) plans them.

Migration. Use the top-level operation classes and client methods rather than the generated classes.

Added

Behavior worth knowing

Changed

Removed

Fixed

commit.validate now accepts a direct remove, as sdk-ts does. Pass a plain mapping: {"operation": "remove", "name": "acme", "expectedVersion": 3}. name must be a non-empty string and expectedVersion a positive integer; anything else raises WriteOperationError before any request. A remove is sent as written, is never part of the duplicate-add check and is not read for Shapes. Before, it was refused with "'operation' must be one of add, revise, retract, reaffirm, rename". There is no Remove class: commit.apply still refuses removes, and thing.remove performs one.

A raw operation with a keepShapes key that is present but not true (for example None or False) is now refused locally with "keepShapes must be true", as sdk-ts does. Before, None was treated as absent and sent.

Revise("a", shapes=[...], keep_shapes=False) now omits the key, so the operation states its Shapes and is accepted. Before, it sent keepShapes: false and was refused with "keepShapes applies only to a revise". keep_shapes=False with no other way of stating Shapes is refused as "states no Shapes".