Metadata-Version: 2.4
Name: matelab-python-sdk
Version: 0.1.0a14
Summary: Reusable async Python client for the Matelab Integration Contract
Author-email: 朱天念 <zhutiannian@gmail.com>
License-Expression: Apache-2.0
License-File: LICENSE
License-File: NOTICE
Keywords: asyncio,electronic-lab-notebook,eln,matelab,sdk
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: httpx2<3,>=2.9.1
Requires-Dist: jsonschema<5,>=4.26
Requires-Dist: pydantic<3,>=2.13.4
Requires-Dist: typing-extensions<5,>=4.14.1
Description-Content-Type: text/markdown

# matelab-python-sdk

Reusable async Python client for the Matelab Integration Contract.

The current alpha is `0.1.0a14`. `[project].version` in `pyproject.toml` is the sole SDK version source;
`uv.lock` only mirrors that source.

The SDK is pinned to the immutable `matelab-spec v0.4.0` 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:

```bash
uv add matelab-python-sdk
```

```bash
python -m pip install matelab-python-sdk
```

Development installs use the locked checkout:

```bash
uv sync --frozen
```

To test the same artifact a downstream Consumer will install, build and install the wheel:

```bash
uv build --no-build-isolation --out-dir dist/release
python -m pip install dist/release/matelab_python_sdk-0.1.0a14-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.

## Design

The public module is intentionally small:

```python
from matelab import AsyncMatelab

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)
    record = await client.records.read(notebook=notebook, record=records.records[0].ref)
```

`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`, so an integration can copy the message, category, and retry flag without inspecting subclasses or
Provider details:

| 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; route directly by `category`. |
| `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`. Authentication codes are hidden behind `MatelabAuthenticationError`; raw Provider codes
are not part of the public error interface. 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 and caller inputs are
never attached to exceptions. `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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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.

### Staging a record attachment before record creation

`records.stage_attachment` supports the Contract's pre-upload workflow without inventing a target record UID.
The returned `StagedNotebookAttachment` is scoped by the SDK to the resolved authenticated user and the exact
notebook selector used for upload:

```python
import hashlib

from matelab import RecordImportItem

content = b"measurement data"
staged = await client.records.stage_attachment(
    notebook=notebook,
    filename="measurement.csv",
    content=content,
    size=len(content),
    sha256=hashlib.sha256(content).hexdigest(),
)
result = 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]}}
        ),
    ),
)
```

`content` accepts `bytes`, a synchronous `IO[bytes]`, or an `AsyncIterable[bytes]`. `size` is always required and must
be exact. `sha256` is optional; when omitted, the SDK computes it while consuming the content. Async content is fully
consumed into a `SpooledTemporaryFile`, size-checked, checksum-checked when a checksum was supplied, rewound, and only
then sent to the Provider. An over-size stream stops at the first chunk that exceeds the declared size. Temporary-file
write, seek, read, and close operations run outside the event-loop thread, including after the spool rolls to disk.

Local size or checksum mismatches raise `MatelabUsageError` before any Provider request. An exception raised by the
async source is propagated unchanged after the SDK closes its temporary file. The SDK does not impose an upload-size
policy; callers remain responsible for limits such as an HTTP endpoint's maximum accepted body size.

The same content interface is available on `records.upload_attachment()` and
`records.upload_comment_attachment()`. `uploads.stage()` remains a separate resumable-fragment interface and does not
accept async content because its per-fragment offset and completion semantics are different.

The same staged handle may instead be consumed by one safe update finalizer. For example, add a new file field to an
existing form module:

```python
from matelab import RecordFormAttachmentFieldAddition

# Alternative to the import above; do not run both with the same staged handle.
result = await client.records.update(
    source,
    attachment_changes=(
        RecordFormAttachmentFieldAddition(module="Attachments", name="Measurement", attachment=staged),
    ),
)
```

Choose exactly one finalizer. A staged name may occur once in either a single-record import or one update operation;
do not reuse it after transport starts, even when the Provider outcome is unknown. A locally rejected target or
argument does not spend the handle. The SDK rejects raw Provider attachment references, cross-user or cross-notebook
handles, unsafe combinations, duplicate use in one request, and a second finalization attempt through the same
client.

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

The v0.1.2 Contract adds dedicated, verified notebook-staged update shapes. They remain public intent objects; callers
never construct Provider paths or attachment strings:

```python
from matelab import RecordFilesAttachmentRootAppend, RecordTableFileCellSet, RecordTableRowAttachmentAppend

# Each example is a separate finalizer; never run several with the same handle.
await client.records.update(
    source,
    attachment_changes=(RecordTableFileCellSet(table="Measurements", column="Evidence", row=0, attachment=staged),),
)

await client.records.update(
    source,
    attachment_changes=(
        RecordTableRowAttachmentAppend(
            table="Measurements", file_column="Evidence", values={"Label": "Sample C"}, attachment=another_staged
        ),
    ),
)

await client.records.update(
    source,
    attachment_changes=(RecordFilesAttachmentRootAppend(module="Files", caption="Evidence", attachment=third_staged),),
)
```

`RecordTableFileCellSet` requires an existing file column and a cell whose immediate canonical value is exactly
`null`. The row-append intent addresses the new row by the row count from the SDK's immediate read and supports one
staged file column. Files append is root-only and requires a string caption.

Replacement starts with an occurrence returned by `records.read()`; applications must not fabricate a
`RecordAttachmentRef`:

```python
from matelab import RecordFilesAttachmentReplacement

record = await client.records.read(notebook=source.notebook, record=source.record)
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, attachment_changes=(RecordFilesAttachmentReplacement(existing=existing, replacement=staged, caption=None),)
)
```

For a files occurrence, `caption=None` preserves the observed string caption (an observed null caption normalizes to
the required empty string). The update preserves the Provider `uid` and folder in the submitted content. Table
replacement requires exactly one current attachment in the selected cell and uses
`RecordTableFileAttachmentReplacement`. Both replacement forms require the new hash to differ.

Row/index-based finalizers 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. `expected_content_sha256` is only a client-side prewrite
check, not Provider compare-and-swap.

File-bearing structure changes do not consume staging:

```python
from matelab import RecordFormFileFieldDeletion, RecordTableFileColumnAddition, RecordTableFileColumnDeletion

await client.records.update(
    source,
    file_structure_changes=(
        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:

```python
result = await client.records.update(
    source, module_deletions=("Raw files",), expected_content_sha256=record.content_sha256
)
```

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_browser_save"`, not persisted.

Notebook-staged rich-text binding remains unsupported. Provider Verification shows that `#file{name}` is stored as
plain text and that a hash-based URI can fall back to an existing quote, so neither is a one-shot staged finalizer.
Existing `RecordRichTextUpdate` remains legal only with record-scoped `StagedRecordAttachment`. Attachment-bearing
multi-record import is also forbidden; callers must split it into single-record finalizers.

## Operation coverage

The SDK tracks all 71 Contract operations and exposes 70 through public domain interfaces; one identity-bootstrap
operation is 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 | 13 | 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** | **70** | **0** | One operation is intentionally unexposed |

### Stability and known capability limits

Coverage currently contains 14 `stable`, 56 `experimental`, and one `not_applicable` operation. The stable operation
IDs are `loginTokenSet`, `refreshTokenSet`, `exchangeChatSsoCode`,
`shareMultipleTemplatesWithUsers`, `removeTemplateFromGroup`, `deleteNotebookShare`, `listNotebooks`,
`listNotebookRecords`, `exportRecords`, `deleteRecordsByUid`, `copyRecord`, `readRecord`,
`deletePersonalLiteratureItem`, and `readLiteratureCreateTemplate`.

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. 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:

```python
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:

```python
groups = await client.groups.list()
targets = await client.users.search("Example Researcher", global_scope=False)
```

Ordering remains Provider-unspecified and is documented rather than repeated as a constant result field. Group
members belong only to `groups.members_for`, not to every returned group. These two discovery interfaces are experimental because the
Provider returns members for an unstable first group and user search is unpaged, unordered, and not field-minimized
(PVD-006, PVD-029, PCG-011).

Notebook create/update and direct sharing use the Provider acknowledgement without an automatic follow-up read:

```python
await client.notebooks.create(title="Example Notebook")
shares = await client.notebooks.shares(notebook)
await client.notebooks.share(notebook, [target.ref])
await client.notebooks.update_share(shares.shares[0].ref, write=True, create=True)
```

Create, update, share, permission update, and unshare return `None` because their Provider responses contain no new
resource representation. Call `list()` or `shares()` explicitly when the application needs current state. A stored
share mask of zero still has effective read access (PVD-010), and share-list order remains unspecified.

Template discovery keeps a template database identity separate from direct-share, market-acquisition, and group
relation identities:

```python
templates = await client.templates.list()
market = await client.templates.search_market("calibration", page=1, page_size=20)
modules = await client.templates.read(templates.owned[0].ref)
```

Semantic template documents hide canonical `uid`, `width`, `data`, `rows`, `folders`, and option encoding:

```python
from matelab import (
    TemplateDocument,
    TemplateFieldAdd,
    TemplateFormField,
    TemplateFormModule,
    TemplatePatch,
    TemplateRichTextModule,
)

document = TemplateDocument.create(
    (
        TemplateFormModule(
            name="Basic",
            fields=(TemplateFormField(name="Temperature", field_type="number", required=True, unit="K"),),
        ),
        TemplateRichTextModule(name="Notes", content="<p>Instructions</p>"),
    )
)
template = await client.templates.create(title="Measurement")
await client.templates.save_document(template, document)

observed = await client.templates.read_document(template)
await client.templates.patch_content(
    template,
    TemplatePatch(
        changes=(
            TemplateFieldAdd(
                module="Basic",
                field=TemplateFormField(name="Operator", field_type="text", required=True),
            ),
        )
    ),
    expected_fingerprint=observed.fingerprint,
)
updated = await client.templates.read_document(template)
```

`TemplateDocument` is immutable at its interface, preserves unknown canonical properties, keeps existing UIDs stable,
and converts losslessly back to `TemplateModule`. Patches are ordered name-based intents and reject missing, duplicate,
or incompatible targets before saving. The fingerprint preflight detects a stale prior read, but is client-side only:
the current Provider has no atomic template patch or CAS (PCG-007). `patch_content()` returns `None`; explicitly
re-read before using a new fingerprint because the mutation response contains no saved representation.

The market result reports the Provider `total_count`, the requested and effective page sizes, and a `has_more`
value derived from the total; it does not claim a stable order or continuation token. Canonical
modules are mapped to public `TemplateModule` values and retain additive module attributes. 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).

Template writes remain separate operations: metadata, canonical modules, and usage HTML are not presented as one
transaction. Metadata create/update returns the `TemplateRef` built from the Provider template ID. Other mutations
return `None` because the Provider supplies no new identity or resource representation; callers can explicitly list
or read when they need current state.
Direct-share, market-acquisition, and group relation refs remain distinct. Marketplace revision and uploader-binding
limitations are documented operation semantics rather than constant fields on every result (PVD-021, PVD-026).
`UploadBindingRef.new()` creates the fresh hidden correlation value required by intro attachment binding.

Extended record reads stay behind the same `records` interface:

```python
from matelab import RecordLocator

exported = await client.records.export([RecordLocator(notebook=notebook, record=record)])
matches = await client.records.search(notebooks=[notebook], extractions={"notes": ("Notes",)})
page = await client.records.page(notebook)
deleted = await client.records.recycle_bin(notebook)
relations = await client.records.relations(notebook=notebook, record=record)
```

`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 from the Provider's complete matching ID list. Public catalog records and
deleted records use identities distinct from active `RecordRef`. Relation targets separately expose declared and
resolved notebook IDs because the Provider may return dangling or incomplete identities. Search and relation order
remain unspecified, and no continuation token is invented.

Record creation keeps blank creation and structured import as separate capabilities:

```python
from matelab import RecordImportItem

await client.records.create_blank(notebook=notebook, title="Blank Record", record_uid="caller-generated-uid")
imported = await client.records.import_dataset(
    notebook=notebook,
    template_title="Example Template",
    items=[RecordImportItem(record_uid="import-uid", title="Imported", data={"Notes": "value"})],
)
```

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). Delete means moving records into the recycle bin, not permanent
deletion. Delete and restore return `None`; record mutations are not automatically retried.

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). `Record.content_sha256` can be supplied as a client-side precondition, documented as advisory
read-before-write rather than Provider CAS. `records.update()` returns the acknowledgement classification
`"provider_reported_persisted"`, `"pending_browser_save"`, or `"provider_acknowledged_unclassified"`; it does not
issue a post-write read. Database, active-browser, and unclassified acknowledgements remain distinct, and mutation
retries stay disabled.

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. Relation deletion refuses an observed cross-notebook
target-ID collision because the Provider ignores target notebook identity (PVD-020). Both mutations return `None`
after acknowledgement.

Comment upload follows the Provider's literal one-request `upload` field, not the incompatible Front fragment
protocol (PVD-037). Comment mutations return `None` after acknowledgement. Edit and delete first verify that the
selected comment is currently observed and caller-owned, but do not perform a post-write read (PVD-004). Staged
comment attachments have no Contract abort operation, and binding remains affected by PVD-026.

Attachment bytes are streamed and must be consumed or closed explicitly:

```python
from matelab import ByteRange

comments = await client.records.comments(notebook=notebook, record=record)
attachment = comments[0].attachments[0]
async with await client.records.download_comment_attachment(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 preserve the notebook/record/comment context where they were observed, but they are not
Provider authorization credentials: current Providers do not verify that association (PVD-038).

Cross-domain staging keeps resumable state and completed-file identity separate:

```python
from matelab import StagedFile

pdf_bytes = b"sanitized PDF bytes"
staged = await client.uploads.stage(
    pdf_bytes,
    filename="example.pdf",
    fragment_size=len(pdf_bytes),
)
assert isinstance(staged, StagedFile)
```

The SDK computes the complete SHA-256 for a single `bytes` fragment. For multiple fragments, pass
`StagedFileFragment.session` into the next call and supply `complete_sha256` on the final call. `next_offset` is
explicitly a caller-side total derived from declared fragment sizes; the Provider does not confirm an offset. A final
result contains the Provider hash, size, temporary row identity and fresh hidden binding value, but does not claim that a
later literature/cloud operation checks the uploader or consumes the file exactly once. `uploads.abort` 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, shared libraries and pending incoming copies:

```python
from matelab import LiteratureMetadata

libraries = await client.literature.libraries()
page = await client.literature.list(libraries.personal.ref)
detail = await client.literature.read(page.items[0].ref)
schema = await client.literature.creation_schema()

if schema.metadata_extraction_available:
    candidates = await client.literature.extract_metadata(doi="10.0000/example")

await client.literature.create(LiteratureMetadata(title="Example import", doi="10.0000/example"), staged_pdf=staged)
```

Create returns `None` and never guesses the new item from list position because the Provider returns no ID. Canonical update reads the
item first and refuses to drop source/hidden fields unless `allow_source_metadata_loss=True` is explicit (PVD-027).
PDF replace/delete are separate acknowledged mutations and are not presented as atomic with metadata (PCG-010).
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 one public save intent: detail is read first, an existing caller-owned comment is edited, and
otherwise a comment is created. Multiple caller-owned comments are rejected as ambiguous (PVD-035). A staged
attachment can replace one `matelab-staged-file` marker; raw temporary URLs are rejected. These checks contain 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:

```python
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 a typed file page, complete folder snapshot, quota usage and personal-root permissions rather
than flattening them into one ambiguous collection. Folder browse results retain their location; filename searches
are explicitly root-wide and return `location=None` because the Provider omits each match's folder ID. Ordering has
no stable ID tie-breaker (PCG-003, PVD-013).

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 (PCG-012). 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 `NotebookRef`, public `PublicNotebookRef`, `RecordRef`, and `RecordVersionRef` keep
Provider identifiers distinct. 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.

Errors are separated into semantic Provider errors, authentication errors, HTTP/transport errors,
Integration Contract response errors, and client-side usage errors. Provider response bodies and caller inputs do not
enter exceptions.

## Development

```bash
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. 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_URL`
- `MATELAB_PROVIDER_USERNAME`
- `MATELAB_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:

```bash
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:

```bash
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:

```bash
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 `matelab-spec v0.4.0`,
commit `7993a7bccc213f626b9cc8124423e2b2c9e48dd7`, and OpenAPI SHA-256
`e725c9649700d5b4d1c7d75fb9422d64b04a68e0308ddec29ba274d774104c8e`.
