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"),
])
- In this example,
Ratingis an existing Shape that composesAssertion. An assertion is a Thing one of whose declared Shapes composesAssertion, with its target indata["about"]. shapestakes bare Shape names. A pinnedDeal@v2is refused: a write is always checked against each Shape's current version.- A raw mapping states a shapeless write as
"shapes": [].shapeless=Trueis a dataclass and builder option, and is refused on ahub/shapes/Xname. - A
keep_shapesrevise of a Thing added or renamed earlier in the same submission is refused; stateshapesinstead.
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:
Add(name="Deal/acme", data=...)still has a validname=keyword, so it does not raiseTypeError. It raisesWriteOperationError("states no Shapes"). If you only addshapes=and keep the name,Add(name="Deal/acme", shapes=["Deal"], ...)succeeds and creates a new Thing literally namedDeal/acme.Retract("Deal/acme"),Reaffirm("Deal/acme", ...)andRename("Deal/acme", ...)pass the local checks and are sent as written. The server answersNOT_FOUND, naming the new form when it can, or acts on a Thing literally namedDeal/acmeif one exists.Revise("Deal/acme", keep_shapes=True)fails locally withNOT_FOUND(no such Thing), with no hint.- Read calls (
thing.get,get_many, history,thing.remove, collection calls,CommitOptions.committer,client.repository("wh:...")) take the new form. An old spelling is looked up literally: a single read getsNOT_FOUND;get_manylists it inmissingand may explain it inmissing_hints. Returnedwrefvalues use the new form too.
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
shape_name(bare)—"Deal"to"hub/shapes/Deal"; an already prefixed name is returned unchanged.from warmhub import shape_name shape_name("Deal") # "hub/shapes/Deal"WriteOperationError— aWarmHubErrorwithoperation_index; codesVALIDATION_ERROR,NOT_FOUND,RESERVED_NAME,ILLEGAL_OP_SEQUENCE, and Shape-name check codes.statusis 404 forNOT_FOUND, else 400.Revise(..., keep_shapes=True)— at submit the SDK reads the Thing's current Shapes and version (one read per 500 Things) and sends both, the version asexpectedVersion; a suppliedexpected_versionwins. A missing Thing is refused withNOT_FOUND. A Thing added, or renamed to or from, earlier in the same submission is refused. A Thing revised earlier in the same submission reuses that revise's Shapes.keep_shapespins the version it read, so a resubmit of the samesubmission_idfrom another process can conflict; explicitshapesis the retry-safe form.repo.apply("m", [Revise("acme", keep_shapes=True, data={"company": "Acme 2"})])shapeless=True— onAdd,Reviseand the builder; sent asshapes: [].OperationWarnings.shapeless—Truewhen a write left the Thing with no Shape.res = repo.apply("m", [Add("scratch", shapeless=True, data={})]) res.operations[0].warnings.shapeless # Trueshapelessread filter — onthing.head,head_iter,head_all,thing.query,count,query_iterandquery_all(sync, async and repository-bound). Not onsearch.client.thing.head("acme", "crm", shapeless=True)ThingGetManyResult.missing_hints— for a missing reference that is an oldShape/namespelling of a Thing you can read, the text naming its new form.DEPENDENCY_FAILED— constant for the per-operation code of an operation that failed only because an operation it read from failed.LEGACY_WREFS_REFUSAL— the refusal message string.RepositoryExportReader.after_version— resume position inside a format 6 Shape; the client sends it on resume.RepositoryExportErrorReasongains"format_mismatch".redacted_resource— may appear onTokenCreateResultscopes.
Behavior worth knowing
client_flags=["legacy-wrefs"]refuses every request. The SDK does not send old-grammar requests. With the flag set, every request (reads, writes, the capabilities check, export download) raisesWarmHubErrorwith codeCLIENT_INCOMPATIBLE, a message equal towarmhub.LEGACY_WREFS_REFUSAL, anddetails.reason == "legacy-wrefs-sdk". Use thewhCLI to submit old-grammar files during the compatibility window.client = WarmHubClient(..., client_flags=["legacy-wrefs"]) client.thing.get("acme", "world", "acme") # WarmHubError CLIENT_INCOMPATIBLE: # `legacy-wrefs` is set; the SDKs are v2-only. Unset it, or use the `wh` CLI for v1.
Changed
collection.revise— before, a selector-backed revise whose target was spelled with another collection Shape (List/...,Arc/...) was refused locally. Now the server decides.- Local sequence checks —
repo.applynow checks only for the same name added twice; other sequence problems are reported by the server.commit.validatenow also refuses adding the same name twice locally. warmhub.rules.preflight_op_diagnosticsmessages teach the new form, for exampleCannot add kind="thing" under built-in shape "Arc". Name the Thing and state the Shape instead: {...}, and refuse a write declaring the abstractCollectionShape. The SDK's own write paths refusekindoperations before these checks run.- Content-limit error labels —
Field "Readme.content" is N bytes; ...instead ofField "Content/Readme.content" is N bytes; ..., and the same forAgents.content. - Pinned Shapes on
stream.appendare refused locally.
Removed
- Dataclass and builder keywords
kind,about,type,members,wref. ThingItem.shape_name;ThingDetail.shape,.shape_name,.validated_shape; the matching generated wire fields.- Entry-level
allowed_matches/allowed_shapeson token scope output models. - Generated
CommitValidateInputOperationsItem0–Item11and the threeToken*ScopesItemAllowedShapesclasses named above.
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".