matelab-python-sdk 0.1.0a1__py3-none-any.whl → 0.1.0a2__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.
matelab/__init__.py CHANGED
@@ -118,6 +118,7 @@ from matelab.records import (
118
118
  RecordFilesAttachmentAppend,
119
119
  RecordFilesAttachmentRemoval,
120
120
  RecordFilesAttachmentReplacement,
121
+ RecordFormAttachmentFieldAddition,
121
122
  RecordFormAttachmentRemoval,
122
123
  RecordImportItem,
123
124
  RecordImportResult,
@@ -150,6 +151,7 @@ from matelab.records import (
150
151
  RecordVersionRef,
151
152
  RecordVersionSummary,
152
153
  RelatedRecordIdentity,
154
+ StagedNotebookAttachment,
153
155
  StagedRecordAttachment,
154
156
  StagedRecordCommentAttachment,
155
157
  )
@@ -300,6 +302,7 @@ __all__ = [
300
302
  "RecordFilesAttachmentAppend",
301
303
  "RecordFilesAttachmentRemoval",
302
304
  "RecordFilesAttachmentReplacement",
305
+ "RecordFormAttachmentFieldAddition",
303
306
  "RecordFormAttachmentRemoval",
304
307
  "RecordImportItem",
305
308
  "RecordImportResult",
@@ -337,6 +340,7 @@ __all__ = [
337
340
  "SharedLiteratureLibraryRef",
338
341
  "StagedFile",
339
342
  "StagedFileFragment",
343
+ "StagedNotebookAttachment",
340
344
  "StagedPdfMetadataSource",
341
345
  "StagedRecordAttachment",
342
346
  "StagedRecordCommentAttachment",
@@ -7,7 +7,7 @@ from typing import Annotated, Any, Literal
7
7
 
8
8
  from pydantic import BaseModel, ConfigDict, Field, RootModel, SecretStr
9
9
 
10
- OPENAPI_INFO_VERSION = "0.1.0"
10
+ OPENAPI_INFO_VERSION = "0.1.1"
11
11
 
12
12
 
13
13
  class ResponseEnvelope(BaseModel):
@@ -108,9 +108,11 @@ class CloudDriveFile(BaseModel):
108
108
  id: Annotated[int, Field(ge=1)]
109
109
  filename: Annotated[str, Field(max_length=255, min_length=1)]
110
110
  hash: Sha256
111
- size: Annotated[str, Field(pattern="^[0-9]+$")]
111
+ size: Annotated[int, Field(ge=0)]
112
112
  """
113
- Unsigned BIGINT serialized as a decimal string by this query.
113
+ File size in bytes. The Provider fetches the unsigned BIGINT with
114
+ PDO::ATTR_STRINGIFY_FETCHES disabled and returns a JSON integer.
115
+
114
116
  """
115
117
  userid: Annotated[int, Field(ge=1)]
116
118
  date_time: Annotated[str | None, Field(pattern="^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$")]
@@ -263,19 +265,31 @@ class UploadAttachmentRequest(BaseModel):
263
265
  )
264
266
  eln: Annotated[str, Field(min_length=1)]
265
267
  """
266
- Notebook title used to select its data server.
268
+ Notebook title used to check write permission and select its data
269
+ server. The value is not persisted on the staging row.
270
+
267
271
  """
268
272
  user: Annotated[int | None, Field(ge=1)] = None
269
273
  """
270
- Optional notebook owner identifier for a non-owned notebook.
274
+ Optional notebook owner identifier for a non-owned notebook. It
275
+ selects the notebook but never replaces the authenticated uploader
276
+ as the staging owner.
277
+
271
278
  """
272
279
  uid: Annotated[str, Field(max_length=45, min_length=1)]
273
280
  """
274
- Record wire UID and upload-event identifier.
281
+ Caller-generated upload-event key stored in `upload_temp.data`.
282
+ It is independent of, and need not equal, the UID of a record later
283
+ created or updated with this staged file.
284
+
275
285
  """
276
286
  name: Annotated[str, Field(max_length=45)] = ""
277
287
  """
278
- Missing or empty falls back to uid as the unique temporary upload name.
288
+ Caller-generated staging name stored in `upload_temp.unique_str`.
289
+ Missing or empty falls back to `uid`. On success, use the response
290
+ `name` once as `#file{name}` in the same user's same-notebook
291
+ import or update workflow.
292
+
279
293
  """
280
294
  hash: Sha256
281
295
  last: Literal[1] = 1
@@ -303,6 +317,9 @@ class UploadAttachmentResponse(BaseModel):
303
317
  Provider-built legacy query string for the temporary file.
304
318
  """
305
319
  name: Annotated[str, Field(min_length=1)]
320
+ """
321
+ Resolved one-shot staging name used by `#file{name}`.
322
+ """
306
323
  filename: Annotated[str, Field(min_length=1)]
307
324
  size: Annotated[int, Field(ge=0)]
308
325
  hash: Sha256
matelab/records.py CHANGED
@@ -229,6 +229,7 @@ _NATIVE_ATTACHMENT_PATTERN = re.compile(r"#(?:hash|file|base64)\{", re.IGNORECAS
229
229
  _COMMENT_TEMP_URL_PATTERN = re.compile(r"https?://[^\"']+/eln_url/eln_temp\?", re.IGNORECASE)
230
230
  _DATABASE_UPDATE_MESSAGES = frozenset({"数据库数据更新成功", "Database data update successful"})
231
231
  _COLLABORATION_UPDATE_MESSAGES = frozenset({"协同编辑数据更新完成", "Collaboration data update completed"})
232
+ _StagedNotebookAttachmentIdentity = tuple[NotebookRef, int, str]
232
233
 
233
234
 
234
235
  @dataclass(frozen=True, slots=True)
@@ -329,7 +330,12 @@ class RecordImportResult:
329
330
  identity_count_status: Literal["one_id_per_item", "fewer_ids_than_items", "more_ids_than_items"] = "one_id_per_item"
330
331
  atomicity: Literal["provider_unspecified"] = "provider_unspecified"
331
332
  field_persistence: Literal["provider_not_verified"] = "provider_not_verified"
332
- known_limitations: tuple[Literal["PVD-023"], Literal["PVD-024"]] = ("PVD-023", "PVD-024")
333
+ staged_attachment_finalization: Literal["not_requested", "provider_acknowledged_non_atomic"] = "not_requested"
334
+ known_limitations: tuple[Literal["PVD-023"], Literal["PVD-024"], Literal["PVD-040"]] = (
335
+ "PVD-023",
336
+ "PVD-024",
337
+ "PVD-040",
338
+ )
333
339
 
334
340
 
335
341
  @dataclass(frozen=True, slots=True)
@@ -532,6 +538,27 @@ class StagedRecordAttachment:
532
538
  binding_state: Literal["staged_not_bound"] = "staged_not_bound"
533
539
 
534
540
 
541
+ @dataclass(frozen=True, slots=True)
542
+ class StagedNotebookAttachment:
543
+ """A one-shot record attachment stage scoped to its uploader and notebook.
544
+
545
+ Pass this object directly in template-shaped ``RecordImportItem.data`` or
546
+ in ``RecordFormAttachmentFieldAddition``. A finalization request is
547
+ non-idempotent: after the SDK starts one import or update request, discard
548
+ this object regardless of the outcome.
549
+ """
550
+
551
+ notebook: NotebookRef
552
+ uploader: UserRef
553
+ temporary_file_id: int
554
+ binding: UploadBindingRef
555
+ filename: str
556
+ size: int
557
+ sha256: str = field(repr=False)
558
+ binding_state: Literal["staged_not_bound"] = "staged_not_bound"
559
+ finalize_semantics: Literal["single_import_or_update_attempt"] = "single_import_or_update_attempt"
560
+
561
+
535
562
  @dataclass(frozen=True, slots=True)
536
563
  class RecordValueUpdate:
537
564
  path: tuple[str | int, ...]
@@ -570,6 +597,15 @@ class RecordValueAddition:
570
597
  path: tuple[str, ...] | None = None
571
598
 
572
599
 
600
+ @dataclass(frozen=True, slots=True)
601
+ class RecordFormAttachmentFieldAddition:
602
+ """Add one new file field and finalize one notebook-scoped staged attachment."""
603
+
604
+ module: str
605
+ name: str
606
+ attachment: StagedNotebookAttachment
607
+
608
+
573
609
  @dataclass(frozen=True, slots=True)
574
610
  class RecordFormAttachmentRemoval:
575
611
  attachment: RecordAttachmentRef
@@ -620,7 +656,8 @@ class RecordPatch:
620
656
  module_deletions: tuple[RecordModuleDeletion, ...] = ()
621
657
  value_additions: tuple[RecordValueAddition, ...] = ()
622
658
  attachment_changes: tuple[
623
- RecordFormAttachmentRemoval
659
+ RecordFormAttachmentFieldAddition
660
+ | RecordFormAttachmentRemoval
624
661
  | RecordTableAttachmentReplacement
625
662
  | RecordFilesAttachmentAppend
626
663
  | RecordFilesAttachmentReplacement
@@ -695,6 +732,7 @@ class RecordUpdateResult:
695
732
  "client_read_precondition_not_provider_cas"
696
733
  )
697
734
  retryable: Literal[False] = False
735
+ staged_attachment_finalization: Literal["not_requested", "provider_acknowledged_non_atomic"] = "not_requested"
698
736
 
699
737
 
700
738
  @dataclass(frozen=True, slots=True)
@@ -1039,6 +1077,7 @@ class Records:
1039
1077
 
1040
1078
  def __init__(self, transport: SessionTransport) -> None:
1041
1079
  self._transport: SessionTransport = transport
1080
+ self._staged_finalization_attempts: set[_StagedNotebookAttachmentIdentity] = set()
1042
1081
 
1043
1082
  async def list(
1044
1083
  self,
@@ -1121,6 +1160,7 @@ class Records:
1121
1160
  raise MatelabUsageError("Record import requires a valid template and one through one hundred items.")
1122
1161
  if len({item.record_uid for item in selected}) != len(selected):
1123
1162
  raise MatelabUsageError("Imported record UIDs must be distinct within the batch.")
1163
+ staged_attachments: set[_StagedNotebookAttachmentIdentity] = set()
1124
1164
  payload: dict[str, object] = {
1125
1165
  "eln": notebook_title,
1126
1166
  "template": template.title,
@@ -1128,7 +1168,11 @@ class Records:
1128
1168
  {
1129
1169
  "uid": item.record_uid,
1130
1170
  "title": item.title,
1131
- "data": dict(item.data),
1171
+ "data": self._render_import_data(
1172
+ notebook,
1173
+ item.data,
1174
+ staged_attachments,
1175
+ ),
1132
1176
  "keyword": item.keyword,
1133
1177
  "path": list(item.path) if item.path is not None else None,
1134
1178
  }
@@ -1137,11 +1181,16 @@ class Records:
1137
1181
  "user": notebook.owner_userid if notebook.scope == "shared" else None,
1138
1182
  "template_user": template.owner.userid if template.owner is not None else None,
1139
1183
  }
1184
+ if staged_attachments and len(selected) != 1:
1185
+ raise MatelabUsageError(
1186
+ "A staged notebook attachment can be finalized only by a single-record import request."
1187
+ )
1140
1188
  try:
1141
1189
  request = WireImportRecordsRequest.model_validate(payload)
1142
1190
  _ = json.dumps(request.model_dump(mode="json"), ensure_ascii=False)
1143
1191
  except (ValidationError, TypeError, ValueError):
1144
1192
  raise MatelabUsageError("Record import dataset does not satisfy the Integration Contract.") from None
1193
+ self._mark_staged_finalization_attempts(staged_attachments)
1145
1194
  response = await self._transport.request(_IMPORT_RECORDS, payload=request.model_dump(mode="json"))
1146
1195
  if response.eln_id != notebook.notebook_id:
1147
1196
  raise MatelabProtocolError("Matelab returned a different target notebook for the record import.")
@@ -1151,6 +1200,9 @@ class Records:
1151
1200
  requested_record_uids=tuple(item.record_uid for item in selected),
1152
1201
  created_record_database_ids=tuple(item.root for item in response.id),
1153
1202
  provider_notebook_id=response.eln_id,
1203
+ staged_attachment_finalization=(
1204
+ "provider_acknowledged_non_atomic" if staged_attachments else "not_requested"
1205
+ ),
1154
1206
  identity_count_status=(
1155
1207
  "one_id_per_item"
1156
1208
  if len(response.id) == len(selected)
@@ -1284,40 +1336,17 @@ class Records:
1284
1336
  binding: UploadBindingRef | None = None,
1285
1337
  content_type: str = "application/octet-stream",
1286
1338
  ) -> StagedRecordAttachment:
1287
- notebook_title = self._notebook_title(source.notebook)
1288
1339
  selected_binding = binding or UploadBindingRef.new()
1289
- self._validate_upload_metadata(
1290
- filename=filename, content=content, size=size, sha256=sha256, content_type=content_type
1291
- )
1292
- try:
1293
- request = WireUploadAttachmentRequest.model_validate(
1294
- {
1295
- "eln": notebook_title,
1296
- "user": source.notebook.owner_userid if source.notebook.scope == "shared" else None,
1297
- "uid": source.record.record_uid,
1298
- "name": selected_binding.uid,
1299
- "hash": sha256,
1300
- "last": 1,
1301
- "file": b"validation-placeholder",
1302
- }
1303
- )
1304
- except ValidationError:
1305
- raise MatelabUsageError("Record attachment upload does not satisfy the Integration Contract.") from None
1306
- response = await self._transport.request(
1307
- _UPLOAD_RECORD_ATTACHMENT,
1308
- payload=request.model_dump(mode="json", exclude={"file"}, exclude_none=True),
1309
- files={"file": (filename, content, content_type)},
1340
+ response = await self._upload_record_attachment(
1341
+ notebook=source.notebook,
1342
+ upload_event_uid=source.record.record_uid,
1343
+ binding=selected_binding,
1344
+ filename=filename,
1345
+ content=content,
1346
+ size=size,
1347
+ sha256=sha256,
1348
+ content_type=content_type,
1310
1349
  )
1311
- if (
1312
- response.hash.root != sha256
1313
- or response.size != size
1314
- or response.filename != filename
1315
- or response.name != selected_binding.uid
1316
- ):
1317
- raise MatelabProtocolError(
1318
- "Matelab staged the record attachment but returned different upload metadata; "
1319
- + "the temporary upload may remain."
1320
- )
1321
1350
  return StagedRecordAttachment(
1322
1351
  source=source,
1323
1352
  temporary_file_id=response.id,
@@ -1327,6 +1356,46 @@ class Records:
1327
1356
  sha256=response.hash.root,
1328
1357
  )
1329
1358
 
1359
+ async def stage_attachment(
1360
+ self,
1361
+ *,
1362
+ notebook: NotebookRef,
1363
+ filename: str,
1364
+ content: IO[bytes] | bytes,
1365
+ size: int,
1366
+ sha256: str,
1367
+ binding: UploadBindingRef | None = None,
1368
+ content_type: str = "application/octet-stream",
1369
+ ) -> StagedNotebookAttachment:
1370
+ """Stage an attachment before its target record exists.
1371
+
1372
+ The returned handle is restricted to the authenticated uploader and
1373
+ exact notebook selector. It can finalize one single-record import or
1374
+ one safe form-file-field update.
1375
+ """
1376
+
1377
+ uploader = self._authenticated_uploader()
1378
+ selected_binding = binding or UploadBindingRef.new()
1379
+ response = await self._upload_record_attachment(
1380
+ notebook=notebook,
1381
+ upload_event_uid=UploadBindingRef.new().uid,
1382
+ binding=selected_binding,
1383
+ filename=filename,
1384
+ content=content,
1385
+ size=size,
1386
+ sha256=sha256,
1387
+ content_type=content_type,
1388
+ )
1389
+ return StagedNotebookAttachment(
1390
+ notebook=notebook,
1391
+ uploader=uploader,
1392
+ temporary_file_id=response.id,
1393
+ binding=selected_binding,
1394
+ filename=response.filename,
1395
+ size=response.size,
1396
+ sha256=response.hash.root,
1397
+ )
1398
+
1330
1399
  async def update(
1331
1400
  self,
1332
1401
  source: RecordLocator,
@@ -1343,7 +1412,7 @@ class Records:
1343
1412
  raise MatelabUsageError(
1344
1413
  "Record content changed before the update; this client-side precondition is not Provider CAS."
1345
1414
  )
1346
- mutation_payload, operation_count = self._record_patch_payload(source, before, patch)
1415
+ mutation_payload, operation_count, staged_attachments = self._record_patch_payload(source, before, patch)
1347
1416
  try:
1348
1417
  request = WireUpdateRecordRequest.model_validate(
1349
1418
  {
@@ -1357,6 +1426,7 @@ class Records:
1357
1426
  _ = json.dumps(request.model_dump(mode="json", by_alias=True), ensure_ascii=False)
1358
1427
  except (ValidationError, TypeError, ValueError):
1359
1428
  raise MatelabUsageError("Record patch does not satisfy the Integration Contract.") from None
1429
+ self._mark_staged_finalization_attempts(staged_attachments)
1360
1430
  response = await self._transport.request(
1361
1431
  _UPDATE_RECORD, payload=request.model_dump(mode="json", by_alias=True, exclude_none=True)
1362
1432
  )
@@ -1370,6 +1440,9 @@ class Records:
1370
1440
  confirmation="pending_browser_save_not_read_back",
1371
1441
  requested_operation_count=operation_count,
1372
1442
  provider_message=response.msg,
1443
+ staged_attachment_finalization=(
1444
+ "provider_acknowledged_non_atomic" if staged_attachments else "not_requested"
1445
+ ),
1373
1446
  )
1374
1447
  after = await self._verified_read(source, "record update")
1375
1448
  return RecordUpdateResult(
@@ -1380,6 +1453,9 @@ class Records:
1380
1453
  confirmation="observed_changed" if after.content_sha256 != before.content_sha256 else "observed_unchanged",
1381
1454
  requested_operation_count=operation_count,
1382
1455
  provider_message=response.msg,
1456
+ staged_attachment_finalization=(
1457
+ "provider_acknowledged_non_atomic" if staged_attachments else "not_requested"
1458
+ ),
1383
1459
  )
1384
1460
 
1385
1461
  async def export(self, records: Sequence[RecordLocator]) -> RecordExport:
@@ -1979,7 +2055,7 @@ class Records:
1979
2055
 
1980
2056
  def _record_patch_payload(
1981
2057
  self, source: RecordLocator, before: Record, patch: RecordPatch
1982
- ) -> tuple[dict[str, object], int]:
2058
+ ) -> tuple[dict[str, object], int, set[_StagedNotebookAttachmentIdentity]]:
1983
2059
  modules: dict[str, Mapping[str, object]] = {}
1984
2060
  for module in before.modules:
1985
2061
  name = module.get("name")
@@ -1994,6 +2070,7 @@ class Records:
1994
2070
  add_values: list[dict[str, object]] = []
1995
2071
  claimed_paths: set[tuple[str | int, ...]] = set()
1996
2072
  used_staged: set[tuple[int, str]] = set()
2073
+ notebook_staged: set[_StagedNotebookAttachmentIdentity] = set()
1997
2074
 
1998
2075
  def claim(path: tuple[str | int, ...]) -> None:
1999
2076
  if path in claimed_paths:
@@ -2075,6 +2152,30 @@ class Records:
2075
2152
  )
2076
2153
 
2077
2154
  for change in patch.attachment_changes:
2155
+ if isinstance(change, RecordFormAttachmentFieldAddition):
2156
+ module = modules.get(change.module)
2157
+ if module is None or module.get("type") != "form":
2158
+ raise MatelabUsageError(
2159
+ "A staged notebook attachment can be finalized only into an existing form module."
2160
+ )
2161
+ if not change.name or self._module_has_field(module, change.name):
2162
+ raise MatelabUsageError("A staged notebook attachment requires a new non-empty form field name.")
2163
+ identity = self._claim_notebook_staged_attachment(
2164
+ source.notebook,
2165
+ change.attachment,
2166
+ notebook_staged,
2167
+ )
2168
+ notebook_staged.add(identity)
2169
+ add_values.append(
2170
+ {
2171
+ "module": change.module,
2172
+ "name": change.name,
2173
+ "type": "file",
2174
+ "data": [f"#file{{{change.attachment.binding.uid}}}"],
2175
+ }
2176
+ )
2177
+ continue
2178
+
2078
2179
  if isinstance(change, RecordFormAttachmentRemoval):
2079
2180
  current = self._current_attachment(source, before, change.attachment)
2080
2181
  location = current.location
@@ -2189,6 +2290,10 @@ class Records:
2189
2290
  operation_count = len(modify) + len(delete_values) + len(create_modules) + len(delete_modules) + len(add_values)
2190
2291
  if operation_count == 0:
2191
2292
  raise MatelabUsageError("Record patch must contain at least one mutation.")
2293
+ if notebook_staged and (len(notebook_staged) != 1 or operation_count != 1):
2294
+ raise MatelabUsageError(
2295
+ "A staged notebook attachment update must contain exactly one new form-file-field operation."
2296
+ )
2192
2297
  payload: dict[str, object] = {}
2193
2298
  if modify:
2194
2299
  payload["modify"] = modify
@@ -2200,7 +2305,7 @@ class Records:
2200
2305
  payload["delModule"] = delete_modules
2201
2306
  if add_values:
2202
2307
  payload["add"] = add_values
2203
- return payload, operation_count
2308
+ return payload, operation_count, notebook_staged
2204
2309
 
2205
2310
  @staticmethod
2206
2311
  def _module_type(name: str, modules: Mapping[str, Mapping[str, object]], created_types: Mapping[str, str]) -> str:
@@ -2224,6 +2329,15 @@ class Records:
2224
2329
  return cast(str, field_mapping["type"])
2225
2330
  return None
2226
2331
 
2332
+ @staticmethod
2333
+ def _module_has_field(module: Mapping[str, object], field_name: str) -> bool:
2334
+ if not isinstance(raw_fields := module.get("data"), list):
2335
+ return False
2336
+ for raw_field in cast(list[object], raw_fields):
2337
+ if isinstance(raw_field, dict) and cast(dict[str, object], raw_field).get("name") == field_name:
2338
+ return True
2339
+ return False
2340
+
2227
2341
  @classmethod
2228
2342
  def _validate_nonattachment_path(
2229
2343
  cls, path: tuple[str | int, ...], modules: Mapping[str, Mapping[str, object]]
@@ -2287,6 +2401,49 @@ class Records:
2287
2401
  raise MatelabUsageError("One staged record attachment can be consumed only once in a patch.")
2288
2402
  used.add(identity)
2289
2403
 
2404
+ def _claim_notebook_staged_attachment(
2405
+ self,
2406
+ notebook: NotebookRef,
2407
+ attachment: StagedNotebookAttachment,
2408
+ used: set[_StagedNotebookAttachmentIdentity],
2409
+ ) -> _StagedNotebookAttachmentIdentity:
2410
+ uploader = self._authenticated_uploader()
2411
+ if attachment.notebook != notebook:
2412
+ raise MatelabUsageError("Staged notebook attachment belongs to a different notebook.")
2413
+ if attachment.uploader != uploader:
2414
+ raise MatelabUsageError("Staged notebook attachment belongs to a different authenticated user.")
2415
+ if (
2416
+ attachment.temporary_file_id < 1
2417
+ or not attachment.binding.uid
2418
+ or len(attachment.binding.uid) > 45
2419
+ or not attachment.filename
2420
+ or "\r" in attachment.filename
2421
+ or "\n" in attachment.filename
2422
+ or attachment.size < 0
2423
+ or re.fullmatch(r"[0-9a-f]{64}", attachment.sha256) is None
2424
+ or attachment.binding_state != "staged_not_bound"
2425
+ or attachment.finalize_semantics != "single_import_or_update_attempt"
2426
+ ):
2427
+ raise MatelabUsageError("Staged notebook attachment metadata is invalid.")
2428
+ identity = (
2429
+ attachment.notebook,
2430
+ attachment.uploader.userid,
2431
+ attachment.binding.uid,
2432
+ )
2433
+ if identity in used:
2434
+ raise MatelabUsageError("One staged notebook attachment can appear only once in a finalization request.")
2435
+ return identity
2436
+
2437
+ def _mark_staged_finalization_attempts(
2438
+ self,
2439
+ attachments: set[_StagedNotebookAttachmentIdentity],
2440
+ ) -> None:
2441
+ if self._staged_finalization_attempts.intersection(attachments):
2442
+ raise MatelabUsageError(
2443
+ "This staged notebook attachment already had a finalization attempt; it cannot be retried."
2444
+ )
2445
+ self._staged_finalization_attempts.update(attachments)
2446
+
2290
2447
  @staticmethod
2291
2448
  def _native_attachment_ref(attachment: StagedRecordAttachment) -> str:
2292
2449
  if "\r" in attachment.filename or "\n" in attachment.filename:
@@ -2310,6 +2467,105 @@ class Records:
2310
2467
  raise MatelabUsageError("Rich-text content contains an unresolved attachment marker.")
2311
2468
  return rendered
2312
2469
 
2470
+ def _render_import_data(
2471
+ self,
2472
+ notebook: NotebookRef,
2473
+ data: Mapping[str, object],
2474
+ used_staged: set[_StagedNotebookAttachmentIdentity],
2475
+ ) -> dict[str, object]:
2476
+ return {name: self._render_import_value(notebook, value, used_staged) for name, value in data.items()}
2477
+
2478
+ def _render_import_value(
2479
+ self,
2480
+ notebook: NotebookRef,
2481
+ value: object,
2482
+ used_staged: set[_StagedNotebookAttachmentIdentity],
2483
+ ) -> object:
2484
+ if isinstance(value, StagedNotebookAttachment):
2485
+ identity = self._claim_notebook_staged_attachment(notebook, value, used_staged)
2486
+ used_staged.add(identity)
2487
+ return f"#file{{{value.binding.uid}}}"
2488
+ if isinstance(value, StagedRecordAttachment):
2489
+ raise MatelabUsageError(
2490
+ "A record-scoped staged attachment cannot be consumed by record import; "
2491
+ + "use stage_attachment before importing."
2492
+ )
2493
+ if isinstance(value, str):
2494
+ if _contains_native_attachment_reference(value):
2495
+ raise MatelabUsageError(
2496
+ "Provider-native attachment references require a staged notebook attachment object."
2497
+ )
2498
+ return value
2499
+ if isinstance(value, Mapping):
2500
+ return {
2501
+ key: self._render_import_value(notebook, item, used_staged)
2502
+ for key, item in cast(Mapping[object, object], value).items()
2503
+ }
2504
+ if isinstance(value, (list, tuple)):
2505
+ return [
2506
+ self._render_import_value(notebook, item, used_staged)
2507
+ for item in cast(list[object] | tuple[object, ...], value)
2508
+ ]
2509
+ return value
2510
+
2511
+ def _authenticated_uploader(self) -> UserRef:
2512
+ identity = self._transport.require_session().identity
2513
+ if identity is None:
2514
+ raise MatelabUsageError(
2515
+ "Notebook-scoped attachment staging requires a resolved session identity; "
2516
+ + "call client.resolve_identity() first."
2517
+ )
2518
+ if identity.userid < 1:
2519
+ raise MatelabUsageError("The resolved session identity has an invalid user identifier.")
2520
+ return UserRef(userid=identity.userid)
2521
+
2522
+ async def _upload_record_attachment(
2523
+ self,
2524
+ *,
2525
+ notebook: NotebookRef,
2526
+ upload_event_uid: str,
2527
+ binding: UploadBindingRef,
2528
+ filename: str,
2529
+ content: IO[bytes] | bytes,
2530
+ size: int,
2531
+ sha256: str,
2532
+ content_type: str,
2533
+ ) -> WireUploadAttachmentResponse:
2534
+ notebook_title = self._notebook_title(notebook)
2535
+ self._validate_upload_metadata(
2536
+ filename=filename, content=content, size=size, sha256=sha256, content_type=content_type
2537
+ )
2538
+ try:
2539
+ request = WireUploadAttachmentRequest.model_validate(
2540
+ {
2541
+ "eln": notebook_title,
2542
+ "user": notebook.owner_userid if notebook.scope == "shared" else None,
2543
+ "uid": upload_event_uid,
2544
+ "name": binding.uid,
2545
+ "hash": sha256,
2546
+ "last": 1,
2547
+ "file": b"validation-placeholder",
2548
+ }
2549
+ )
2550
+ except ValidationError:
2551
+ raise MatelabUsageError("Record attachment upload does not satisfy the Integration Contract.") from None
2552
+ response = await self._transport.request(
2553
+ _UPLOAD_RECORD_ATTACHMENT,
2554
+ payload=request.model_dump(mode="json", exclude={"file"}, exclude_none=True),
2555
+ files={"file": (filename, content, content_type)},
2556
+ )
2557
+ if (
2558
+ response.hash.root != sha256
2559
+ or response.size != size
2560
+ or response.filename != filename
2561
+ or response.name != binding.uid
2562
+ ):
2563
+ raise MatelabProtocolError(
2564
+ "Matelab staged the record attachment but returned different upload metadata; "
2565
+ + "the temporary upload may remain."
2566
+ )
2567
+ return response
2568
+
2313
2569
  @staticmethod
2314
2570
  def _validate_upload_metadata(
2315
2571
  *, filename: str, content: IO[bytes] | bytes, size: int, sha256: str, content_type: str
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matelab-python-sdk
3
- Version: 0.1.0a1
3
+ Version: 0.1.0a2
4
4
  Summary: Reusable async Python client for the Matelab Integration Contract
5
5
  Author-email: 朱天念 <zhutiannian@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -28,10 +28,10 @@ Description-Content-Type: text/markdown
28
28
 
29
29
  Reusable async Python client for the Matelab Integration Contract.
30
30
 
31
- The current alpha is `0.1.0a1`. `[project].version` in `pyproject.toml` is the sole SDK version source;
31
+ The current alpha is `0.1.0a2`. `[project].version` in `pyproject.toml` is the sole SDK version source;
32
32
  `uv.lock` only mirrors that source.
33
33
 
34
- The SDK is pinned to the immutable `matelab-spec v0.1.0` Contract Release. The sole release pin is
34
+ The SDK is pinned to the immutable `matelab-spec v0.1.1` Contract Release. The sole release pin is
35
35
  `contracts/matelab-integration-v1.lock.json`, which records
36
36
  the source tag, commit, OpenAPI path, local snapshot path, and SHA-256.
37
37
 
@@ -57,7 +57,7 @@ To test the same artifact a downstream Consumer will install, build and install
57
57
 
58
58
  ```bash
59
59
  uv build --no-build-isolation --out-dir dist/release
60
- python -m pip install dist/release/matelab_python_sdk-0.1.0a1-py3-none-any.whl
60
+ python -m pip install dist/release/matelab_python_sdk-0.1.0a2-py3-none-any.whl
61
61
  ```
62
62
 
63
63
  Do not infer Provider compatibility from the SDK version alone. A release is also bound to the Contract
@@ -164,10 +164,70 @@ lifecycle operations, record discovery/lifecycle operations, comment reads, and
164
164
  comment attachment downloads, resumable file staging, literature discovery/lifecycle workflows, and
165
165
  personal cloud-drive management.
166
166
 
167
+ ### Staging a record attachment before record creation
168
+
169
+ `records.stage_attachment` supports the Contract's pre-upload workflow without inventing a target record UID.
170
+ The returned `StagedNotebookAttachment` is scoped by the SDK to the resolved authenticated user and the exact
171
+ notebook selector used for upload:
172
+
173
+ ```python
174
+ import hashlib
175
+
176
+ from matelab import RecordImportItem, RecordImportTemplate, TemplateRef
177
+
178
+ content = b"measurement data"
179
+ staged = await client.records.stage_attachment(
180
+ notebook=notebook,
181
+ filename="measurement.csv",
182
+ content=content,
183
+ size=len(content),
184
+ sha256=hashlib.sha256(content).hexdigest(),
185
+ )
186
+ result = await client.records.import_dataset(
187
+ notebook=notebook,
188
+ template=RecordImportTemplate(template=TemplateRef(template_id=8), title="Example Template"),
189
+ items=(
190
+ RecordImportItem(
191
+ record_uid="REC-IMPORT-001",
192
+ title="Imported measurement",
193
+ data={"Attachments": {"File": [staged]}},
194
+ ),
195
+ ),
196
+ )
197
+ ```
198
+
199
+ The same staged handle may instead be consumed by one safe update that adds a new file field to an existing form
200
+ module:
201
+
202
+ ```python
203
+ from matelab import RecordFormAttachmentFieldAddition, RecordPatch
204
+
205
+ # Alternative to the import above; do not run both with the same staged handle.
206
+ result = await client.records.update(
207
+ source,
208
+ RecordPatch(
209
+ attachment_changes=(
210
+ RecordFormAttachmentFieldAddition(
211
+ module="Attachments",
212
+ name="Measurement",
213
+ attachment=staged,
214
+ ),
215
+ )
216
+ ),
217
+ )
218
+ ```
219
+
220
+ Choose exactly one finalizer. A staged name may occur once in either a single-record import or one update
221
+ operation; do not reuse it, even after an error whose Provider outcome is unknown. The SDK rejects raw Provider
222
+ attachment references, cross-user or cross-notebook handles, unsafe update shapes, duplicate use in one request,
223
+ and a second finalization attempt through the same client. A Session imported without identity must first call
224
+ `await client.resolve_identity()`. The Provider supplies no staging status, abort, TTL, atomicity, or retry
225
+ guarantee, so callers must discard the handle as soon as a finalization request starts.
226
+
167
227
  ## Implementation roadmap
168
228
 
169
229
  `docs/roadmap.md` is the complete SDK-only execution plan. It assigns all 71
170
- `matelab-spec v0.1.0` operations to ordered work packages, defines the machine-readable coverage that
230
+ `matelab-spec v0.1.1` operations to ordered work packages, defines the machine-readable coverage that
171
231
  must be added, records Provider-risk gates, and specifies the final completion checks.
172
232
 
173
233
  The SDK exposes all 71 Contract operations through public domain interfaces. It deliberately excludes MCP
@@ -533,6 +593,6 @@ uv run python scripts/check_release.py dist/release/*.whl dist/release/*.tar.gz
533
593
  ```
534
594
 
535
595
  Rebuilding the same commit with the same locked environment and `SOURCE_DATE_EPOCH` must produce
536
- byte-identical wheel and source distribution hashes. The release is bound to `matelab-spec v0.1.0`,
537
- commit `236795db1aa7a784ad9df936e9547e8d529be53f`, and OpenAPI SHA-256
538
- `62ca07dcd7a0b3f80eb381a0d1447316f69faa751bafb8ab7c6a751e619d8164`.
596
+ byte-identical wheel and source distribution hashes. The release is bound to `matelab-spec v0.1.1`,
597
+ commit `047746ad37d827a85f93d947942f1e5fab80d54c`, and OpenAPI SHA-256
598
+ `1d437b071968d2df165c712092fba4a283832e0ac19bc791e0d5af82294d1cca`.
@@ -1,4 +1,4 @@
1
- matelab/__init__.py,sha256=3iX8xyk434Yh8j_tsinsgW1bnMwgrv_dqGQEIspSFf8,9741
1
+ matelab/__init__.py,sha256=lPCMdUftURTOuBw1XCHzHaSweIGZCq2ugqyPMe-R_vU,9883
2
2
  matelab/_transport.py,sha256=qMaioZcP5RfIxl5rr2FzY2Gn_zHAa-vJbjDNxapii-4,20223
3
3
  matelab/client.py,sha256=U54F-s8APVXppCjaMo-ALcQQCFM-VOzWoB_taeCdhXM,8168
4
4
  matelab/cloud_drive.py,sha256=NDVobVI-3MDRbbN_M6uRgM4apI6RUglk3ZAEdeaP90g,33306
@@ -8,15 +8,15 @@ matelab/literature.py,sha256=uMAXqDowj5JwSMed6b3koGHZBHF22GVqExO1kv2fmCY,51970
8
8
  matelab/models.py,sha256=AuIlyERpIVUYF2DgIhtC7LsmG4hLpHz28ae4hvyrEik,655
9
9
  matelab/notebooks.py,sha256=ykmpvziHGMtYt9vrMe97NSwcVTHsc4FRBStLNAhyDvc,19154
10
10
  matelab/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
11
- matelab/records.py,sha256=9HiFdu9c8VqlbgSYhayOOXxZBSZeOMMHbkNm0a0-Xd4,107223
11
+ matelab/records.py,sha256=KgvghcQ8oRR5p0pywMX3vVNJlqUw9WwDOPd9RWfHUl0,117926
12
12
  matelab/streaming.py,sha256=KcRx8QiBpaH198OY3YKdIFyx3bAikNf1sGHXXUKvoU4,3056
13
13
  matelab/templates.py,sha256=YpnCZ6WIMI7JEPYfdKNIzfuCMA9iwQx2qA473AvWpfk,31584
14
14
  matelab/uploads.py,sha256=_H6WdE_hvPdJeWdRdeh-0g9pm8EdxdcKKsv6hPMxObc,10182
15
15
  matelab/users.py,sha256=oU-FU0RNyNaSu6MBEL_2uP627AI7ZZWzQM7irwyJ1WY,2528
16
16
  matelab/_generated/__init__.py,sha256=jmGbXLLe3JorsGU7OUdF8iKGewFMRqkCzgyFAw7C15w,79
17
- matelab/_generated/models.py,sha256=uAsP0DYFGcanOxhn5-ISEXkoi_MDHhJylqwow-4MOBc,92551
18
- matelab_python_sdk-0.1.0a1.dist-info/METADATA,sha256=YMcK5wX333aTqUf4QrNsRiUUrUHctCe79BJCHKjSjAQ,27968
19
- matelab_python_sdk-0.1.0a1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
20
- matelab_python_sdk-0.1.0a1.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
21
- matelab_python_sdk-0.1.0a1.dist-info/licenses/NOTICE,sha256=Hc4scCJTof5VL3JCPI2aaewnnzCejIHUGuBVYBf-klc,98
22
- matelab_python_sdk-0.1.0a1.dist-info/RECORD,,
17
+ matelab/_generated/models.py,sha256=xXrIGbmgsyXjDO7RxSCMci2MkjB6azu6frrGOtIaJ2Q,93166
18
+ matelab_python_sdk-0.1.0a2.dist-info/METADATA,sha256=G7r1hU4Zp_eaFB4a_ETF9F2cepBFxzFD6Qi1oBEz780,30183
19
+ matelab_python_sdk-0.1.0a2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
20
+ matelab_python_sdk-0.1.0a2.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
21
+ matelab_python_sdk-0.1.0a2.dist-info/licenses/NOTICE,sha256=Hc4scCJTof5VL3JCPI2aaewnnzCejIHUGuBVYBf-klc,98
22
+ matelab_python_sdk-0.1.0a2.dist-info/RECORD,,