This release is a pre-release and may not be stable for production use.
matelab-python-sdk
Reusable async Python client for the Matelab Integration Contract.
This README describes the current SDK interface, integration semantics, examples, and runnable workflows. See
CONTEXT.md for domain terminology, CHANGELOG.md for version deltas,
AGENTS.md for durable maintenance rules, and docs/operation-coverage.yaml for the machine-readable
operation inventory.
The current alpha is 0.1.0a19. [project].version in pyproject.toml is the sole SDK version source;
uv.lock only mirrors that source.
The SDK is pinned to the rewritten matelab-spec v0.4.2 Contract Release. The sole release pin is
contracts/matelab-integration-v1.lock.json, which records
the source tag, commit, OpenAPI path, local snapshot path, and SHA-256.
Installation
Python 3.11 or newer is required. Install the alpha from a package index with either:
uv add matelab-python-sdk
python -m pip install matelab-python-sdk
Development installs use the locked checkout:
uv sync --frozen
To test the same artifact a downstream Consumer will install, build and install the wheel:
uv build --no-build-isolation --out-dir dist/release
python -m pip install dist/release/matelab_python_sdk-0.1.0a19-py3-none-any.whl
Do not infer Provider compatibility from the SDK version alone. A release is also bound to the Contract tag, commit, and checksum recorded below.
The Contract and public SDK interface track the current reviewed Provider at
a3e6b961800f6b1ab666f0d3f6cd4c64c2a3ce27. Provider limitations that remain relevant to integrations are documented
alongside the affected interfaces below.
Design
The public module is intentionally small:
from matelab import AsyncMatelab, RecordLocator
async with AsyncMatelab() as client:
session = await client.authenticate("user@example.org", "password")
assert client.session is session
notebooks = await client.notebooks.list()
notebook = notebooks.owned[0].ref
records = await client.records.list(notebook=notebook)
source = RecordLocator(notebook=notebook, record=records.records[0].ref)
record = await client.records.read(source)
AsyncMatelab() uses https://matelab.iphy.ac.cn/api by default. Pass another Provider API root
explicitly when needed, for example AsyncMatelab("https://custom.example/api").
Error handling
Catch MatelabError once at an integration seam. Every instance exposes a stable category: MatelabErrorCategory and
retryable: bool. MatelabProviderError additionally exposes provider_code: int and provider_message: str, so a
gateway can retain a stable diagnostic code and safe summary while routing recovery through the integration-level
category:
| Error | Category | Retryable | Meaning and normal response |
|---|---|---|---|
MatelabAuthenticationError |
AUTHENTICATION |
No | The Session or credentials cannot authenticate; obtain valid authentication before making a new call. |
MatelabUsageError |
VALIDATION |
No | The call cannot be represented safely; correct its arguments. |
MatelabProviderError |
BUSINESS, VALIDATION, or UPSTREAM |
No | The Provider rejected the request; record provider_code and route recovery by category; provider_message is an SDK-owned safe summary. |
MatelabTransportError |
UPSTREAM |
No | The HTTP exchange failed; status_code is present for HTTP failures, and a mutation outcome may be unknown. |
MatelabProtocolError |
UPSTREAM |
No | The response violates the pinned Contract; treat it as Provider drift or an SDK defect. |
For MatelabProviderError, the SDK maps Provider wire code 2 to BUSINESS, 4 to VALIDATION, and 3 or an
unknown code to UPSTREAM. provider_code is diagnostic metadata, not a stable routing identifier, and integrations
must not reinterpret it instead of category. provider_message and the exception string are SDK-owned safe summaries
selected from the code mapping; they do not reproduce Provider-authored text. Authentication codes remain hidden behind
MatelabAuthenticationError; code 5
triggers at most one refresh and replay only for authenticated operations that explicitly enable
retry_on_access_expired, otherwise codes 1 and 5 raise MatelabAuthenticationError directly.
Codes 0 and 10 remain operation-specific successes selected by the pinned Contract; an operation that receives a
success code it does not allow raises MatelabProtocolError rather than assigning an error category.
MatelabTransportError.status_code remains available for HTTP failures. Provider response bodies, top-level msg,
legacy errmsg, opaque errs, uncontracted debug fields, and request payloads are not attached to exceptions because
Provider-authored diagnostics may contain stack traces, credentials, or submitted values.
The pinned v0.4.2 Contract also declares optional code-4 errs, but PVD-041 and PCG-015 record opaque array/object
containers and insufficient evidence for requiredness, cardinality, or exclusion from other codes. The SDK
therefore keeps those diagnostics wire-only and does not expose errs as stable public metadata. A typed detail
interface remains blocked until a future Contract release can define one from stronger Provider evidence. retryable
means the identical SDK call is safe to replay without further interpretation. It is conservatively False for every
current SDK error because the Contract does not guarantee mutation idempotency or outcome; an integration may add a
narrower operation-specific retry policy only when it owns that evidence.
Session ownership
Each AsyncMatelab instance owns at most one current, process-local Session. The SDK injects its bearer
token, refreshes it under a per-instance async lock, performs bounded safe retries, and exposes every token
rotation through client.session. If refresh succeeds but the subsequent business request fails,
client.session still contains the refreshed token pair.
| SDK responsibility | Integrator responsibility |
|---|---|
| Bearer injection, expiry checks, refresh and bounded retry | Redis/database/file persistence and encryption |
| Per-instance, in-process refresh serialization | Cross-process locking and conflict handling |
| Contract validation of token and identity responses | Mapping userid/session_id to a persisted Session |
Latest immutable Session through client.session |
Revocation, cleanup, and saving after each call |
Session, Token, and Identity are frozen Pydantic models and form the stable, normalized SDK Session contract.
Their JSON fields are access, refresh, and identity; tokens contain value and expires_at_ms, while identity
contains userid, username, and email. Provider envelope fields are not part of this model. An Identity requires
a positive userid and non-empty username; its email may be None.
Token values are excluded from model representations but intentionally remain present in model_dump() and
model_dump_json() so an integration can persist and restore the complete Session:
serialized = session.model_dump_json()
restored = Session.model_validate_json(serialized)
assert restored == session
The serialized result contains live credentials. Encrypt it at rest and never write it to logs or send it to an untrusted party. The SDK does not read tokens from environment variables and does not provide a session store.
Credential authentication installs the returned Session on the client:
async with AsyncMatelab() as client:
session = await client.authenticate(username, password)
assert client.session is session
Restore a previously validated Session by passing it to the constructor. Construction performs no network request:
persisted_session = await session_store.load(userid, session_id)
async with AsyncMatelab(session=persisted_session) as client:
result = await handle_request(client)
Every Session contains a validated identity, and refreshing a restored Session preserves it. Integrations that construct a Session from an external assertion are responsible for validating that assertion and the identity-token association before passing the complete Session to the SDK; the SDK does not accept bare external token pairs.
A Web or MCP integration should create one client for one logical session, then save the latest Session in
finally, including when a business call fails after refresh:
persisted_session = await session_store.load(userid, session_id)
client = AsyncMatelab(session=persisted_session, http_client=shared_http_client)
try:
result = await handle_request(client)
finally:
latest_session = client.session
try:
if latest_session is not None:
await session_store.save(userid, session_id, latest_session)
finally:
await client.aclose()
If several processes can use the same persisted session, the integration must place its own distributed lock
around load, use, and save. The SDK lock only coordinates refreshes inside one AsyncMatelab instance.
Different logical sessions require different clients. They may reuse the same externally managed HTTP connection
pool, but must never share one global AsyncMatelab singleton:
alice_client = AsyncMatelab(session=alice_session, http_client=shared_http_client)
bob_client = AsyncMatelab(session=bob_session, http_client=shared_http_client)
An injected http_client must be an httpx2.AsyncClient. The independently distributed httpx.AsyncClient has
similar methods but uses incompatible request, response, transport, and exception types.
src/matelab/_generated is a private wire layer. Applications should not depend on its file
layout or generated class names. The distribution includes py.typed, so type checkers can consume the
public annotations directly from an installed wheel.
The current public domain scope includes authentication, group/user discovery, template and notebook lifecycle operations, record discovery/lifecycle operations, comment reads, and streaming record or comment attachment downloads, resumable file staging, literature discovery/lifecycle workflows, and personal cloud-drive management.
Uploading a record attachment
records.upload_attachment() prepares a file without choosing a target record. The returned StagedAttachment can
be supplied to one later import_dataset() or update() in the same notebook by the same authenticated uploader:
from matelab import RecordImportItem
content = b"measurement data"
staged = await client.records.upload_attachment(notebook, filename="measurement.csv", content=content)
record_ids = await client.records.import_dataset(
notebook=notebook,
template_title="Example Template",
items=(
RecordImportItem(
record_uid="REC-IMPORT-001",
title="Imported measurement",
data={"Attachments": {"File": [staged]}},
keywords=("measurement", "calibration"),
),
),
)
RecordImportItem.data uses the recursive RecordImportValue type: JSON-compatible scalars and nested mappings,
lists, or tuples may contain a StagedAttachment wherever the template expects a file. Arbitrary Python
objects are outside the interface and are also rejected at runtime before a Provider request.
content accepts bytes, a synchronous IO[bytes], or an AsyncIterable[bytes]. Callers do not supply a size,
checksum, or multipart content type. The SDK calculates the SHA-256 itself; streams are fully consumed into a
SpooledTemporaryFile, rewound, and only then sent to the Provider. Temporary-file write, seek, read, and close
operations run outside the event-loop thread, including after the spool rolls to disk. The Contract fixes attachment
parts to application/octet-stream.
An exception raised by an async source is propagated unchanged after the SDK closes its temporary file. The SDK reads
streams through end-of-file and imposes no upload-size policy; callers remain responsible for limits such as an HTTP
endpoint's maximum accepted body size. records.upload_comment_attachment() uses the same content interface and also
generates its Provider binding internally. When multiple new attachments will be saved into one comment, pass the
first staged comment attachment as batch= on each later upload; callers never handle the Provider UID directly.
uploads.stage() remains a separate resumable-fragment interface and does not accept async content because its
per-fragment offset and completion semantics are different.
StagedAttachment carries the upload notebook selector, uploader, Provider-resolved binding, filename, and hash.
Callers do not supply a staging identity; the SDK generates the upload-event value and uses the validated response
metadata as the completed staging result. The handle deliberately carries no RecordLocator: the Provider staging row
is not bound to a record, so record identity belongs only to the later import or update operation. Provider temporary
row IDs are not exposed.
The Provider supplies no staging status, abort, TTL, atomicity, idempotency, or retry guarantee. Integrations that
persist handles must durably claim their own uploaded -> finalizing/indeterminate transition before calling a
finalizer.
Attachment-bearing record update boundary
records.update(source, changes, ...) accepts one non-empty sequence of RecordChange intents. Value updates and
deletions, module changes, attachments, file structure, and rich text all cross this single interface; the SDK reads
the current record, validates conflicts, and compiles them into the Provider's modify, del, addModule,
delModule, and add operation families. The input sequence groups update intent; it is not an execution-order DSL,
because the Provider operation families determine wire ordering.
For an existing record, upload against its notebook and pass the same StagedAttachment type as one change; callers
never construct Provider attachment paths or attachment strings:
from matelab import RecordFormAttachmentFieldAddition, RecordTableFileCellSet, RecordTableRowAttachmentAppend
uploaded = await client.records.upload_attachment(source.notebook, filename="measurement.csv", content=content)
await client.records.update(
source, (RecordFormAttachmentFieldAddition(module="Attachments", name="Measurement", attachment=uploaded),)
)
# Each of the following is a separate update with a fresh upload.
await client.records.update(
source, (RecordTableFileCellSet(table="Measurements", column="Evidence", row=0, attachment=another_uploaded),)
)
await client.records.update(
source,
(
RecordTableRowAttachmentAppend(
table="Measurements", file_column="Evidence", values={"Label": "Sample C"}, attachment=third_uploaded
),
),
)
These three operations use the Provider's staged-name finalizer and must be the only mutation in their update request.
RecordTableFileCellSet requires an existing file column and a cell whose immediate canonical value is exactly
null. RecordTableRowAttachmentAppend addresses the new row by the row count from the SDK's immediate read and
supports one file column. A locally rejected target does not spend the handle; once transport starts, an ambiguous
result cannot be retried through the same client.
Files-module append and table/files replacement use the general record attachment intents and may be combined with
other non-conflicting record changes. RecordFilesAttachmentAppend targets the module root; the current Provider does
not accept a files-folder path (PVD-044):
from matelab import RecordAttachmentReplacement, RecordFilesAttachmentAppend, RecordValueUpdate
await client.records.update(
source,
(
RecordValueUpdate(path=("Metadata", "Reviewed"), value=True),
RecordFilesAttachmentAppend(module="Files", attachment=files_uploaded, caption="Evidence"),
),
)
A replacement starts with an occurrence returned by records.read(); applications must not fabricate a
RecordAttachmentRef. The same RecordAttachmentReplacement covers both table-file and files-module occurrences:
record = await client.records.read(source)
existing = next(
attachment
for attachment in record.attachments
if attachment.location is not None and attachment.location.kind == "files_module"
)
await client.records.update(
source, (RecordAttachmentReplacement(existing=existing, replacement=replacement, caption=None),)
)
For a files occurrence, caption=None preserves the observed string caption (an observed null caption normalizes to
the required empty string). Table replacement requires exactly one current attachment in the selected cell and does
not accept a caption. Both forms require the replacement hash to differ from the current occurrence.
All record attachment intents reject handles from another notebook or authenticated uploader, duplicate use within one finalization request, and raw Provider attachment references. Row/index-based operations use an immediate SDK read and must not be called while a concurrent editor is known to be active. The Provider offers no expected hash or revision, so the SDK does not present its read-before-write checks as concurrency control.
File-bearing structure changes do not consume staging:
from matelab import RecordFormFileFieldDeletion, RecordTableFileColumnAddition, RecordTableFileColumnDeletion
await client.records.update(
source,
(
RecordTableFileColumnAddition(table="Measurements", name="Additional evidence"),
RecordFormFileFieldDeletion(form="Attachments", name="Obsolete evidence"),
RecordTableFileColumnDeletion(table="Measurements", name="Old evidence"),
),
)
The SDK emits the v0.1.2 dedicated operations: a new file column omits wire data; whole
form-field/table-column deletion uses a strict two-segment target. It does not expose a three-segment delete as
table-cell clearing.
Deleting a complete canonical module is also supported even when the current read observes attachments in it:
from matelab import RecordModuleDeletion
result = await client.records.update(source, (RecordModuleDeletion("Raw files"),))
This requests only a canonical record-content mutation. A successful response acknowledges the submitted mutation;
it does not prove attachment-quote cleanup or byte deletion. Collaboration-pending is reported as
"pending", not persisted.
RecordRichTextUpdate accepts the same StagedAttachment as import and other update intents. Attachment-bearing
multi-record import is forbidden, so callers must split it into single-record imports with a fresh staged attachment
for each request. Once an import or update request using a staged attachment starts, that handle cannot be retried or
used for the other finalizer through the same client.
Operation coverage
The SDK tracks all 71 Contract operations and exposes 69 through public domain interfaces; identity bootstrap and the frontend literature-creation template are intentionally unexposed. It deliberately excludes MCP migration, adjacent-repository changes, external publishing, and automatic mutation against a real Provider.
Machine-readable status lives in
docs/operation-coverage.yaml. An exact-coverage test keeps its 71
operation IDs, methods, paths, states, public interfaces, and Provider issue references aligned with the pinned
OpenAPI snapshot.
| Domain | Implemented | Planned | Current public surface |
|---|---|---|---|
| Authentication | 3 | 0 | authenticate, refresh, exchange_chat_sso_code; identity bootstrap intentionally unexposed |
| Groups and users | 2 | 0 | groups.list, users.search |
| Notebooks | 6 | 0 | notebooks.list/create/update/shares/share/update_share/unshare |
| Records | 18 | 0 | Discovery, reads, lifecycle, typed patch/attachments, relations, and downloads |
| Comments | 5 | 0 | Read, staged attachment upload, create/update/delete, and download |
| Templates | 13 | 0 | Discovery, content, lifecycle, sharing, groups, and marketplace |
| File staging | 1 | 0 | Resumable fragment staging and compensating abort request |
| Literature | 12 | 0 | Libraries, items, canonical metadata, comments, sharing, PDF lifecycle and streaming |
| Cloud drive | 9 | 0 | Personal root/folders/files, staged binding, metadata, move/delete and streaming |
| Total | 69 | 0 | Two operations are intentionally unexposed |
Stability and known capability limits
Coverage currently contains 13 stable, 56 experimental, and two not_applicable operations. The stable operation
IDs are loginTokenSet, refreshTokenSet, exchangeChatSsoCode,
shareMultipleTemplatesWithUsers, removeTemplateFromGroup, deleteNotebookShare, listNotebooks,
listNotebookRecords, exportRecords, deleteRecordsByUid, copyRecord, readRecord,
and deletePersonalLiteratureItem.
Every other implemented operation is explicitly experimental; the exact per-operation list and its
PVD/PCG references live in
docs/operation-coverage.yaml. resolveCurrentIdentity is intentionally unexposed because the SDK accepts only
complete, integration-validated Sessions and does not bind bare external token pairs. readLiteratureCreateTemplate
is intentionally unexposed because it returns a frontend form definition and an optional deployment flag rather than
a resource needed by the SDK interface. There are no planned operations. Experimental support means the SDK
validates and exposes the pinned Contract while preserving limitations such as unstable ordering/pagination,
incomplete mutation acknowledgements, missing batch atomicity or idempotency, weak attachment ownership binding, and known
Provider authorization gaps. It does not turn those limitations into SDK guarantees.
Chat iframe SSO consumes a one-time code and shared key. Both arguments are treated as secrets, the request is never
automatically retried, and the returned token set is stored in the same in-memory Session shape as credential login:
session = await client.exchange_chat_sso_code(code="chat-sanitizedcode123", key="sanitized-shared-key")
Group and user discovery expose sharing identities without inventing Provider pagination:
groups = await client.groups.list()
targets = await client.users.search("Example Researcher", global_scope=False)
groups.list() returns the GroupSummary tuple directly. Ordering remains Provider-unspecified and is documented
rather than repeated as a constant result field. The Provider's members for an unstable first group remain wire-only;
users.search() is the public recipient-discovery interface. These two discovery interfaces are experimental because
group order is unstable and user search is unpaged, unordered and not field-minimized (PVD-006, PVD-029).
GroupSummary.notebook_creation_available expresses whether the group can currently host notebook creation without
exposing the Provider's data-server routing value.
Notebook create and direct sharing use the Provider acknowledgement without an automatic follow-up read:
from dataclasses import replace
await client.notebooks.create(title="Example Notebook")
shares = await client.notebooks.shares(notebook)
await client.notebooks.share(notebook, [target.ref])
share = shares.shares[0]
await client.notebooks.update_share(
share.ref, replace(share.permissions, can_write_records=True, can_create_records=True)
)
Create, update, share, permission update, and unshare return None because their Provider responses contain no new
resource representation. Updating notebook metadata first reads the current owned-notebook wire snapshot so the SDK
can preserve the Provider's UI-only type tags and list-display configuration, which the public SDK does not expose.
Consequently, update performs one list pre-read followed by the write. This preservation is best effort because the
Provider offers no compare-and-swap or atomic read-modify-write operation. Call list() or shares() explicitly when
the application needs current state. NotebookPermissions normalizes the Provider's stored and effective masks and
is reused by record pages instead of maintaining a second nearly identical permission model. Direct shares always
have implicit read access even when every optional permission is false (PVD-010).
provider_signing_allowed only represents Provider policy, not an SDK signing operation. Share-list order remains
unspecified.
Template discovery keeps a template database identity separate from direct-share, market-acquisition, and group relation identities:
templates = await client.templates.list()
market = await client.templates.search_market("calibration", page=1, page_size=20)
owned = next(template for template in templates if template.scope == "owned")
modules = await client.templates.read(owned.ref)
Template content is exposed as canonical TemplateModule values and saved as a complete replacement:
modules = await client.templates.read(template)
# Build the complete replacement from these canonical modules.
await client.templates.save_content(template, modules)
TemplateModule.data_present distinguishes a missing data property from an explicit null value, while
attributes prevents additive canonical properties from being dropped during a read/save cycle. The SDK does not
synthesize frontend editor UIDs, layout widths, rows, folders, option encoding, or a fixed set of display module
types. The Provider supports only whole-content replacement and has no atomic patch or compare-and-swap operation
(PCG-007), so
a read-modify-save sequence can overwrite a concurrent change.
The market result retains the Provider's total_count and derives has_more without echoing the caller's page
arguments; it does not claim a stable order or continuation token. Template reads remain experimental
because Provider discovery ordering/pagination and historical images compatibility are not fully stable
(PCG-003, PCG-009, PVD-013, PVD-022).
templates.list() returns one tuple. TemplateSummary.scope is limited to owned, shared, group, and public;
Provider marketplace acquisitions and system defaults are both public templates. An acquired public template carries
a user-library relation while a default does not. Provider pending[] is an administrator review queue rather than a
visibility scope and is not returned as an ordinary template. Owned rows expose their actual submission state through
market_review_pending. User-library and group-library relations occupy different identity spaces;
TemplateLibraryEntryRef.kind records that distinction and remove_from_library() routes to the correct Provider
operation. Direct shares and marketplace acquisitions intentionally share the same user-library removal semantics.
Template writes remain separate operations: metadata, canonical modules, and usage HTML are not presented as one
transaction. Creation returns the new TemplateRef; metadata replacement and other mutations return None because
the Provider supplies no new resource representation. update() therefore requires the complete title, summary, and
keyword set rather than implying a partial patch. update_usage() generates the Provider's required fresh hidden
correlation value internally. Usage attachments remain unsupported until their staging and binding lifecycle is
contracted safely (PVD-026). Marketplace revision limitations remain explicit through content_version,
published_content_version, and market_review_reason (PVD-021).
Extended record reads stay behind the same records interface:
from matelab import RecordLocator
source = RecordLocator(notebook=notebook, record=record)
exported = await client.records.export([source])
matches = await client.records.search({"notes": ("Notes",)}, notebooks=[notebook])
page = await client.records.page(notebook, search="voltage")
deleted = await client.records.recycle_bin(notebook)
relations = await client.records.relations(source)
records.export() returns ExportedRecord snapshots directly as a tuple. Their canonical stored content is exposed
as modules; it is not the template-shaped RecordImportItem.data accepted by record import.
records.search() maps each requested field name to a stored record path. A match exposes only Provider-returned
entries in values, so key membership distinguishes an absent result from an explicit JSON null.
The current Provider excludes encrypted records from search (PVD-043); an empty search result therefore does
not prove that no matching encrypted record exists.
The Provider calls its notebook directory tree subtype; that name remains wire-only. The SDK exposes
RecordFolder, RecordSummary.folder_id, and the folders returned with record listings. A top-level folder has
parent_folder_id=None, and SQL NULL counts are normalized to zero. Records at the notebook root have
folder_id=None. For records.page(), omit folder_id to include all folders, pass 0 for root records, or pass a
positive folder ID for one folder.
records.page fixes the legacy request to page_size=0&default=1, preventing the known owner-preference writes
described by PVD-039; its total is derived internally from the Provider's complete matching ID list. The result keeps
semantic NotebookPermissions directly, without a shallow context model or duplicated notebook/owner/template
display metadata. Effective request pagination, the UI selection list and Provider keyword-filter echo are not exposed.
Its search argument, shared with records.list(), maps the Provider's wire-level content filter without exposing
that ambiguous name.
It remains distinct from the unpaged integration records.list() operation: only the legacy page response supplies
effective permissions, folders and total matching identities, so merging the two would introduce nullable fields.
Active and deleted records use the same RecordRef; recycle-bin state is expressed by DeletedRecordSummary, not a
second identity type. Public catalog records use a distinct identity because their catalog and source IDs differ.
Public summaries keep source notebook attribution but omit the Provider's unused publication-lock residue and owner
email; their notebook_title follows the same naming used by export and search results. Relation refs retain only the
stored relation row and declared target IDs; nullable JOIN previews and resolved target identity stay wire-only.
Search and relation order remain unspecified, and no continuation token is invented. Export and search return tuples
directly; Provider code 10 only reports transparent data-server forwarding and does not create a second public result
shape.
DeletedRecordPage does not repeat its input notebook.
Record creation keeps blank creation and structured import as separate capabilities:
from matelab import RecordImportItem
await client.records.create_blank(
notebook=notebook, title="Blank Record", record_uid="caller-generated-uid", folder_path=("Measurements",)
)
imported = await client.records.import_dataset(
notebook=notebook,
template_title="Example Template",
items=[
RecordImportItem(
record_uid="import-uid", title="Imported", data={"Notes": "value"}, folder_path=("Measurements",)
)
],
)
Blank-record creation returns None because the Provider returns no record identity, even when the caller supplies a
UID. Import validates the complete batch with generated wire models but cannot map returned database IDs to
individual inputs or promise atomicity (PCG-008); it therefore returns only the tuple of database IDs actually
reported by the Provider. RecordImportItem.keywords is a tuple; the adapter owns the Provider's
legacy semicolon encoding. Folder paths are ordered existing folder titles from the notebook root; they do not create
missing folders. The current Provider rejects explicit-null table-file imports (PVD-045). Because the SDK accepts
arbitrary template-shaped data and cannot identify file columns without the template definition, integrations must omit
an unset file-valued key instead of sending it as None. Delete means moving records into the recycle bin, not permanent
deletion. Delete and restore return None; record mutations are not automatically retried.
records.copy() accepts an optional new_record_uid; when omitted, the SDK generates one locally. The Provider
returns only the copied row's database ID, so retaining that UID lets the SDK return a complete RecordLocator
without a readback request. Omitting target_notebook copies within the source notebook.
Record patching exposes a deliberately narrower capability than the raw Provider operation. Scalar/module changes
cannot smuggle Provider-native attachment strings; staged attachments use separate form-removal, table-replacement,
files append/replace/remove, and rich-text types. Unsafe form replacement and table-file removal are absent, while a
files/images removal is rejected when the observed module contains the same hash more than once (PVD-014 through
PVD-016). The SDK reads current content only when a change needs attachment type, occurrence, row, or index state;
ordinary module creation and deletion rely on the Provider's own canonical update validation. records.update()
returns the acknowledgement classification
"persisted", "pending", or "unknown"; it does not
issue a post-write read. Database, active-browser, and unclassified acknowledgements remain distinct, and mutation
retries stay disabled. Encrypted record content is outside the SDK's public read/update interface: the list-level
RecordSummary.encrypted flag lets callers identify and skip it, while the Provider password field and frontend-only
password lifecycle remain unexposed. Files-module attachment append is root-only because the current Provider rejects
folder-path segments (PVD-044). A staged attachment is indeterminate once the write has started.
Relation addition reads both endpoints and checks their resolved data server before writing; this reduces PVD-019
risk but is not an atomic Provider authorization guarantee. It does not list relations merely to inspect the
caller-relative editable display flag: the mutation endpoint performs the source permission check. Relation
deletion lists current relations only to refuse an observed cross-notebook target-ID collision because the Provider
ignores target notebook identity (PVD-020); its mutation likewise performs authorization. Both mutations return
None after acknowledgement. records.relations() returns the relation tuple directly. The endpoint's duplicated
more comments, editable value and target preview/JOIN fields stay wire-only; comments come from
records.comments(). Each returned RecordRelationRef carries only the relation row and declared target IDs.
Deletion accepts the source RecordLocator and one of those observed refs.
Comment upload follows the current Provider's literal one-request file field (PVD-037). Comment mutations return None
after acknowledgement. Edit first verifies that the selected
comment is currently observed and caller-owned because the Provider otherwise reports a false success for a missing or
other-user ID (PVD-004). Delete sends the typed ref directly because the Provider mutation itself enforces record and
caller ownership. Neither operation performs a post-write read. Staged comment attachments have no Contract abort
operation, and binding remains affected by PVD-026. Comment create/update use the same body vocabulary as the
returned RecordComment.body; the SDK does not perform rich-text display normalization, while the Provider's
documented surrounding-whitespace trim still applies when saving.
RecordCommentRef carries its RecordLocator, so update and delete accept the ref rather than an entire comment or a
public owned_by_caller UI flag.
Attachment bytes are streamed and must be consumed or closed explicitly:
from matelab import ByteRange
comments = await client.records.comments(source)
comment = comments[0]
attachment = comment.attachments[0]
async with await client.records.download_comment_attachment(
comment.ref, attachment, byte_range=ByteRange.from_start(0)
) as download:
async for chunk in download:
consume(chunk)
DownloadStream exposes status, content type, length, range, and disposition metadata without buffering the
complete file. Streams are not automatically replayed. ByteRange deliberately rejects bytes=0-0 (PVD-002).
Comment attachment refs contain only file identity. Pass the parent comment ref returned by the same read when
downloading; current Providers do not verify that association (PVD-038).
Cross-domain staging keeps resumable state and completed-file identity separate:
from matelab import StagedFile
pdf_bytes = b"sanitized PDF bytes"
staged = await client.uploads.stage(pdf_bytes, filename="example.pdf")
assert isinstance(staged, StagedFile)
The SDK derives fragment_size and computes the complete SHA-256 for a single bytes fragment. File-like content
must declare its fragment size. For multiple fragments, pass the returned StagedUploadSession into the next call and
supply complete_sha256 on the final call. next_offset is explicitly a caller-side total on an active upload session,
derived from declared fragment sizes; the Provider does not confirm an offset. The SDK verifies the Provider's returned filename and size against the submitted filename and
client-tracked total, but does not repeat those caller-known values on the final result. A final result contains the
Provider hash, temporary row identity and direct binding; it no longer embeds resumable session state. The temporary
Provider URL and upload-session identity needed by internal binding and cleanup remain hidden. Callers do not need to
splice either into content. The result does not claim that a later literature/cloud operation checks the uploader or
consumes the file exactly once. uploads.abort() accepts either an active StagedUploadSession or a completed
StagedFile and exposes the Provider's legacy code-2 cancellation signal as a None-returning compensating cleanup
that is not independently verified (PVD-028).
Staging mutations are never automatically retried.
Literature identities distinguish the personal library from shared libraries:
from matelab import LiteratureMetadataInput
libraries = await client.literature.libraries()
page = await client.literature.list()
detail = await client.literature.read(page.items[0].ref)
candidate = await client.literature.extract_metadata(doi="10.0000/example")
await client.literature.create(
LiteratureMetadataInput(title="Example import", doi="10.0000/example"), staged_pdf=staged
)
literature.libraries() directly returns usable LiteratureLibrarySummary values in Provider-unspecified order.
LiteratureLibraryRef() identifies the synthetic personal library; a positive library_id identifies a real shared
library, and the read-only scope property is derived from that identity. Each LiteratureItemRef carries the library
where it was observed. Raw permission masks are normalized as LiteratureLibraryPermissions. Dangling memberships
without a usable library identity and pending-share projections remain wire-only; the pinned Contract does not provide
the complete accept/reject workflow needed to expose pending shares safely.
The Provider's static literature form definition and extraction deployment flag remain wire-only; Consumers call
extract_metadata() directly and handle an unavailable or failing optional backend as a Provider error. Extraction
returns a PublicationMetadataCandidate: unstored, rich publication evidence rather than metadata already attached to
a literature item. Its structured authors are PublicationAuthorCandidate values. Extraction accepts a DOI, a
completed staged PDF, or both; a supplied DOI takes precedence over the staged PDF. A non-empty DOI must match
^10\.[0-9]+/\S+$; the generated request model rejects other values before sending them to the Provider (PVD-042).
Literature list
results likewise exclude Provider-generated citation HTML and author-highlight fragments; Consumers receive the
underlying citation fields instead of presentation markup. LiteratureDetail.metadata is None when invalid legacy
JSON produces the Provider's empty-array fallback; otherwise it contains decoded stored metadata without arbitrary
extension values.
List and detail results normalize the Provider's zero rating to None. Missing list author, abstract, journal, and PDF
values are likewise None. A LiteraturePdf is exposed only when the Provider supplies the SHA-256 required for
download; its optional filename and size metadata may still be None. The list response's
dtime is not exposed as a publication date because it is a mutable library-entry timestamp with no stable Contract
meaning. LiteratureItemSummary.tags and LiteratureDetail.tags contain the current item's library classification
tags; bibliographic keywords remain under LiteratureDetail.metadata.keywords when stored metadata is available.
LiteratureLibrarySummary.tags and LiteraturePage.tags contain the library-wide tag set used for filtering. Library
names and tag sets belong to library/list results rather than being repeated on an item detail.
StoredLiteratureMetadata.author is the single public author representation: canonical author text takes precedence,
while a legacy author list is joined with semicolons when no canonical value exists. The legacy array remains wire-only.
LiteratureMetadataInput is the complete canonical input for create and replacement. StoredLiteratureMetadata is a
decoded snapshot returned by read() and can contain readable legacy/source fields that the canonical write schema
does not accept. Neither is interchangeable with an extraction candidate.
Create returns None and never guesses the new item from list position because the Provider returns no ID. By default,
canonical update preflights the raw item response and refuses to drop top-level or nested source/hidden fields. Passing
allow_source_metadata_loss=True explicitly skips that preflight and sends the canonical replacement directly. The
default read/check/write protection is best effort rather than atomic because the Provider offers no revision or
compare-and-swap condition; this Provider-specific behavior remains internal to the update operation (PVD-027).
PDF replace/delete are separate acknowledged mutations and are not presented as atomic with metadata.
Permanent personal deletion is named permanently_delete, returns None, and is non-recoverable. Sharing requires
list-observed item summaries, user-search summaries and a
valid caller identity, then returns None because the Provider supplies no per-recipient IDs (PVD-012, PVD-036).
Literature comments use explicit create_comment() and update_comment() intents. Creation first reads the current
detail and rejects when the caller already owns a comment; callers then use that comment's LiteratureCommentRef with
update_comment(). The ref contains only the Provider comment-row identity. Update and delete send that identity
directly because the Provider mutation verifies current ownership and library permission. The creation preflight
maintains the Integration Contract's one-caller-comment invariant for ordinary SDK use, but is not atomic because the
Provider has no matching uniqueness constraint (PVD-035). When sharing with copy_owner_comment=True, multiple
caller-owned comments remain an explicit ambiguity because the Provider would otherwise choose one implicitly. A
staged attachment can replace one matelab-staged-file marker; raw temporary URLs are rejected. These checks prevent
common misuse but do not repair the Provider's cross-user UID lookup (PVD-026). Shared-library reads and writes remain
experimental because the Provider permission JOIN is not scoped to the current user (PVD-011); successful SDK calls
must not be treated as independent authorization proof. Literature PDF downloads reuse DownloadStream and the
stable ByteRange subset.
The personal cloud-drive surface keeps root, folder, final file and temporary staging identities separate:
listing = await client.cloud_drive.list()
folder = await client.cloud_drive.create_folder(name="Example data")
await client.cloud_drive.bind_staged_file(staged, target=folder)
CloudDriveListing contains the selected files, total_count and SDK-derived has_more alongside a complete folder
snapshot and quota usage; pagination applies only to files, while request page arguments are not echoed. Personal-root
metadata and permissions are synthetic/fixed for this Contract line and remain wire-only. File rows omit the constant
caller owner fields and do not invent a folder location that the Provider fails to return for root-wide searches.
Listings likewise omit search/order/direction request echoes. Ordering has no stable ID tie-breaker (PCG-003, PVD-013).
Cloud files expose the nullable stored modification timestamp, not the Provider's duplicate display-formatted date and
time strings.
Folder create returns a CloudFolderRef built from the Provider ID. Other folder and file mutations return None
after acknowledgement and do not automatically list the drive. Staged finalize accepts a completed StagedFile;
the Provider binds by temporary row ID without checking its owner (PVD-031), and finalize atomicity/idempotency remain
absent. Batch move and permanent delete do not claim Provider per-item results or atomicity. Permanent
deletion is named permanently_delete_files and is non-recoverable. Cloud downloads
resolve bytes from the final file identity and reuse DownloadStream, thumbnail/preview choices and the PVD-002-safe
range subset. Cloud mutations are not automatically retried.
Owned, shared, and public catalog notebooks use one NotebookRef, distinguished by its
scope ("owned", "shared", or "public"). For owned/shared refs, notebook_id is the source eln.id;
for public refs it is the distinct eln_public.id catalog identity and must not be treated as a source ELN ID.
Public record entries expose that source identity separately as PublicRecordRef.source_notebook_id.
NotebookRef.title is a non-blank str preserved exactly because private operations use it as a Provider selector.
The pinned owned/shared wire field remains nullable because the database column allows null, but Provider writes
require a non-empty title. A runtime database audit found no null or blank titles and did find legacy private titles
with surrounding whitespace; the SDK therefore treats null/blank as a protocol violation without normalizing the exact
selector. Public record listing uses that non-blank title as an additional Provider selector and accepts only public
scope. Private
operations reject public scope, while owned-only notebook mutations continue to reject shared/public refs. Static
typing exposes exactly those three scope values, so a redundant runtime membership check is not repeated. Scope policy
is checked at each SDK operation boundary; reading title itself does not imply or perform a private-scope check.
RecordRef and RecordVersionRef keep their Provider identifiers distinct; a version ref carries its typed
RecordLocator instead of repeating notebook ID, record database ID and record UID fields. records.read() accepts
either a RecordLocator for current content or a RecordVersionRef for historical content, so callers never repeat
the record identity alongside its version. Historical reads first re-read the authorized current record and confirm
that the requested version is still present in its modify_log; both reads write Provider audit entries.
All three notebook collections contain the same NotebookSummary model. Public catalog entries do not expose owner
names or groups, so those fields are None. Public summary and creation time remain non-null at runtime under the wire
Contract, while the unified model uses the wider owned/shared nullable types.
Provider types, display, and requests fields configure its notebook-list UI rather than notebook domain data.
types is a list of user-defined classification tags used by the Provider frontend for client-side filtering. These
fields are therefore absent from NotebookSummary and from create/update inputs; the SDK handles their update-time
preservation only as an internal Provider compatibility detail.
Template render metadata is also excluded from template summaries and record reads. A Record has one
source: RecordLocator; it does not duplicate the notebook and record refs, caller-relative owner/editable UI state,
notebook title/keywords, Provider data-server routing, or the frontend PDF-capability flag. Record.version identifies
historical content and is None for the current content. Record.versions contains refs usable for historical reads;
Provider lock and signature projections remain wire-only. Relation creation still checks returned data servers
internally to mitigate PVD-019.
Provider record-finalization and additional-signature mutations are intentionally outside the pinned Contract and SDK interface. They require the account password to unlock a private key, combine version creation with signing, provide no reliable per-record result, and lack a compare-and-swap guard between hashing and snapshot creation. The SDK therefore does not expose a signing operation, signature projection or credentials for these operations; notebook permission results only report whether Provider policy allows signing.
Errors are separated into semantic Provider errors, authentication errors, HTTP/transport errors, Integration Contract response errors, and client-side usage errors. Semantic Provider errors retain only the Contract-defined top-level code plus an SDK-owned safe summary; Provider-authored messages, response bodies, legacy aliases, and unrelated fields do not enter exceptions.
Development
uv sync
uv run python scripts/generate_models.py
uv run python scripts/generate_models.py --check
uv run ruff check .
uv run ruff format --check .
uv run basedpyright
uv run pytest
uv build
The generator first verifies the contract lock, OpenAPI release metadata, and snapshot digest. It then
creates a temporary OpenAPI 3.1 generation projection, resolves references without network access, and
generates private component, operation-response, and parameter models. Generator emission uses its built-in formatter;
the checked-in output is then normalized using the repository pyproject.toml as the sole Ruff configuration. The
projection flattens pure
object inheritance and preserves constraints the model generator cannot express as self-contained JSON Schema
2020-12 metadata; the checked-in release snapshot remains unchanged. --check performs the same validation and
deterministic generation without writing the checked-in models. WireModel applies that metadata with the standard
jsonschema Draft 2020-12 validator; the SDK does not maintain a second hand-written schema interpreter. The current
lock resolves
datamodel-code-generator 0.71.0 and
hatchling 1.31.0. Published metadata requires httpx2>=2.9.1,<3, jsonschema>=4.26,<5,
pydantic>=2.13.4,<3, and typing-extensions>=4.14.1,<5; the build backend requires
hatchling>=1.27,<2. These lower bounds are verified
against the complete test suite on the supported Python boundary versions rather than inferred from
uv.lock. The exact toolchain remains locked for development and release builds. Basedpyright and its
Node wheel retain the compatible exact pair basedpyright==1.39.9 and
nodejs-wheel-binaries==22.20.0.
Opt-in Provider consumer smoke
tests/provider/test_provider_smoke.py exercises the consumer flow through only the public SDK interface. Its base
scenario covers credential authentication, persistence of the complete returned Session, restoration through a new
AsyncMatelab instance, refresh with identity preservation, and notebook discovery. It is not Provider Verification
and is skipped by default.
Raw Provider conformance remains the responsibility of matelab-spec, which sends direct HTTP requests and validates
the unmodified responses. The SDK does not repeat its route-by-route, cross-account, sharing, or attachment-isolation
verification. Representative SDK adapter tests instead feed the pinned OpenAPI's sanitized response examples through
MockTransport and assert the resulting public values; synthetic fixtures remain where SDK-specific encoding,
error, retry, and compatibility boundaries require evidence beyond those examples.
Provider smoke is restricted to the confirmed isolated test service. Authentication and refresh persist Provider
token state. This side effect is inherent to the tested Provider operations; it cannot be disabled by a test setting.
Explicitly loading .env.test and selecting the provider marker is the opt-in for this flow.
Copy .env.example to the git-ignored local .env.test, then fill in the shared Provider connection settings:
MATELAB_PROVIDER_BASE_URLMATELAB_PROVIDER_USERNAMEMATELAB_PROVIDER_PASSWORD
These names intentionally match matelab-spec Provider Verification. The isolated target may copy them from the spec
.env into this repository's .env.test. Refreshing the restored Session must preserve its authenticated identity.
With the environment prepared:
uv run --env-file .env.test pytest -m provider tests/provider/test_provider_smoke.py
This command runs only the SDK public-interface smoke; it is not the 71-operation Provider Verification. To reuse the
same .env.test for the complete Contract suite, also populate the optional share user, secondary account, record
staging opt-in, and Chat SSO settings documented in .env.example, then run from sibling checkouts:
cd ../matelab-spec
uv run --env-file ../matelab-python-sdk/.env.test pytest
The files are never loaded implicitly, so normal test runs remain safely skipped. Do not use either flow against production, and never commit Provider credentials.
Reproducible release build
Build from a clean release commit (or its tag) and set the archive timestamp to that commit's
committer timestamp. pyproject.toml declares the supported Hatchling range, while uv.lock supplies the
exact version used by the frozen, no-build-isolation release environment:
export SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)"
uv sync --frozen
uv run python scripts/generate_models.py --check
uv build --no-build-isolation --out-dir dist/release
uv run python scripts/check_release.py dist/release/*.whl dist/release/*.tar.gz
(cd dist/release && sha256sum *.whl *.tar.gz > SHA256SUMS)
Rebuilding the same commit with the same locked environment and SOURCE_DATE_EPOCH must produce
byte-identical wheel and source distribution hashes. The release is bound to the rewritten matelab-spec v0.4.2,
commit 1a4b77721d782fccd2b1f26bcc16a7d876347686, and OpenAPI SHA-256
1e737421aac636356487122ea89e20d54de07c8b9c0651cf6970a7123fe3b72b.
Release files for matelab-python-sdk 0.1.0a19
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| matelab_python_sdk-0.1.0a19.tar.gz | 273.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| matelab_python_sdk-0.1.0a19-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 364.9 kB
Release files / matelab_python_sdk-0.1.0a19.tar.gz
| Download URL | matelab_python_sdk-0.1.0a19.tar.gz |
|---|---|
| Size | 273.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
58eaa2cb541c8e2be4392ca2802833f571cca1983217dda9798e93e728afc632
|
|
BLAKE2b-256 checksum How to use checksums |
bde2c30c542532517abffe7ca405c60bea35a4fc2f9392b15bb274626025bb24
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 23, 2026.
Transparency logRelease files / matelab_python_sdk-0.1.0a19-py3-none-any.whl
| Download URL | matelab_python_sdk-0.1.0a19-py3-none-any.whl |
|---|---|
| Size | 91.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
acd3502cc6073bda7deebf9c78c829a087824429bea457f56c390cdf1191d207
|
|
BLAKE2b-256 checksum How to use checksums |
4d69429168d7ba38eb60f87840d5c1094f7ebdaf9e73988441036e439cdb7f41
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 23, 2026.
Transparency log