matelab-python-sdk 0.2.0a1__py3-none-any.whl → 0.2.0a3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  #
3
3
  # @generated by datamodel-codegen:
4
4
  # filename: openapi.yaml
5
- # version: 0.71.0
5
+ # version: 0.83.0
6
6
 
7
7
  from enum import Enum, StrEnum
8
8
  from typing import Annotated, Any, Literal
matelab/_transport.py CHANGED
@@ -80,7 +80,7 @@ def _provider_error(code: int) -> MatelabAuthenticationError | MatelabProviderEr
80
80
 
81
81
 
82
82
  def _header(response: Response, name: str) -> str:
83
- return cast(str, response.headers.get(name, ""))
83
+ return response.headers.get(name, "")
84
84
 
85
85
 
86
86
  def _parse_content_length(response: Response) -> int | None:
@@ -293,7 +293,8 @@ class SessionTransport:
293
293
  raise MatelabTransportError("Could not exchange data with the Matelab Provider.") from None
294
294
 
295
295
  if response.status_code not in {200, 206}:
296
- await response.aclose()
296
+ with suppress(RequestError):
297
+ await response.aclose()
297
298
  raise MatelabTransportError(
298
299
  f"Matelab returned HTTP {response.status_code}.", status_code=response.status_code
299
300
  )
@@ -321,7 +322,8 @@ class SessionTransport:
321
322
  continue
322
323
  raise _provider_error(code)
323
324
  if operation.media_types is not None and media_type not in operation.media_types:
324
- await response.aclose()
325
+ with suppress(RequestError):
326
+ await response.aclose()
325
327
  raise MatelabProtocolError("Matelab returned an unexpected download media type.")
326
328
 
327
329
  content_length = _parse_content_length(response)
@@ -331,6 +333,7 @@ class SessionTransport:
331
333
  or not _CONTENT_RANGE_PATTERN.fullmatch(_header(response, "Content-Range"))
332
334
  or not _header(response, "Content-Disposition")
333
335
  ):
334
- await response.aclose()
336
+ with suppress(RequestError):
337
+ await response.aclose()
335
338
  raise MatelabProtocolError("Matelab returned invalid partial-download metadata.")
336
339
  return DownloadStream(response, content_length=content_length)
@@ -976,10 +976,9 @@ def _encode_record_rich_text(update: RecordRichTextUpdate) -> str:
976
976
  markers = set(_ATTACHMENT_MARKER_PATTERN.findall(update.html))
977
977
  if markers != set(update.attachments):
978
978
  raise MatelabUsageError("Rich-text attachment names must exactly match matelab-attachment:<name> markers.")
979
- encoded = update.html
980
- for name, attachment in update.attachments.items():
981
- encoded = encoded.replace(f"matelab-attachment:{name}", _encode_record_attachment_reference(attachment))
982
- return encoded
979
+ return _ATTACHMENT_MARKER_PATTERN.sub(
980
+ lambda match: _encode_record_attachment_reference(update.attachments[match.group(1)]), update.html
981
+ )
983
982
 
984
983
 
985
984
  def _validate_upload_filename(filename: str) -> None:
@@ -1001,15 +1000,15 @@ def _bind_comment_attachments(
1001
1000
 
1002
1001
  first = next(iter(selected_attachments.values()))
1003
1002
  binding_uid = first._binding_uid # pyright: ignore[reportPrivateUsage]
1004
- for name, attachment in selected_attachments.items():
1003
+ for attachment in selected_attachments.values():
1005
1004
  attachment_binding_uid = attachment._binding_uid # pyright: ignore[reportPrivateUsage]
1006
1005
  if attachment.source != source or attachment_binding_uid != binding_uid:
1007
1006
  raise MatelabUsageError("Comment attachments must belong to the same record and upload batch.")
1008
- body = body.replace(
1009
- f"matelab-attachment:{name}",
1010
- attachment._temporary_url, # pyright: ignore[reportPrivateUsage]
1011
- )
1012
- return body, binding_uid
1007
+ bound_body = _ATTACHMENT_MARKER_PATTERN.sub(
1008
+ lambda match: selected_attachments[match.group(1)]._temporary_url, # pyright: ignore[reportPrivateUsage]
1009
+ body,
1010
+ )
1011
+ return bound_body, binding_uid
1013
1012
 
1014
1013
 
1015
1014
  def _notebook_selector(notebook: NotebookRef) -> str | dict[str, JsonValue]:
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.5
2
+ Name: matelab-python-sdk
3
+ Version: 0.2.0a3
4
+ Summary: Reusable async Python client for the Matelab Integration Contract
5
+ Author: CMPDC, Institute of Physics, Chinese Academy of Sciences
6
+ License-Expression: Apache-2.0
7
+ License-File: LICENSE
8
+ License-File: NOTICE
9
+ Keywords: asyncio,electronic-lab-notebook,eln,matelab,sdk
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Scientific/Engineering
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: httpx2<3,>=2.13.1
24
+ Requires-Dist: jsonschema<5,>=4.26.0
25
+ Requires-Dist: pydantic<3,>=2.13.5
26
+ Requires-Dist: typing-extensions<5,>=4.16.0
@@ -1,6 +1,6 @@
1
1
  matelab/__init__.py,sha256=C3CXRwVFMfaeyb55jl-MsNJtyUURKfuvVNlb_0mLxwM,5055
2
2
  matelab/_hashes.py,sha256=ImrLHx9wvcU6R-xGOrUCVDN_30sYA2UMn-NLwFrmFtU,163
3
- matelab/_transport.py,sha256=FdxpvEu9Fa4PPwBJ0LixlhUPR9NLCcdhuzRpE2PkGD4,14727
3
+ matelab/_transport.py,sha256=hdDxNsVnEYoZvhtkD4hQLzsVhlOTKsU4wvCdOpHVDuI,14863
4
4
  matelab/_wire_validation.py,sha256=Z_PpxwLS0S18kCC8ah6CmxUEjzvQ-iP8_XBOoQ46Wt4,4752
5
5
  matelab/client.py,sha256=SXlrTX8ekf7ympTxCXlrt4tE3UHqM5adDj4EFUXGwWg,5068
6
6
  matelab/errors.py,sha256=CJjZH12nca_-41msyLYMrIDpIaQO4CDgz2LaQRVBzpQ,1630
@@ -8,19 +8,19 @@ matelab/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
8
8
  matelab/session.py,sha256=D8i9uyAt32-bADotS-Lrb7RpmWRNYNIkIpZUijTYvy4,899
9
9
  matelab/streaming.py,sha256=36epH2josWk87Ors_JY0YsjNmtiq3NZa2iB9djLmlnY,3792
10
10
  matelab/_generated/__init__.py,sha256=jmGbXLLe3JorsGU7OUdF8iKGewFMRqkCzgyFAw7C15w,79
11
- matelab/_generated/models.py,sha256=-73A-w2DdvQY300uzkIGWS2WYX7uzSQDguTA55t6Dg0,168733
11
+ matelab/_generated/models.py,sha256=TzVbDt0NRx8R9i6GvRe5PVZpRKBgVDg5miKl6oNWKxc,168733
12
12
  matelab/domains/__init__.py,sha256=C34IWsGoCAwP7r6uYKJ79DJkToZyrZ4LWA87ufc1i0Q,65
13
13
  matelab/domains/_attachment_content.py,sha256=z26hwFpdgAk2x1w8EGe00XBLtJ3QFDX8BPS35Zc5SoE,1461
14
14
  matelab/domains/cloud_drive.py,sha256=EoqAYWvLVUfVOsic3g-TxsQEtAI3wTp5h0_vB79KpwY,10406
15
15
  matelab/domains/groups.py,sha256=H8-Da2WTm2Ui4m3Nr1bhMNMn7stbzbRa3EmpkTRmkBU,1300
16
16
  matelab/domains/literature.py,sha256=1KZPakc20ExorVV4gsBlcQJ9CspZY3KkdF77Z0WO5rk,27485
17
17
  matelab/domains/notebooks.py,sha256=_SvffaViECSEOkN5iwXtSv6El3fK2OYaR4RJvUCXfGk,13477
18
- matelab/domains/records.py,sha256=_LRmiQhYDys_QOFnvW60WszS4CBy_sD4U3_rjU8DBfY,92355
18
+ matelab/domains/records.py,sha256=n-YfsBbGZOjj1j2keqjHyckHAJ6ZLfYB9J1bto__q9g,92322
19
19
  matelab/domains/templates.py,sha256=AsnayIc72J9pPTgcFpo8IfaM2a2C-acL6k8sdRH3rt8,17386
20
20
  matelab/domains/uploads.py,sha256=J9YveZIHq03YQuWwRZwayP1-tz7cE0F17RIgWByLcvc,7368
21
21
  matelab/domains/users.py,sha256=ZhlLzC_AadwYqNqxQrqkUBgIUqL7MJ6FgMyxwq-az8s,1445
22
- matelab_python_sdk-0.2.0a1.dist-info/METADATA,sha256=a1CgAGTSrMMgM4WdjFISYU1YcUs12ZNdelz5b_AReYQ,63475
23
- matelab_python_sdk-0.2.0a1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
24
- matelab_python_sdk-0.2.0a1.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
25
- matelab_python_sdk-0.2.0a1.dist-info/licenses/NOTICE,sha256=Hc4scCJTof5VL3JCPI2aaewnnzCejIHUGuBVYBf-klc,98
26
- matelab_python_sdk-0.2.0a1.dist-info/RECORD,,
22
+ matelab_python_sdk-0.2.0a3.dist-info/METADATA,sha256=wdiO0RLndPd6fjQHtHo7WfW63JYLQYtey1BiVvZ4yeo,1086
23
+ matelab_python_sdk-0.2.0a3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
24
+ matelab_python_sdk-0.2.0a3.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
25
+ matelab_python_sdk-0.2.0a3.dist-info/licenses/NOTICE,sha256=Hc4scCJTof5VL3JCPI2aaewnnzCejIHUGuBVYBf-klc,98
26
+ matelab_python_sdk-0.2.0a3.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.31.0
2
+ Generator: hatchling 1.32.4
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,993 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: matelab-python-sdk
3
- Version: 0.2.0a1
4
- Summary: Reusable async Python client for the Matelab Integration Contract
5
- Author-email: 朱天念 <zhutiannian@gmail.com>
6
- License-Expression: Apache-2.0
7
- License-File: LICENSE
8
- License-File: NOTICE
9
- Keywords: asyncio,electronic-lab-notebook,eln,matelab,sdk
10
- Classifier: Development Status :: 3 - Alpha
11
- Classifier: Framework :: AsyncIO
12
- Classifier: Operating System :: OS Independent
13
- Classifier: Programming Language :: Python :: 3
14
- Classifier: Programming Language :: Python :: 3 :: Only
15
- Classifier: Programming Language :: Python :: 3.11
16
- Classifier: Programming Language :: Python :: 3.12
17
- Classifier: Programming Language :: Python :: 3.13
18
- Classifier: Programming Language :: Python :: 3.14
19
- Classifier: Topic :: Scientific/Engineering
20
- Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
- Classifier: Typing :: Typed
22
- Requires-Python: >=3.11
23
- Requires-Dist: httpx2<3,>=2.9.1
24
- Requires-Dist: jsonschema<5,>=4.26
25
- Requires-Dist: pydantic<3,>=2.13.4
26
- Requires-Dist: typing-extensions<5,>=4.14.1
27
- Description-Content-Type: text/markdown
28
-
29
- # matelab-python-sdk
30
-
31
- Reusable async Python client for the Matelab Integration Contract.
32
-
33
- This README describes the current SDK interface, integration semantics, examples, and runnable workflows. See
34
- [CONTEXT.md](CONTEXT.md) for domain terminology, [CHANGELOG.md](CHANGELOG.md) for version deltas,
35
- [AGENTS.md](AGENTS.md) for durable maintenance rules, and `docs/operation-coverage.yaml` for the machine-readable
36
- operation inventory.
37
-
38
- The current alpha is `0.2.0a1`. `[project].version` in `pyproject.toml` is the sole SDK version source;
39
- `uv.lock` only mirrors that source.
40
-
41
- The SDK is pinned to the immutable `matelab-spec v2026.10.1` Contract Release. The sole pin,
42
- `contracts/matelab-integration-v1.lock.json`, records peeled commit `9941e2d623a1c2633adb459b823d78d4e62d3802`,
43
- source `openapi/matelab-integration-v1.openapi.yaml`, the local snapshot path, and SHA-256
44
- `a07997128c1df17a03724c5af8e11b45a34166f9d6e15558e66e0a301f2e0813`. The annotated tag object is
45
- `ae8098e1b5178b69706bddd8b70c1b4cfe3e09ba`.
46
-
47
- ## Installation
48
-
49
- Python 3.11 or newer is required. Install the alpha from a package index with either:
50
-
51
- ```bash
52
- uv add matelab-python-sdk
53
- ```
54
-
55
- ```bash
56
- python -m pip install matelab-python-sdk
57
- ```
58
-
59
- Development installs use the locked checkout:
60
-
61
- ```bash
62
- uv sync --frozen
63
- ```
64
-
65
- To test the same artifact a downstream Consumer will install, build and install the wheel:
66
-
67
- ```bash
68
- uv build --no-build-isolation --out-dir dist/release
69
- python -m pip install dist/release/matelab_python_sdk-0.2.0a1-py3-none-any.whl
70
- ```
71
-
72
- Do not infer Provider compatibility from the SDK version alone. A release is also bound to the Contract
73
- tag, commit, and checksum recorded below.
74
-
75
- The Contract uses Provider source evidence from `3f39d47b7ad43bed618b029a7d9cc51fab852eb6`; v2026.10.1 validation ran
76
- against an isolated test instance and does not establish production behavior. The corresponding Front Consumer evidence
77
- is `0c985f5b6da8e8ef4627ca7065cea85e408ae981`; it informs cross-Consumer capability review but does not define the SDK
78
- interface. Provider limitations that remain relevant to integrations are documented alongside the affected interfaces
79
- below.
80
-
81
- Contract releases now use calendar versions to identify Provider-specific snapshots. This snapshot covers the
82
- Workerman runtime with the `localhost` data-server branch for ELN records and attachments; remote data-server
83
- forwarding is outside its verified callable scope (PVD-049).
84
-
85
- ## Versioning
86
-
87
- SDK versions express compatibility of the public Python interface and behavior. Python prerelease spelling follows
88
- PEP 440: `0.2.0a1` is the first alpha candidate for `0.2.0`. During `0.x` development, incompatible changes advance
89
- the minor version. From `1.0.0`, compatible fixes advance patch, compatible additions advance minor, and incompatible
90
- changes advance major. Successive candidates for the same target version use `aN`, `bN`, or `rcN`.
91
-
92
- Contract calendar versions identify Provider wire snapshots independently. Each SDK release records its exact
93
- Contract tag, commit and checksum in the lock; multiple SDK fixes can use the same Contract release. Adopting a new
94
- Contract changes the SDK version according to its public impact. See [CHANGELOG.md](CHANGELOG.md) for migration details.
95
-
96
- ## Design
97
-
98
- The public module is intentionally small:
99
-
100
- ```python
101
- from matelab import AsyncMatelab, RecordLocator
102
-
103
- async with AsyncMatelab() as client:
104
- session = await client.authenticate("user@example.org", "password")
105
- assert client.session is session
106
- notebooks = await client.notebooks.list()
107
- notebook = notebooks.owned[0].ref
108
- records = await client.records.list(notebook=notebook)
109
- source = RecordLocator(notebook=notebook, record=records.records[0].ref)
110
- record = await client.records.read(source)
111
- ```
112
-
113
- `AsyncMatelab()` uses `https://matelab.iphy.ac.cn/api` by default. Pass another Provider API root
114
- explicitly when needed, for example `AsyncMatelab("https://custom.example/api")`.
115
-
116
- ### Error handling
117
-
118
- Catch `MatelabError` once at an integration seam. Every instance exposes a stable `category: MatelabErrorCategory` and
119
- `retryable: bool`. `MatelabProviderError` additionally exposes `provider_code: int` and `provider_message: str`, so a
120
- gateway can retain a stable diagnostic code and safe summary while routing recovery through the integration-level
121
- category:
122
-
123
- | Error | Category | Retryable | Meaning and normal response |
124
- |---|---|---:|---|
125
- | `MatelabAuthenticationError` | `AUTHENTICATION` | No | The Session or credentials cannot authenticate; obtain valid authentication before making a new call. |
126
- | `MatelabUsageError` | `VALIDATION` | No | The call cannot be represented safely; correct its arguments. |
127
- | `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. |
128
- | `MatelabTransportError` | `UPSTREAM` | No | The HTTP exchange failed; `status_code` is present for HTTP failures, and a mutation outcome may be unknown. |
129
- | `MatelabProtocolError` | `UPSTREAM` | No | The response violates the pinned Contract or cannot supply the structured result required by the SDK operation; inspect the Provider result or SDK boundary. |
130
-
131
- For `MatelabProviderError`, the SDK maps Provider wire code `2` to `BUSINESS`, `4` to `VALIDATION`, and `3` or an
132
- unknown code to `UPSTREAM`. `provider_code` is diagnostic metadata, not a stable routing identifier, and integrations
133
- must not reinterpret it instead of `category`. `provider_message` and the exception string are SDK-owned safe summaries
134
- selected from the code mapping; they do not reproduce Provider-authored text. Authentication codes remain hidden behind
135
- `MatelabAuthenticationError`; code `5`
136
- triggers at most one refresh and replay only for authenticated operations that explicitly enable
137
- `retry_on_access_expired`, otherwise codes `1` and `5` raise `MatelabAuthenticationError` directly.
138
- Record operations accept business code `0`. Provider-internal forwarding code `10` is outside the pinned
139
- callable Contract and raises `MatelabProtocolError` rather than assigning an error category.
140
- `MatelabTransportError.status_code` remains available for HTTP failures. Provider response bodies, top-level `msg`,
141
- legacy `errmsg`, opaque `errs`, uncontracted debug fields, and request payloads are not attached to exceptions because
142
- Provider-authored diagnostics may contain stack traces, credentials, or submitted values.
143
- The pinned v2026.10.1 Contract also declares optional code-4 `errs`, but PVD-029 and PCG-010 record opaque array/object
144
- containers and insufficient evidence for requiredness, cardinality, or exclusion from other codes. The SDK
145
- therefore keeps those diagnostics wire-only and does not expose `errs` as stable public metadata. A typed detail
146
- interface remains blocked until a future Contract release can define one from stronger Provider evidence. `retryable`
147
- means the identical SDK call is safe to replay without further interpretation. It is conservatively `False` for every
148
- current SDK error because the Contract does not guarantee mutation idempotency or outcome; an integration may add a
149
- narrower operation-specific retry policy only when it owns that evidence.
150
-
151
- ### Session ownership
152
-
153
- Each `AsyncMatelab` instance owns at most one current, process-local `Session`. The SDK injects its bearer
154
- token, refreshes it under a per-instance async lock, performs bounded safe retries, and exposes every token
155
- rotation through `client.session`. If refresh succeeds but the subsequent business request fails,
156
- `client.session` still contains the refreshed token pair.
157
-
158
- | SDK responsibility | Integrator responsibility |
159
- |---|---|
160
- | Bearer injection, expiry checks, refresh and bounded retry | Redis/database/file persistence and encryption |
161
- | Per-instance, in-process refresh serialization | Cross-process locking and conflict handling |
162
- | Contract validation of token and identity responses | Mapping `userid`/`session_id` to a persisted `Session` |
163
- | Latest immutable `Session` through `client.session` | Revocation, cleanup, and saving after each call |
164
-
165
- `Session`, `Token`, and `Identity` are frozen Pydantic models and form the stable, normalized SDK Session contract.
166
- Their JSON fields are `access`, `refresh`, and `identity`; tokens contain `value` and `expires_at_ms`, while identity
167
- contains `userid`, `username`, and `email`. Provider envelope fields are not part of this model. An `Identity` requires
168
- a positive userid and non-empty username; its email may be `None`.
169
-
170
- Token values are excluded from model representations but intentionally remain present in `model_dump()` and
171
- `model_dump_json()` so an integration can persist and restore the complete Session:
172
-
173
- ```python
174
- serialized = session.model_dump_json()
175
- restored = Session.model_validate_json(serialized)
176
- assert restored == session
177
- ```
178
-
179
- The serialized result contains live credentials. Encrypt it at rest and never write it to logs or send it to an
180
- untrusted party. The SDK does not read tokens from environment variables and does not provide a session store.
181
-
182
- Credential authentication installs the returned Session on the client:
183
-
184
- ```python
185
- async with AsyncMatelab() as client:
186
- session = await client.authenticate(username, password)
187
- assert client.session is session
188
- ```
189
-
190
- Restore a previously validated Session by passing it to the constructor. Construction performs no network request:
191
-
192
- ```python
193
- persisted_session = await session_store.load(userid, session_id)
194
-
195
- async with AsyncMatelab(session=persisted_session) as client:
196
- result = await handle_request(client)
197
- ```
198
-
199
- Every Session contains a validated identity, and refreshing a restored Session preserves it. Integrations that
200
- construct a Session from an external assertion are responsible for validating that assertion and the identity-token
201
- association before passing the complete Session to the SDK; the SDK does not accept bare external token pairs.
202
-
203
- A Web or MCP integration should create one client for one logical session, then save the latest Session in
204
- `finally`, including when a business call fails after refresh:
205
-
206
- ```python
207
- persisted_session = await session_store.load(userid, session_id)
208
- client = AsyncMatelab(session=persisted_session, http_client=shared_http_client)
209
-
210
- try:
211
- result = await handle_request(client)
212
- finally:
213
- latest_session = client.session
214
- try:
215
- if latest_session is not None:
216
- await session_store.save(userid, session_id, latest_session)
217
- finally:
218
- await client.aclose()
219
- ```
220
-
221
- If several processes can use the same persisted session, the integration must place its own distributed lock
222
- around load, use, and save. The SDK lock only coordinates refreshes inside one `AsyncMatelab` instance.
223
-
224
- Different logical sessions require different clients. They may reuse the same externally managed HTTP connection
225
- pool, but must never share one global `AsyncMatelab` singleton:
226
-
227
- ```python
228
- alice_client = AsyncMatelab(session=alice_session, http_client=shared_http_client)
229
- bob_client = AsyncMatelab(session=bob_session, http_client=shared_http_client)
230
- ```
231
-
232
- An injected `http_client` must be an `httpx2.AsyncClient`. The independently distributed `httpx.AsyncClient` has
233
- similar methods but uses incompatible request, response, transport, and exception types.
234
-
235
- `src/matelab/_generated` is a private wire layer. Applications should not depend on its file
236
- layout or generated class names. The distribution includes `py.typed`, so type checkers can consume the
237
- public annotations directly from an installed wheel.
238
-
239
- The current public domain scope includes authentication, group/user discovery, template and notebook
240
- lifecycle operations, record discovery/lifecycle operations, comment reads, and streaming record or
241
- comment attachment downloads, resumable file staging, literature discovery/lifecycle workflows, and
242
- personal cloud-drive management.
243
-
244
- ### Uploading a record attachment
245
-
246
- `records.upload_attachment()` prepares a file without choosing a target record. The returned `StagedAttachment` can
247
- be supplied to one later `import_dataset()` or `update()` in the same notebook by the same authenticated uploader:
248
-
249
- ```python
250
- from matelab import RecordImportItem
251
-
252
- content = b"measurement data"
253
- staged = await client.records.upload_attachment(notebook, filename="measurement.csv", content=content)
254
- record_ids = await client.records.import_dataset(
255
- notebook=notebook,
256
- template_title="Example Template",
257
- items=(
258
- RecordImportItem(
259
- record_uid="REC-IMPORT-001",
260
- title="Imported measurement",
261
- data={"Attachments": {"File": [staged]}},
262
- keywords=("measurement", "calibration"),
263
- ),
264
- ),
265
- )
266
- ```
267
-
268
- `RecordImportItem.data` uses the recursive `RecordImportValue` type: JSON-compatible scalars and nested mappings,
269
- lists, or tuples may contain a `StagedAttachment` wherever the template expects a file. Arbitrary Python
270
- objects are outside the interface and are also rejected at runtime before a Provider request.
271
-
272
- `content` accepts `bytes`, a synchronous `IO[bytes]`, or an `AsyncIterable[bytes]`. Callers do not supply a size,
273
- checksum, or multipart content type. The SDK calculates the SHA-256 itself; streams are fully consumed into a
274
- `SpooledTemporaryFile`, rewound, and only then sent to the Provider. Temporary-file write, seek, read, and close
275
- operations run outside the event-loop thread, including after the spool rolls to disk. The Contract fixes attachment
276
- parts to `application/octet-stream`.
277
-
278
- An exception raised by an async source is propagated unchanged after the SDK closes its temporary file. The SDK reads
279
- streams through end-of-file and imposes no upload-size policy; callers remain responsible for limits such as an HTTP
280
- endpoint's maximum accepted body size. `records.upload_comment_attachment()` uses the same content interface and also
281
- generates its Provider binding internally. When multiple new attachments will be saved into one comment, pass the
282
- first staged comment attachment as `batch=` on each later upload; callers never handle the Provider UID directly.
283
- `uploads.stage()` remains a separate resumable-fragment interface and does not accept async content because its
284
- per-fragment offset and completion semantics are different.
285
-
286
- `StagedAttachment` carries the upload notebook selector, uploader, Provider-resolved binding, filename, and hash.
287
- Callers do not supply a staging identity; the SDK generates the upload-event value and uses the validated response
288
- metadata as the completed staging result. The handle deliberately carries no `RecordLocator`: the Provider staging row
289
- is not bound to a record, so record identity belongs only to the later import or update operation. Provider temporary
290
- row IDs are not exposed.
291
-
292
- The Provider supplies no staging status, abort, TTL, atomicity, idempotency, or retry guarantee. Integrations that
293
- persist handles must durably claim their own `uploaded -> finalizing/indeterminate` transition before calling a
294
- finalizer.
295
-
296
- ### Attachment-bearing record update boundary
297
-
298
- `records.update(source, changes, ...)` accepts one non-empty sequence of `RecordChange` intents. Value updates and
299
- deletions, module changes, attachments, file structure, and rich text all cross this single interface; the SDK reads
300
- the current record, validates conflicts, and compiles them into the Provider's `modify`, `del`, `addModule`,
301
- `delModule`, and `add` operation families. The input sequence groups update intent; it is not an execution-order DSL,
302
- because the Provider operation families determine wire ordering.
303
-
304
- For an existing record, upload against its notebook and pass the same `StagedAttachment` type as one change; callers
305
- never construct Provider attachment paths or attachment strings:
306
-
307
- ```python
308
- from matelab import RecordFormAttachmentFieldAddition, RecordTableFileCellSet, RecordTableRowAttachmentAppend
309
-
310
- uploaded = await client.records.upload_attachment(source.notebook, filename="measurement.csv", content=content)
311
-
312
- await client.records.update(
313
- source, (RecordFormAttachmentFieldAddition(module="Attachments", name="Measurement", attachment=uploaded),)
314
- )
315
-
316
- # Each of the following is a separate update with a fresh upload.
317
- await client.records.update(
318
- source, (RecordTableFileCellSet(table="Measurements", column="Evidence", row=0, attachment=another_uploaded),)
319
- )
320
-
321
- await client.records.update(
322
- source,
323
- (
324
- RecordTableRowAttachmentAppend(
325
- table="Measurements", file_column="Evidence", values={"Label": "Sample C"}, attachment=third_uploaded
326
- ),
327
- ),
328
- )
329
- ```
330
-
331
- These three operations use the Provider's staged-name finalizer and must be the only mutation in their update request.
332
- `RecordTableFileCellSet` requires an existing file column and a cell whose immediate canonical value is exactly
333
- `null`. `RecordTableRowAttachmentAppend` addresses the new row by the row count from the SDK's immediate read and
334
- supports one file column. A locally rejected target does not spend the handle; once transport starts, an ambiguous
335
- result cannot be retried through the same client.
336
-
337
- Files-module append at the module root and table/files replacement may be combined with other non-conflicting record
338
- changes. `RecordFilesAttachmentAppend.folder_path` optionally selects folders within the files module. A non-empty
339
- path uses the staged-name finalizer and must be the only mutation in its update request; the SDK validates every
340
- segment before writing to avoid the Provider's malformed-path fallback to the module root (PVD-030):
341
-
342
- ```python
343
- from matelab import RecordAttachmentReplacement, RecordFilesAttachmentAppend, RecordValueUpdate
344
-
345
- await client.records.update(
346
- source,
347
- (
348
- RecordValueUpdate(path=("Metadata", "Reviewed"), value=True),
349
- RecordFilesAttachmentAppend(module="Files", attachment=files_uploaded, caption="Evidence"),
350
- ),
351
- )
352
- ```
353
-
354
- To append within a folder, use a fresh upload in a separate update:
355
-
356
- ```python
357
- await client.records.update(
358
- source,
359
- (RecordFilesAttachmentAppend(module="Files", attachment=folder_uploaded, folder_path=("Evidence", "Raw Data")),),
360
- )
361
- ```
362
-
363
- For `RecordModuleCreation(module_type="code", language="json", style="echarts")`, omitted, null, and empty `data` all
364
- produce an empty string; explicit `"0"` stays `"0"`. Send the desired initial chart configuration as a JSON code string.
365
- Richtext/code module creation defaults missing `data` to `""`; form/table/files creation ignores it and initializes
366
- content as `[]`.
367
-
368
- A replacement starts with an occurrence returned by `records.read()`; applications must not fabricate a
369
- `RecordAttachmentRef`. The same `RecordAttachmentReplacement` covers both table-file and files-module occurrences:
370
-
371
- ```python
372
- record = await client.records.read(source)
373
- existing = next(
374
- attachment
375
- for attachment in record.attachments
376
- if attachment.location is not None and attachment.location.kind == "files_module"
377
- )
378
- await client.records.update(
379
- source, (RecordAttachmentReplacement(existing=existing, replacement=replacement, caption=None),)
380
- )
381
- ```
382
-
383
- For a files occurrence, `caption=None` preserves the observed string caption (an observed null caption normalizes to
384
- the required empty string). Table replacement requires exactly one current attachment in the selected cell and does
385
- not accept a caption. Record reads likewise accept only one canonical attachment object or `null` in a table file cell;
386
- an attachment array violates the pinned response contract. Both replacement forms require the replacement hash to
387
- differ from the current occurrence.
388
-
389
- As a temporary Provider-compatibility exception, `records.read()` also accepts a table file attachment object whose
390
- otherwise valid `filename`, `hash`, and `size` are present but whose Contract-required `timestamp` is omitted. The SDK
391
- preserves that omission in `Record.modules` and does not invent a timestamp; null timestamps and all other malformed
392
- attachment shapes remain response errors. This exception can be removed after the Provider canonicalizes current and
393
- historical record content.
394
-
395
- All record attachment intents reject handles from another notebook or authenticated uploader, duplicate use within one
396
- finalization request, and raw Provider attachment references. Row/index-based operations use an immediate SDK read and
397
- must not be called while a concurrent editor is known to be active. The Provider offers no expected hash or revision,
398
- so the SDK does not present its read-before-write checks as concurrency control.
399
-
400
- File-bearing structure changes do not consume staging:
401
-
402
- ```python
403
- from matelab import RecordFormFileFieldDeletion, RecordTableFileColumnAddition, RecordTableFileColumnDeletion
404
-
405
- await client.records.update(
406
- source,
407
- (
408
- RecordTableFileColumnAddition(table="Measurements", name="Additional evidence"),
409
- RecordFormFileFieldDeletion(form="Attachments", name="Obsolete evidence"),
410
- RecordTableFileColumnDeletion(table="Measurements", name="Old evidence"),
411
- ),
412
- )
413
- ```
414
-
415
- The SDK emits the v0.1.2 dedicated operations: a new file column omits wire `data`; whole
416
- form-field/table-column deletion uses a strict two-segment target. It does not expose a three-segment delete as
417
- table-cell clearing.
418
-
419
- Deleting a complete canonical module is also supported even when the current read observes attachments in it:
420
-
421
- ```python
422
- from matelab import RecordModuleDeletion
423
-
424
- result = await client.records.update(source, (RecordModuleDeletion("Raw files"),))
425
- ```
426
-
427
- This requests only a canonical record-content mutation. A successful response acknowledges the submitted mutation;
428
- it does not prove attachment-quote cleanup or byte deletion. Collaboration-pending is reported as
429
- `"pending"`, not persisted.
430
-
431
- `RecordRichTextUpdate` accepts the same `StagedAttachment` as import and other update intents. Attachment-bearing
432
- multi-record import is forbidden, so callers must split it into single-record imports with a fresh staged attachment
433
- for each request. Once an import or update request using a staged attachment starts, that handle cannot be retried or
434
- used for the other finalizer through the same client.
435
-
436
- ## Operation coverage
437
-
438
- The SDK tracks all 71 Contract operations and exposes 69 through public domain interfaces; identity bootstrap and the
439
- frontend literature-creation template are intentionally unexposed. It deliberately excludes MCP migration,
440
- adjacent-repository changes, external publishing, and automatic mutation against a real Provider.
441
-
442
- Machine-readable status lives in
443
- `docs/operation-coverage.yaml`. An exact-coverage test keeps its 71
444
- operation IDs, methods, paths, states, public interfaces, and Provider issue references aligned with the pinned
445
- OpenAPI snapshot.
446
-
447
- | Domain | Implemented | Planned | Current public surface |
448
- |---|---:|---:|---|
449
- | Authentication | 3 | 0 | `authenticate`, `refresh`, `exchange_chat_sso_code`; identity bootstrap intentionally unexposed |
450
- | Groups and users | 2 | 0 | `groups.list`, `users.search` |
451
- | Notebooks | 6 | 0 | `notebooks.list/create/update/shares/share/update_share/unshare` |
452
- | Records | 18 | 0 | Discovery, reads, lifecycle, typed patch/attachments, relations, and downloads |
453
- | Comments | 5 | 0 | Read, staged attachment upload, create/update/delete, and download |
454
- | Templates | 13 | 0 | Discovery, content, lifecycle, sharing, groups, and marketplace |
455
- | File staging | 1 | 0 | Resumable fragment staging and compensating abort request |
456
- | Literature | 12 | 0 | Libraries, items, canonical metadata, comments, sharing, PDF lifecycle and streaming |
457
- | Cloud drive | 9 | 0 | Personal root/folders/files, staged binding, metadata, move/delete and streaming |
458
- | **Total** | **69** | **0** | Two operations are intentionally unexposed |
459
-
460
- ### Stability and known capability limits
461
-
462
- Coverage currently contains 13 `stable`, 56 `experimental`, and two `not_applicable` operations. The stable operation
463
- IDs are `loginTokenSet`, `refreshTokenSet`, `exchangeChatSsoCode`,
464
- `shareMultipleTemplatesWithUsers`, `removeTemplateFromGroup`, `deleteNotebookShare`, `listNotebooks`,
465
- `listNotebookRecords`, `exportRecords`, `deleteRecordsByUid`, `copyRecord`, `readRecord`,
466
- and `deletePersonalLiteratureItem`.
467
-
468
- Every other implemented operation is explicitly `experimental`; the exact per-operation list and its
469
- PVD/PCG references live in
470
- `docs/operation-coverage.yaml`. `readApplicationBootstrap` is intentionally unexposed because it returns the Provider
471
- application environment and menu model, including required `data_express_enable` and `data_express_text` fields, while
472
- SDK identity is sourced from token issuance. `readLiteratureCreateTemplate`
473
- is intentionally unexposed because it returns a frontend form definition and an optional deployment flag rather than
474
- a resource needed by the SDK interface. There are no `planned` operations. Experimental support means the SDK
475
- validates and exposes the pinned Contract while preserving limitations such as unstable ordering/pagination,
476
- incomplete mutation acknowledgements, missing batch atomicity or idempotency, weak attachment ownership binding, and known
477
- Provider authorization gaps. It does not turn those limitations into SDK guarantees.
478
-
479
- Chat iframe SSO consumes a one-time code and shared key. Both arguments are treated as secrets, the request is never
480
- automatically retried, and the returned token set is stored in the same in-memory `Session` shape as credential login:
481
-
482
- ```python
483
- session = await client.exchange_chat_sso_code(code="chat-sanitizedcode123", key="sanitized-shared-key")
484
- ```
485
-
486
- The code suffix must be non-empty and the shared key must be non-blank. The Provider rejects a code after the current
487
- user's `active_datetime` plus 300 seconds; that timestamp can change through other user activity, so Consumers must not
488
- derive a deadline from the code or automatically retry an uncertain exchange. A sequential replay is rejected, but the
489
- Provider's read-then-clear implementation does not guarantee that concurrent exchanges have only one winner.
490
- Ordinary credential login does not expose the non-empty WeChat binding code branch, which can reassign another account's
491
- OpenID (PVD-032).
492
-
493
- Group and user discovery expose sharing identities without inventing Provider pagination:
494
-
495
- ```python
496
- groups = await client.groups.list()
497
- targets = await client.users.search("Example Researcher", global_scope=False)
498
- ```
499
-
500
- `groups.list()` returns the `GroupSummary` tuple directly. Ordering remains Provider-unspecified and is documented
501
- rather than repeated as a constant result field. The Provider's members for an unstable first group remain wire-only;
502
- `users.search()` is the public recipient-discovery interface. These two discovery interfaces are experimental because
503
- group order is unstable and user search is unpaged, unordered and not field-minimized (PVD-017, PVD-025).
504
- `GroupSummary.notebook_creation_available` expresses whether the group can currently host notebook creation without
505
- exposing the Provider's data-server routing value.
506
- An observed dangling group membership has `GroupSummary.ref=None` and no usable group identity. If such a membership
507
- is selected as the Provider's first group, the whole request can fail with a Provider error (PVD-048).
508
-
509
- Notebook create and direct sharing use the Provider acknowledgement without an automatic follow-up read:
510
-
511
- ```python
512
- from dataclasses import replace
513
-
514
- await client.notebooks.create(title="Example Notebook")
515
- shares = await client.notebooks.shares(notebook)
516
- await client.notebooks.share(notebook, [target.ref])
517
- share = shares.shares[0]
518
- await client.notebooks.update_share(
519
- share.ref, replace(share.permissions, can_write_records=True, can_create_records=True)
520
- )
521
- ```
522
-
523
- Create, update, share, permission update, and unshare return `None` because their Provider responses contain no new
524
- resource representation. Updating notebook metadata first reads the current owned-notebook wire snapshot so the SDK
525
- can preserve the Provider's UI-only type tags and list-display configuration, which the public SDK does not expose.
526
- Consequently, update performs one list pre-read followed by the write. This preservation is best effort because the
527
- Provider offers no compare-and-swap or atomic read-modify-write operation. Call `list()` or `shares()` explicitly when
528
- the application needs current state. `NotebookPermissions` normalizes the Provider's stored and effective masks and
529
- is reused by record pages instead of maintaining a second nearly identical permission model. Direct shares always
530
- have implicit read access even when every optional permission is false (PVD-037).
531
- `provider_signing_allowed` only represents Provider policy, not an SDK signing operation. Share-list order remains
532
- unspecified.
533
- Stored notebook type configuration may decode to any non-null JSON value, but metadata writes accept arrays only.
534
- If the stored configuration or display extraction requests cannot be preserved within the write Contract, update
535
- raises `MatelabProtocolError` before sending a mutation instead of silently replacing them (PVD-046).
536
-
537
- Template discovery keeps a template database identity separate from direct-share, market-acquisition, and group
538
- relation identities:
539
-
540
- ```python
541
- templates = await client.templates.list()
542
- market = await client.templates.search_market("calibration", page=1, page_size=20)
543
- owned = next(template for template in templates if template.scope == "owned")
544
- modules = await client.templates.read(owned.ref)
545
- ```
546
-
547
- Template content is exposed as canonical `TemplateModule` values and saved as a complete replacement:
548
-
549
- ```python
550
- modules = await client.templates.read(template)
551
- # Build the complete replacement from these canonical modules.
552
- await client.templates.save_content(template, modules)
553
- ```
554
-
555
- `TemplateModule.data_present` distinguishes a missing `data` property from an explicit null value, while
556
- `attributes` prevents additive canonical properties from being dropped during a read/save cycle. The SDK does not
557
- synthesize frontend editor UIDs, layout widths, rows, folders, option encoding, or a fixed set of display module
558
- types. A JSON-language code module's `data` remains code text and may be empty, null, or invalid JSON;
559
- `language=json` does not imply that Consumers can parse it. When template content contains a code module with
560
- `language=json` and non-null `style`, its code text must be valid JSON to save. Number and boolean table cells preserve
561
- the Provider's empty string as distinct from scalar and `null` values. The Provider accepts some
562
- non-canonical module roots and arbitrary table file arrays, but the SDK does not expose that behavior: saved content
563
- must remain a canonical module list, and a table file cell must be one canonical attachment object or `null` (PVD-013).
564
- The Provider supports only whole-content replacement and has no atomic patch or compare-and-swap operation (PCG-008),
565
- so a read-modify-save sequence can overwrite a concurrent change.
566
-
567
- The market result retains the Provider's `total_count` and derives `has_more` without echoing the caller's page
568
- arguments; it does not claim a stable order or continuation token. Template reads remain experimental
569
- because Provider discovery ordering/pagination and historical `images` compatibility are not fully stable
570
- (PCG-011, PCG-012, PVD-038, PVD-022).
571
-
572
- `templates.list()` returns one tuple. `TemplateSummary.scope` is limited to `owned`, `shared`, `group`, and `public`;
573
- Provider marketplace acquisitions and system defaults are both public templates. An acquired public template carries
574
- a user-library relation while a default does not. Provider `pending[]` is an administrator review queue rather than a
575
- visibility scope and is not returned as an ordinary template. Owned rows expose their actual submission state through
576
- `market_review_pending`. User-library and group-library relations occupy different identity spaces;
577
- `TemplateLibraryEntryRef.kind` records that distinction and `remove_from_library()` routes to the correct Provider
578
- operation. Direct shares and marketplace acquisitions intentionally share the same user-library removal semantics.
579
-
580
- Template writes remain separate operations: metadata, canonical modules, and usage HTML are not presented as one
581
- transaction. Creation returns the new `TemplateRef`; metadata replacement and other mutations return `None` because
582
- the Provider supplies no new resource representation. `update()` therefore requires the complete title, summary, and
583
- keyword set rather than implying a partial patch. `update_usage()` generates the Provider's required fresh hidden
584
- correlation value internally. Usage attachments remain unsupported until their staging and binding lifecycle is
585
- contracted safely (PVD-003). Marketplace revision limitations remain explicit through `content_version`,
586
- `published_content_version`, and `market_review_reason` (PVD-021).
587
-
588
- Extended record reads stay behind the same `records` interface:
589
-
590
- ```python
591
- from matelab import RecordLocator
592
-
593
- source = RecordLocator(notebook=notebook, record=record)
594
- exported = await client.records.export([source])
595
- matches = await client.records.search({"notes": ("Notes",)}, notebooks=[notebook])
596
- page = await client.records.page(notebook, search="voltage")
597
- deleted = await client.records.recycle_bin(notebook)
598
- relations = await client.records.relations(source)
599
- ```
600
-
601
- `records.export()` returns `ExportedRecord` snapshots directly as a tuple. Their canonical stored content is exposed
602
- as `modules`; it is not the template-shaped `RecordImportItem.data` accepted by record import.
603
- `records.search()` maps each requested field name to a stored record path. A match exposes only Provider-returned
604
- entries in `values`, so key membership distinguishes an absent result from an explicit JSON `null`.
605
- Extraction aliases cannot use `id`, `uid`, `eln_id`, `title`, or `eln_name`, which would overwrite result metadata
606
- (PVD-043). Paths contain one to four segments: the first three accept non-empty, non-numeric names or non-negative
607
- integer indices; a fourth segment must be a non-numeric property name. Numeric strings are rejected before HTTP.
608
- The current Provider excludes encrypted records from search (PVD-041); an empty search result therefore does
609
- not prove that no matching encrypted record exists.
610
-
611
- The Provider calls its notebook directory tree `subtype`; that name remains wire-only. The SDK exposes
612
- `RecordFolder`, `RecordSummary.folder_id`, and the `folders` returned with record listings. A top-level folder has
613
- `parent_folder_id=None`, and SQL `NULL` counts are normalized to zero. Records at the notebook root have
614
- `folder_id=None`. For `records.page()`, omit `folder_id` to include all folders, pass `0` for root records, or pass a
615
- positive folder ID for one folder.
616
-
617
- `records.page` fixes the legacy request to `page_size=0&default=1`, preventing the known owner-preference writes
618
- described by PVD-028; its total is derived internally from the Provider's complete matching ID list. The result keeps
619
- semantic `NotebookPermissions` directly, without a shallow context model or duplicated notebook/owner/template
620
- display metadata. Effective request pagination, the UI selection list and Provider keyword-filter echo are not exposed.
621
- Its `search` argument, shared with `records.list()`, maps the Provider's wire-level `content` filter without exposing
622
- that ambiguous name.
623
- It remains distinct from the unpaged integration `records.list()` operation: only the legacy page response supplies
624
- effective permissions, folders and total matching identities, so merging the two would introduce nullable fields.
625
- Active and deleted records use the same `RecordRef`; recycle-bin state is expressed by `DeletedRecordSummary`, not a
626
- second identity type. Public catalog records use a distinct identity because their catalog and source IDs differ.
627
- Public summaries keep source notebook attribution but omit the Provider's unused publication-lock residue and owner
628
- email; their `notebook_title` follows the same naming used by export and search results. Relation refs retain only the
629
- stored relation row and declared target IDs; nullable JOIN previews and resolved target identity stay wire-only.
630
- Search and relation order remain unspecified, and no continuation token is invented. Export and search return tuples
631
- directly. Remote data-server forwarding code `10` is rejected because only local data-server results are covered.
632
- `DeletedRecordPage` does not repeat its input notebook.
633
-
634
- Record creation keeps blank creation and structured import as separate capabilities:
635
-
636
- ```python
637
- from matelab import RecordImportItem
638
-
639
- await client.records.create_blank(
640
- notebook=notebook, title="Blank Record", record_uid="caller-generated-uid", folder_path=("Measurements",)
641
- )
642
- imported = await client.records.import_dataset(
643
- notebook=notebook,
644
- template_title="Example Template",
645
- items=[
646
- RecordImportItem(
647
- record_uid="import-uid", title="Imported", data={"Notes": "value"}, folder_path=("Measurements",)
648
- )
649
- ],
650
- )
651
- ```
652
-
653
- Blank-record creation returns `None` because the Provider returns no record identity, even when the caller supplies a
654
- UID. Import validates the complete batch with generated wire models but cannot map returned database IDs to
655
- individual inputs or promise atomicity (PCG-009); it therefore returns only the tuple of database IDs actually
656
- reported by the Provider. `RecordImportItem.keywords` is a tuple; the adapter owns the Provider's
657
- legacy semicolon encoding. Folder paths are ordered existing folder titles from the notebook root; they do not create
658
- missing folders. The current Provider rejects explicit-null table-file imports (PVD-031). Because the SDK accepts
659
- arbitrary template-shaped data and cannot identify file columns without the template definition, integrations must omit
660
- an unset file-valued key instead of sending it as `None`. Delete means moving records into the recycle bin, not permanent
661
- deletion. Delete and restore return `None`; record mutations are not automatically retried.
662
-
663
- `records.copy()` accepts an optional `new_record_uid`; when omitted, the SDK generates one locally. The Provider
664
- returns only the copied row's database ID, so retaining that UID lets the SDK return a complete `RecordLocator`
665
- without a readback request. Omitting `target_notebook` copies within the source notebook.
666
-
667
- Record patching exposes a deliberately narrower capability than the raw Provider operation. Scalar/module changes
668
- cannot smuggle Provider-native attachment strings; staged attachments use separate form-removal, table-replacement,
669
- files append/replace/remove, and rich-text types. Unsafe form replacement and table-file removal are absent, while a
670
- files/images removal is rejected when the observed module contains the same hash more than once (PVD-010, PVD-018,
671
- and PVD-019). The SDK reads current content only when a change needs attachment type, occurrence, row, or index state;
672
- ordinary module creation and deletion rely on the Provider's own canonical update validation. `records.update()`
673
- returns the acknowledgement classification
674
- `"persisted"`, `"pending"`, or `"unknown"`; it does not
675
- issue a post-write read. Database, active-browser, and unclassified acknowledgements remain distinct, and mutation
676
- retries stay disabled. Business code `0` alone does not distinguish persisted data from a browser collaboration
677
- broadcast; the classification uses the observed Provider acknowledgement and supplies no revision or atomicity
678
- guarantee. Current collaboration peers must be browsers viewing the same notebook and record.
679
- Encrypted reads return `Record.encrypted_modules` as an `EncryptedRecordModules` containing the ciphertext,
680
- algorithm and additive fields; `modules` and `attachments` are empty because no plaintext is available. Plaintext
681
- reads have `encrypted_modules=None`. State-dependent updates reject encrypted content before mutation, while
682
- ordinary module changes rely on the Provider's password validation before any write. The password and decryption
683
- lifecycle remain unexposed. The list-level `RecordSummary.encrypted` flag remains available for discovery.
684
- Unresolved author lookup descriptors remain wire-only and map to `author_name=None` on records and comments (PVD-044).
685
- A staged attachment is indeterminate once the write has started.
686
-
687
- Relation addition reads both endpoints and checks their resolved data server before writing; this reduces PVD-009
688
- risk but is not an atomic Provider authorization guarantee. It does not list relations merely to inspect the
689
- caller-relative `editable` display flag: the mutation endpoint performs the source permission check. Relation
690
- deletion lists current relations only to refuse an observed cross-notebook target-ID collision because the Provider
691
- ignores target notebook identity (PVD-020); its mutation likewise performs authorization. Both mutations return
692
- `None` after acknowledgement. `records.relations()` returns the relation tuple directly. The endpoint's duplicated
693
- `more` comments, `editable` value and target preview/JOIN fields stay wire-only; comments come from
694
- `records.comments()`. Each returned `RecordRelationRef` carries only the relation row and declared target IDs.
695
- Deletion accepts the source `RecordLocator` and one of those observed refs.
696
-
697
- Comment upload follows the current Provider's literal one-request `file` field. Comment mutations return `None`
698
- after acknowledgement. Edit first verifies that the selected
699
- comment is currently observed and caller-owned because the Provider otherwise reports a false success for a missing or
700
- other-user ID (PVD-004). Delete sends the typed ref directly because the Provider mutation itself enforces record and
701
- caller ownership. Neither operation performs a post-write read. Staged comment attachments have no Contract abort
702
- operation, and binding remains affected by PVD-003. Comment create/update use the same `body` vocabulary as the
703
- returned `RecordComment.body`; the SDK does not perform rich-text display normalization, while the Provider's
704
- documented surrounding-whitespace trim still applies when saving.
705
- `RecordCommentRef` carries its `RecordLocator`, so update and delete accept the ref rather than an entire comment or a
706
- public `owned_by_caller` UI flag.
707
-
708
- Attachment bytes are streamed and must be consumed or closed explicitly:
709
-
710
- ```python
711
- from matelab import ByteRange
712
-
713
- comments = await client.records.comments(source)
714
- comment = comments[0]
715
- attachment = comment.attachments[0]
716
- async with await client.records.download_comment_attachment(
717
- comment.ref, attachment, byte_range=ByteRange.from_start(0)
718
- ) as download:
719
- async for chunk in download:
720
- consume(chunk)
721
- ```
722
-
723
- `DownloadStream` exposes status, content type, length, range, and disposition metadata without buffering the
724
- complete file. Streams are not automatically replayed. `ByteRange` deliberately rejects `bytes=0-0` (PVD-015).
725
- For local files, `ByteRange.from_start(0)` returns at most the first 4097 bytes. Inspect `content_range` and request
726
- the next range starting at its end plus one, use a positive explicit end, or omit `byte_range` for the full file.
727
- Zero-byte comment attachments can be discovered and downloaded without a range.
728
- Comment attachment refs contain only file identity. Pass the parent comment ref returned by the same read when
729
- downloading; current Providers do not verify that association (PVD-006).
730
-
731
- Cross-domain staging keeps resumable state and completed-file identity separate:
732
-
733
- ```python
734
- from matelab import StagedFile
735
-
736
- pdf_bytes = b"sanitized PDF bytes"
737
- staged = await client.uploads.stage(pdf_bytes, filename="example.pdf")
738
- assert isinstance(staged, StagedFile)
739
- ```
740
-
741
- The SDK derives `fragment_size` and computes the complete SHA-256 for a single `bytes` fragment. File-like content
742
- must declare its fragment size. For multiple fragments, pass the returned `StagedUploadSession` into the next call and
743
- supply `complete_sha256` on the final call. `next_offset` is explicitly a caller-side total on an active upload session,
744
- 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
745
- client-tracked total, but does not repeat those caller-known values on the final result. A final result contains the
746
- Provider hash, temporary row identity and direct `binding`; it no longer embeds resumable session state. The temporary
747
- Provider URL and upload-session identity needed by internal binding and cleanup remain hidden. Callers do not need to
748
- splice either into content. The result does not claim that a later literature/cloud operation checks the uploader or
749
- consumes the file exactly once. `uploads.abort()` accepts either an active `StagedUploadSession` or a completed
750
- `StagedFile` and exposes the Provider's legacy code-2 cancellation signal as a `None`-returning compensating cleanup
751
- that is not independently verified (PVD-024).
752
- Staging mutations are never automatically retried.
753
-
754
- Literature identities distinguish the personal library from shared libraries:
755
-
756
- ```python
757
- from matelab import LiteratureMetadataInput
758
-
759
- libraries = await client.literature.libraries()
760
- page = await client.literature.list()
761
- detail = await client.literature.read(page.items[0].ref)
762
- candidate = await client.literature.extract_metadata(doi="10.0000/example")
763
-
764
- await client.literature.create(
765
- LiteratureMetadataInput(title="Example import", doi="10.0000/example"), staged_pdf=staged
766
- )
767
- ```
768
-
769
- `literature.libraries()` directly returns usable `LiteratureLibrarySummary` values in Provider-unspecified order.
770
- `LiteratureLibraryRef()` identifies the synthetic personal library; a positive `library_id` identifies a real shared
771
- library, and the read-only `scope` property is derived from that identity. Each `LiteratureItemRef` carries the library
772
- where it was observed. Raw permission masks are normalized as `LiteratureLibraryPermissions`. Dangling memberships
773
- without a usable library identity and pending-share projections remain wire-only; the pinned Contract does not provide
774
- the complete accept/reject workflow needed to expose pending shares safely.
775
-
776
- The Provider's static literature form definition and extraction deployment flag remain wire-only; Consumers call
777
- `extract_metadata()` directly and handle an unavailable or failing optional backend as a Provider error. Extraction
778
- returns a `PublicationMetadataCandidate`: unstored, rich publication evidence rather than metadata already attached to
779
- a literature item. Its structured authors are `PublicationAuthorCandidate` values. Extraction accepts a DOI, a
780
- completed staged PDF, or both; a supplied DOI takes precedence over the staged PDF. A non-empty DOI must match
781
- `^10\.[0-9]+/\S+$`; the generated request model rejects other values before sending them to the Provider (PVD-040).
782
- The Provider can wrap an error inside an outer successful extraction response. The SDK maps that nested error to
783
- its usual safe Provider/authentication error. Empty or opaque backend lists raise `MatelabProtocolError` because they
784
- provide no structured `PublicationMetadataCandidate`; an outer success code does not promise extracted metadata
785
- (PVD-045).
786
- Literature list
787
- results likewise exclude Provider-generated citation HTML and author-highlight fragments; Consumers receive the
788
- underlying citation fields instead of presentation markup. `LiteratureDetail.metadata` is `None` when invalid legacy
789
- JSON produces the Provider's empty-array fallback; otherwise it contains decoded stored metadata without arbitrary
790
- extension values.
791
- List and detail results normalize the Provider's zero rating to `None`. Missing list author, abstract, journal, and PDF
792
- values are likewise `None`. A `LiteraturePdf` is exposed only when the Provider supplies the SHA-256 required for
793
- download; its optional filename and size metadata may still be `None`. The list response's
794
- `dtime` is not exposed as a publication date because it is a mutable library-entry timestamp with no stable Contract
795
- meaning. `LiteratureItemSummary.tags` and `LiteratureDetail.tags` contain the current item's library classification
796
- tags; bibliographic keywords remain under `LiteratureDetail.metadata.keywords` when stored metadata is available.
797
- `LiteratureLibrarySummary.tags` and `LiteraturePage.tags` contain the library-wide tag set used for filtering. Library
798
- names and tag sets belong to library/list results rather than being repeated on an item detail.
799
- `StoredLiteratureMetadata.author` is the single public author representation: canonical author text takes precedence,
800
- while a legacy author list is joined with semicolons when no canonical value exists. The legacy array remains wire-only.
801
-
802
- `LiteratureMetadataInput` is the complete canonical input for create and replacement. `StoredLiteratureMetadata` is a
803
- decoded snapshot returned by `read()` and can contain readable legacy/source fields that the canonical write schema
804
- does not accept. Neither is interchangeable with an extraction candidate.
805
-
806
- Create returns `None` and never guesses the new item from list position because the Provider returns no ID. By default,
807
- canonical update preflights the raw item response and refuses to drop top-level or nested source/hidden fields. Passing
808
- `allow_source_metadata_loss=True` explicitly skips that preflight and sends the canonical replacement directly. The
809
- default read/check/write protection is best effort rather than atomic because the Provider offers no revision or
810
- compare-and-swap condition; this Provider-specific behavior remains internal to the update operation (PVD-012).
811
- PDF replace/delete are separate acknowledged mutations and are not presented as atomic with metadata.
812
- Permanent personal deletion is named `permanently_delete`, returns `None`, and is non-recoverable. Sharing requires
813
- list-observed item summaries, user-search summaries and a
814
- valid caller identity, then returns `None` because the Provider supplies no per-recipient IDs (PVD-008, PVD-027).
815
-
816
- Literature comments use explicit `create_comment()` and `update_comment()` intents. Creation first reads the current
817
- detail and rejects when the caller already owns a comment; callers then use that comment's `LiteratureCommentRef` with
818
- `update_comment()`. The ref contains only the Provider comment-row identity. Update and delete send that identity
819
- directly because the Provider mutation verifies current ownership and library permission. The creation preflight
820
- maintains the Integration Contract's one-caller-comment invariant for ordinary SDK use, but is not atomic because the
821
- Provider has no matching uniqueness constraint (PVD-026). When sharing with `copy_owner_comment=True`, multiple
822
- caller-owned comments remain an explicit ambiguity because the Provider would otherwise choose one implicitly. A
823
- staged attachment can replace one `matelab-staged-file` marker; raw temporary URLs are rejected. These checks prevent
824
- common misuse but do not repair the Provider's cross-user UID lookup (PVD-003). Shared-library reads and writes remain
825
- experimental because the Provider permission JOIN is not scoped to the current user (PVD-007); successful SDK calls
826
- must not be treated as independent authorization proof. Literature PDF downloads reuse `DownloadStream` and the
827
- stable `ByteRange` subset.
828
-
829
- The personal cloud-drive surface keeps root, folder, final file and temporary staging identities separate:
830
-
831
- ```python
832
- listing = await client.cloud_drive.list()
833
- folder = await client.cloud_drive.create_folder(name="Example data")
834
- await client.cloud_drive.bind_staged_file(staged, target=folder)
835
- ```
836
-
837
- `CloudDriveListing` contains the selected files, `total_count` and SDK-derived `has_more` alongside a complete folder
838
- snapshot and quota usage; pagination applies only to `files`, while request page arguments are not echoed. Personal-root
839
- metadata and permissions are synthetic/fixed for this Contract line and remain wire-only. File rows omit the constant
840
- caller owner fields and do not invent a folder location that the Provider fails to return for root-wide searches.
841
- Listings likewise omit search/order/direction request echoes. Ordering has no stable ID tie-breaker (PCG-011, PVD-038).
842
- `CloudFile.modified_at` contains the stored modification timestamp as a non-null string. A stored null causes a
843
- Provider error before a successful file row can be returned (PVD-047). Display-formatted date and time remain wire-only.
844
-
845
- Folder create returns a `CloudFolderRef` built from the Provider ID. Other folder and file mutations return `None`
846
- after acknowledgement and do not automatically list the drive. Staged finalize accepts a completed `StagedFile`;
847
- the Provider binds by temporary row ID without checking its owner (PVD-002), and finalize atomicity/idempotency remain
848
- absent. Batch move and permanent delete do not claim Provider per-item results or atomicity. Permanent
849
- deletion is named `permanently_delete_files` and is non-recoverable. Cloud downloads
850
- resolve bytes from the final file identity and reuse `DownloadStream`, thumbnail/preview choices and the PVD-015-safe
851
- range subset. Cloud mutations are not automatically retried.
852
-
853
- Owned, shared, and public catalog notebooks use one `NotebookRef`, distinguished by its
854
- `scope` (`"owned"`, `"shared"`, or `"public"`). For owned/shared refs, `notebook_id` is the source `eln.id`;
855
- for public refs it is the distinct `eln_public.id` catalog identity and must not be treated as a source ELN ID.
856
- Public record entries expose that source identity separately as `PublicRecordRef.source_notebook_id`.
857
- `NotebookRef.title` is a non-blank `str` preserved exactly because private operations use it as a Provider selector.
858
- The pinned owned/shared wire field remains nullable because the database column allows null, but Provider writes
859
- require a non-empty title. A runtime database audit found no null or blank titles and did find legacy private titles
860
- with surrounding whitespace; the SDK therefore treats null/blank as a protocol violation without normalizing the exact
861
- selector. Public record listing uses that non-blank title as an additional Provider selector and accepts only public
862
- scope. Private
863
- operations reject public scope, while owned-only notebook mutations continue to reject shared/public refs. Static
864
- typing exposes exactly those three scope values, so a redundant runtime membership check is not repeated. Scope policy
865
- is checked at each SDK operation boundary; reading `title` itself does not imply or perform a private-scope check.
866
- `RecordRef` and `RecordVersionRef` keep their Provider identifiers distinct; a version ref carries its typed
867
- `RecordLocator` instead of repeating notebook ID, record database ID and record UID fields. `records.read()` accepts
868
- either a `RecordLocator` for current content or a `RecordVersionRef` for historical content, so callers never repeat
869
- the record identity alongside its version. Historical reads first re-read the authorized current record and confirm
870
- that the requested version is still present in its `modify_log`; both reads write Provider audit entries.
871
-
872
- All three notebook collections contain the same `NotebookSummary` model. Public catalog entries do not expose owner
873
- names or groups, so those fields are `None`. Public summary and creation time remain non-null at runtime under the wire
874
- Contract, while the unified model uses the wider owned/shared nullable types.
875
-
876
- Provider `types`, `display`, and `requests` fields configure its notebook-list UI rather than notebook domain data.
877
- `types` normally holds classification tags used by the Provider frontend for client-side filtering; legacy stored JSON
878
- can have another non-null shape. These fields are absent from `NotebookSummary` and from create/update inputs;
879
- the SDK handles their update-time
880
- preservation only as an internal Provider compatibility detail.
881
-
882
- Template render metadata is also excluded from template summaries and record reads. A `Record` has one
883
- `source: RecordLocator`; it does not duplicate the notebook and record refs, caller-relative owner/editable UI state,
884
- notebook title/keywords, Provider data-server routing, or the frontend PDF-capability flag. `Record.version` identifies
885
- historical content and is `None` for the current content. `Record.versions` contains refs usable for historical reads;
886
- Provider lock and signature projections remain wire-only. Relation creation still checks returned data servers
887
- internally to mitigate PVD-009.
888
-
889
- Provider record-finalization and additional-signature mutations are intentionally outside the pinned Contract and SDK
890
- interface. They require the account password to unlock a private key, combine version creation with signing, provide no
891
- reliable per-record result, and lack a compare-and-swap guard between hashing and snapshot creation. The SDK therefore
892
- does not expose a signing operation, signature projection or credentials for these operations; notebook permission
893
- results only report whether Provider policy allows signing.
894
-
895
- Errors are separated into semantic Provider errors, authentication errors, HTTP/transport errors,
896
- Integration Contract response errors, and client-side usage errors. Semantic Provider errors retain only the
897
- Contract-defined top-level code plus an SDK-owned safe summary; Provider-authored messages, response bodies, legacy
898
- aliases, and unrelated fields do not enter exceptions.
899
-
900
- ## Development
901
-
902
- ```bash
903
- uv sync
904
- uv run python scripts/generate_models.py
905
- uv run python scripts/generate_models.py --check
906
- uv run ruff check .
907
- uv run ruff format --check .
908
- uv run basedpyright
909
- uv run pytest
910
- uv build
911
- ```
912
-
913
- The generator first verifies the contract lock, OpenAPI release metadata, and snapshot digest. It then
914
- creates a temporary OpenAPI 3.1 generation projection, resolves references without network access, and
915
- generates private component, operation-response, and parameter models. Generator emission uses its built-in formatter;
916
- the checked-in output is then normalized using the repository `pyproject.toml` as the sole Ruff configuration. The
917
- projection flattens pure
918
- object inheritance and preserves constraints the model generator cannot express as self-contained JSON Schema
919
- 2020-12 metadata; the checked-in release snapshot remains unchanged. `--check` performs the same validation and
920
- deterministic generation without writing the checked-in models. `WireModel` applies that metadata with the standard
921
- `jsonschema` Draft 2020-12 validator; the SDK does not maintain a second hand-written schema interpreter. The current
922
- lock resolves
923
- `datamodel-code-generator 0.71.0` and
924
- `hatchling 1.31.0`. Published metadata requires `httpx2>=2.9.1,<3`, `jsonschema>=4.26,<5`,
925
- `pydantic>=2.13.4,<3`, and `typing-extensions>=4.14.1,<5`; the build backend requires
926
- `hatchling>=1.27,<2`. These lower bounds are verified
927
- against the complete test suite on the supported Python boundary versions rather than inferred from
928
- `uv.lock`. The exact toolchain remains locked for development and release builds. Basedpyright and its
929
- Node wheel retain the compatible exact pair `basedpyright==1.39.9` and
930
- `nodejs-wheel-binaries==22.20.0`.
931
-
932
- ## Opt-in Provider consumer smoke
933
-
934
- `tests/provider/test_provider_smoke.py` exercises the consumer flow through only the public SDK interface. Its base
935
- scenario covers credential authentication, persistence of the complete returned Session, restoration through a new
936
- `AsyncMatelab` instance, refresh with identity preservation, and notebook discovery. It is not Provider Verification
937
- and is skipped by default.
938
-
939
- Raw Provider conformance remains the responsibility of `matelab-spec`, which sends direct HTTP requests and validates
940
- the unmodified responses. The SDK does not repeat its route-by-route, cross-account, sharing, or attachment-isolation
941
- verification. Representative SDK adapter tests instead feed the pinned OpenAPI's sanitized response examples through
942
- `MockTransport` and assert the resulting public values; synthetic fixtures remain where SDK-specific encoding,
943
- error, retry, and compatibility boundaries require evidence beyond those examples.
944
-
945
- Provider smoke is restricted to the confirmed isolated test service. Authentication and refresh persist Provider
946
- token state. This side effect is inherent to the tested Provider operations; it cannot be disabled by a test setting.
947
- Explicitly loading `.env.test` and selecting the `provider` marker is the opt-in for this flow.
948
-
949
- Copy `.env.example` to the git-ignored local `.env.test`, then fill in the shared Provider connection settings:
950
-
951
- - `MATELAB_PROVIDER_BASE_URL`
952
- - `MATELAB_PROVIDER_USERNAME`
953
- - `MATELAB_PROVIDER_PASSWORD`
954
-
955
- These names intentionally match `matelab-spec` Provider Verification. The isolated target may copy them from the spec
956
- `.env` into this repository's `.env.test`. Refreshing the restored Session must preserve its authenticated identity.
957
- With the environment prepared:
958
-
959
- ```bash
960
- uv run --env-file .env.test pytest -m provider tests/provider/test_provider_smoke.py
961
- ```
962
-
963
- This command runs only the SDK public-interface smoke; it is not the 71-operation Provider Verification. To reuse the
964
- same `.env.test` for the complete Contract suite, also populate the optional share user, secondary account, record
965
- staging opt-in, and Chat SSO settings documented in `.env.example`, then run from sibling checkouts:
966
-
967
- ```bash
968
- cd ../matelab-spec
969
- uv run --env-file ../matelab-python-sdk/.env.test pytest
970
- ```
971
-
972
- The files are never loaded implicitly, so normal test runs remain safely skipped. Do not use either flow against
973
- production, and never commit Provider credentials.
974
-
975
- ## Reproducible release build
976
-
977
- Build from a clean release commit (or its tag) and set the archive timestamp to that commit's
978
- committer timestamp. `pyproject.toml` declares the supported Hatchling range, while `uv.lock` supplies the
979
- exact version used by the frozen, no-build-isolation release environment:
980
-
981
- ```bash
982
- export SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)"
983
- uv sync --frozen
984
- uv run python scripts/generate_models.py --check
985
- uv build --no-build-isolation --out-dir dist/release
986
- uv run python scripts/check_release.py dist/release/*.whl dist/release/*.tar.gz
987
- (cd dist/release && sha256sum *.whl *.tar.gz > SHA256SUMS)
988
- ```
989
-
990
- Rebuilding the same commit with the same locked environment and `SOURCE_DATE_EPOCH` must produce
991
- byte-identical wheel and source distribution hashes. The SDK Contract pin is immutable `matelab-spec v2026.10.1`, commit
992
- `9941e2d623a1c2633adb459b823d78d4e62d3802`, and OpenAPI SHA-256
993
- `a07997128c1df17a03724c5af8e11b45a34166f9d6e15558e66e0a301f2e0813`.