matelab-python-sdk 0.1.0a1__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/records.py ADDED
@@ -0,0 +1,2511 @@
1
+ import hashlib
2
+ import json
3
+ import re
4
+ from collections.abc import Mapping, Sequence
5
+ from dataclasses import dataclass, field
6
+ from html.parser import HTMLParser
7
+ from types import MappingProxyType
8
+ from typing import IO, Literal, cast
9
+ from urllib.parse import parse_qs, unquote
10
+
11
+ from pydantic import ValidationError
12
+
13
+ from matelab._generated.models import AddRelationRequest as WireAddRelationRequest
14
+ from matelab._generated.models import BasicSuccessResponse as WireBasicSuccessResponse
15
+ from matelab._generated.models import Code as WireSuccessCode
16
+ from matelab._generated.models import CommentListResponse as WireCommentListResponse
17
+ from matelab._generated.models import CopyRecordRequest as WireCopyRecordRequest
18
+ from matelab._generated.models import CopyRecordResponse as WireCopyRecordResponse
19
+ from matelab._generated.models import CreateRecordRequest as WireCreateRecordRequest
20
+ from matelab._generated.models import Default as WirePageDefault
21
+ from matelab._generated.models import DeletedRecordPageResponse as WireDeletedRecordPageResponse
22
+ from matelab._generated.models import DeleteRecordCommentRequest as WireDeleteRecordCommentRequest
23
+ from matelab._generated.models import DeleteRecordsRequest as WireDeleteRecordsRequest
24
+ from matelab._generated.models import DeleteRelationRequest as WireDeleteRelationRequest
25
+ from matelab._generated.models import DownloadRecordAttachmentParametersQuery as WireDownloadRecordAttachmentQuery
26
+ from matelab._generated.models import (
27
+ DownloadRecordCommentAttachmentParametersQuery as WireDownloadRecordCommentAttachmentQuery,
28
+ )
29
+ from matelab._generated.models import ExportRecordsRequest as WireExportRecordsRequest
30
+ from matelab._generated.models import ExportRecordsResponse as WireExportRecordsResponse
31
+ from matelab._generated.models import ForwardableSuccessResponse as WireForwardableSuccessResponse
32
+ from matelab._generated.models import ImportRecordsRequest as WireImportRecordsRequest
33
+ from matelab._generated.models import ImportRecordsResponse as WireImportRecordsResponse
34
+ from matelab._generated.models import ItemViewResponse as WireItemViewResponse
35
+ from matelab._generated.models import ListDeletedRecordPageParametersQuery as WireDeletedRecordPageQuery
36
+ from matelab._generated.models import ListNotebookRecordPageParametersQuery as WireRecordPageQuery
37
+ from matelab._generated.models import ListRecordCommentsParametersQuery as WireRecordCommentsQuery
38
+ from matelab._generated.models import ListRecordRelationsParametersQuery as WireRecordRelationsQuery
39
+ from matelab._generated.models import ListRecordsRequest as WireListRecordsRequest
40
+ from matelab._generated.models import OrderDesc as WireOrderDesc
41
+ from matelab._generated.models import PublicRecordListRequest as WirePublicRecordListRequest
42
+ from matelab._generated.models import PublicRecordListResponse as WirePublicRecordListResponse
43
+ from matelab._generated.models import ReadRecordParametersQuery as WireReadRecordQuery
44
+ from matelab._generated.models import RecordComment as WireRecordComment
45
+ from matelab._generated.models import RecordListItem as WireRecordListItem
46
+ from matelab._generated.models import RecordListResponse as WireRecordListResponse
47
+ from matelab._generated.models import RecordPageResponse as WireRecordPageResponse
48
+ from matelab._generated.models import RecordSubtype as WireRecordSubtype
49
+ from matelab._generated.models import RelationListResponse as WireRelationListResponse
50
+ from matelab._generated.models import RestoreRecordsRequest as WireRestoreRecordsRequest
51
+ from matelab._generated.models import SaveRecordCommentRequest as WireSaveRecordCommentRequest
52
+ from matelab._generated.models import SearchRecordsRequest as WireSearchRecordsRequest
53
+ from matelab._generated.models import SearchRecordsResponse as WireSearchRecordsResponse
54
+ from matelab._generated.models import UpdateRecordRequest as WireUpdateRecordRequest
55
+ from matelab._generated.models import UploadAttachmentRequest as WireUploadAttachmentRequest
56
+ from matelab._generated.models import UploadAttachmentResponse as WireUploadAttachmentResponse
57
+ from matelab._generated.models import UploadRecordCommentAttachmentRequest as WireUploadRecordCommentAttachmentRequest
58
+ from matelab._generated.models import UploadRecordCommentAttachmentResponse as WireUploadRecordCommentAttachmentResponse
59
+ from matelab._transport import Encoding, Operation, SessionTransport, StreamOperation, multipart_fields
60
+ from matelab.errors import MatelabError, MatelabProtocolError, MatelabUsageError, MatelabVerificationError
61
+ from matelab.notebooks import NotebookRef, PublicNotebookRef
62
+ from matelab.streaming import ByteRange, DownloadStream
63
+ from matelab.templates import TemplateRef
64
+ from matelab.uploads import UploadBindingRef
65
+ from matelab.users import UserRef
66
+
67
+ _LIST_RECORDS = Operation(
68
+ method="POST",
69
+ path="/eln_api/items",
70
+ encoding=Encoding.JSON,
71
+ response_model=WireRecordListResponse,
72
+ success_codes=frozenset({0, 10}),
73
+ retry_on_access_expired=True,
74
+ )
75
+ _CREATE_BLANK_RECORD = Operation(
76
+ method="POST",
77
+ path="/eln_api/create",
78
+ encoding=Encoding.JSON,
79
+ response_model=WireBasicSuccessResponse,
80
+ success_codes=frozenset({0}),
81
+ )
82
+ _IMPORT_RECORDS = Operation(
83
+ method="POST",
84
+ path="/eln_api/import",
85
+ encoding=Encoding.JSON,
86
+ response_model=WireImportRecordsResponse,
87
+ success_codes=frozenset({0}),
88
+ )
89
+ _DELETE_RECORDS = Operation(
90
+ method="POST",
91
+ path="/eln_api/delete",
92
+ encoding=Encoding.JSON,
93
+ response_model=WireBasicSuccessResponse,
94
+ success_codes=frozenset({0}),
95
+ )
96
+ _UPDATE_RECORD = Operation(
97
+ method="POST",
98
+ path="/eln_api/update",
99
+ encoding=Encoding.JSON,
100
+ response_model=WireBasicSuccessResponse,
101
+ success_codes=frozenset({0}),
102
+ )
103
+ _UPLOAD_RECORD_ATTACHMENT = Operation(
104
+ method="POST",
105
+ path="/eln_api/upload",
106
+ encoding=Encoding.MULTIPART,
107
+ response_model=WireUploadAttachmentResponse,
108
+ success_codes=frozenset({0}),
109
+ )
110
+ _READ_RECORD = Operation(
111
+ method="GET",
112
+ path="/eln_items/item_view",
113
+ encoding=Encoding.QUERY,
114
+ response_model=WireItemViewResponse,
115
+ success_codes=frozenset({0}),
116
+ retry_on_access_expired=True,
117
+ )
118
+ _EXPORT_RECORDS = Operation(
119
+ method="POST",
120
+ path="/eln_api/export",
121
+ encoding=Encoding.JSON,
122
+ response_model=WireExportRecordsResponse,
123
+ success_codes=frozenset({0, 10}),
124
+ retry_on_access_expired=True,
125
+ )
126
+ _SEARCH_RECORDS = Operation(
127
+ method="POST",
128
+ path="/eln_api/search",
129
+ encoding=Encoding.JSON,
130
+ response_model=WireSearchRecordsResponse,
131
+ success_codes=frozenset({0, 10}),
132
+ retry_on_access_expired=True,
133
+ )
134
+ _LIST_PUBLIC_RECORDS = Operation(
135
+ method="POST",
136
+ path="/eln_api/public_items",
137
+ encoding=Encoding.JSON,
138
+ response_model=WirePublicRecordListResponse,
139
+ success_codes=frozenset({0}),
140
+ retry_on_access_expired=True,
141
+ )
142
+ _LIST_RECORD_PAGE = Operation(
143
+ method="GET",
144
+ path="/eln_items/items",
145
+ encoding=Encoding.QUERY,
146
+ response_model=WireRecordPageResponse,
147
+ success_codes=frozenset({0}),
148
+ retry_on_access_expired=True,
149
+ )
150
+ _LIST_DELETED_RECORDS = Operation(
151
+ method="GET",
152
+ path="/eln_items/items_recycle",
153
+ encoding=Encoding.QUERY,
154
+ response_model=WireDeletedRecordPageResponse,
155
+ success_codes=frozenset({0, 10}),
156
+ retry_on_access_expired=True,
157
+ )
158
+ _LIST_RELATIONS = Operation(
159
+ method="GET",
160
+ path="/eln_items/related_list",
161
+ encoding=Encoding.QUERY,
162
+ response_model=WireRelationListResponse,
163
+ success_codes=frozenset({0}),
164
+ retry_on_access_expired=True,
165
+ )
166
+ _ADD_RELATION = Operation(
167
+ method="POST",
168
+ path="/eln_items/related_add",
169
+ encoding=Encoding.JSON,
170
+ response_model=WireForwardableSuccessResponse,
171
+ success_codes=frozenset({0, 10}),
172
+ )
173
+ _DELETE_RELATION = Operation(
174
+ method="POST",
175
+ path="/eln_items/related_del",
176
+ encoding=Encoding.JSON,
177
+ response_model=WireForwardableSuccessResponse,
178
+ success_codes=frozenset({0, 10}),
179
+ )
180
+ _RESTORE_RECORDS = Operation(
181
+ method="POST",
182
+ path="/eln_items/restore",
183
+ encoding=Encoding.JSON,
184
+ response_model=WireBasicSuccessResponse,
185
+ success_codes=frozenset({0}),
186
+ )
187
+ _COPY_RECORD = Operation(
188
+ method="POST",
189
+ path="/eln_items/copy",
190
+ encoding=Encoding.JSON,
191
+ response_model=WireCopyRecordResponse,
192
+ success_codes=frozenset({0, 10}),
193
+ )
194
+ _LIST_COMMENTS = Operation(
195
+ method="GET",
196
+ path="/eln_items/mores",
197
+ encoding=Encoding.QUERY,
198
+ response_model=WireCommentListResponse,
199
+ success_codes=frozenset({0}),
200
+ retry_on_access_expired=True,
201
+ )
202
+ _SAVE_COMMENT = Operation(
203
+ method="POST",
204
+ path="/eln_items/more",
205
+ encoding=Encoding.MULTIPART,
206
+ response_model=WireForwardableSuccessResponse,
207
+ success_codes=frozenset({0, 10}),
208
+ )
209
+ _UPLOAD_COMMENT_ATTACHMENT = Operation(
210
+ method="POST",
211
+ path="/eln_items/more_upload",
212
+ encoding=Encoding.MULTIPART,
213
+ response_model=WireUploadRecordCommentAttachmentResponse,
214
+ success_codes=frozenset({0}),
215
+ )
216
+ _DELETE_COMMENT = Operation(
217
+ method="POST",
218
+ path="/eln_items/more_del",
219
+ encoding=Encoding.MULTIPART,
220
+ response_model=WireForwardableSuccessResponse,
221
+ success_codes=frozenset({0, 10}),
222
+ )
223
+ _DOWNLOAD_RECORD_ATTACHMENT = StreamOperation(method="GET", path="/eln_url/eln", retry_on_access_expired=True)
224
+ _DOWNLOAD_COMMENT_ATTACHMENT = StreamOperation(method="GET", path="/eln_url/eln_more", retry_on_access_expired=True)
225
+ _SEARCH_RESERVED_FIELDS = frozenset({"id", "eln_id", "uid", "title", "eln_name"})
226
+ _ELNURL_PATTERN = re.compile(r"^elnurl://h=([a-f0-9]{64})&s=([0-9]+)&f=(.+)$", re.IGNORECASE)
227
+ _ATTACHMENT_MARKER_PATTERN = re.compile(r"matelab-attachment:([A-Za-z0-9_.-]{1,64})")
228
+ _NATIVE_ATTACHMENT_PATTERN = re.compile(r"#(?:hash|file|base64)\{", re.IGNORECASE)
229
+ _COMMENT_TEMP_URL_PATTERN = re.compile(r"https?://[^\"']+/eln_url/eln_temp\?", re.IGNORECASE)
230
+ _DATABASE_UPDATE_MESSAGES = frozenset({"数据库数据更新成功", "Database data update successful"})
231
+ _COLLABORATION_UPDATE_MESSAGES = frozenset({"协同编辑数据更新完成", "Collaboration data update completed"})
232
+
233
+
234
+ @dataclass(frozen=True, slots=True)
235
+ class RecordRef:
236
+ """Record identity with distinct database and wire identifiers."""
237
+
238
+ record_database_id: int
239
+ record_uid: str
240
+
241
+
242
+ @dataclass(frozen=True, slots=True)
243
+ class RecordLocator:
244
+ notebook: NotebookRef
245
+ record: RecordRef
246
+
247
+
248
+ @dataclass(frozen=True, slots=True)
249
+ class DeletedRecordRef:
250
+ record_database_id: int
251
+ record_uid: str
252
+
253
+
254
+ @dataclass(frozen=True, slots=True)
255
+ class RecordVersionRef:
256
+ """Historical version identity derived from an authorized current record read."""
257
+
258
+ notebook_id: int
259
+ record_database_id: int
260
+ record_uid: str
261
+ version_id: int
262
+
263
+
264
+ @dataclass(frozen=True, slots=True)
265
+ class RecordSummary:
266
+ ref: RecordRef
267
+ title: str
268
+ summary: str | None
269
+ created_at: str
270
+ modified_at: str
271
+ keywords: tuple[str, ...]
272
+ template_id: int | None
273
+ subtype_id: int | None
274
+ encrypted: bool
275
+ audit_count: int | None
276
+
277
+
278
+ @dataclass(frozen=True, slots=True)
279
+ class RecordSubtypeSummary:
280
+ subtype_id: int
281
+ title: str
282
+ parent_subtype_id: int
283
+ record_count: int | None
284
+ child_subtype_count: int | None
285
+
286
+
287
+ @dataclass(frozen=True, slots=True)
288
+ class RecordCollection:
289
+ records: tuple[RecordSummary, ...]
290
+ subtypes: tuple[RecordSubtypeSummary, ...]
291
+ trash_count: int
292
+
293
+
294
+ @dataclass(frozen=True, slots=True)
295
+ class BlankRecordCreateResult:
296
+ notebook: NotebookRef
297
+ title: str
298
+ requested_record_uid: str | None
299
+ observed: RecordSummary | None
300
+ confirmation: Literal[
301
+ "observed_by_requested_uid", "not_observed_by_requested_uid", "provider_generated_identity_not_returned"
302
+ ]
303
+
304
+
305
+ @dataclass(frozen=True, slots=True)
306
+ class RecordImportTemplate:
307
+ template: TemplateRef
308
+ title: str
309
+ owner: UserRef | None = None
310
+
311
+
312
+ @dataclass(frozen=True, slots=True)
313
+ class RecordImportItem:
314
+ record_uid: str
315
+ title: str
316
+ data: Mapping[str, object]
317
+ keyword: str | None = None
318
+ path: tuple[str, ...] | None = None
319
+
320
+
321
+ @dataclass(frozen=True, slots=True)
322
+ class RecordImportResult:
323
+ notebook: NotebookRef
324
+ template: RecordImportTemplate
325
+ requested_record_uids: tuple[str, ...]
326
+ created_record_database_ids: tuple[int, ...]
327
+ provider_notebook_id: int
328
+ per_item_mapping: None = None
329
+ identity_count_status: Literal["one_id_per_item", "fewer_ids_than_items", "more_ids_than_items"] = "one_id_per_item"
330
+ atomicity: Literal["provider_unspecified"] = "provider_unspecified"
331
+ field_persistence: Literal["provider_not_verified"] = "provider_not_verified"
332
+ known_limitations: tuple[Literal["PVD-023"], Literal["PVD-024"]] = ("PVD-023", "PVD-024")
333
+
334
+
335
+ @dataclass(frozen=True, slots=True)
336
+ class RecordCopyResult:
337
+ source: RecordLocator
338
+ target_notebook: NotebookRef
339
+ record_database_id: int
340
+ record: RecordRef | None
341
+ observed: RecordSummary | None
342
+ forwarded: bool
343
+ confirmation: Literal["observed_by_returned_id", "returned_id_not_observed"]
344
+
345
+
346
+ @dataclass(frozen=True, slots=True)
347
+ class RecordDeleteResult:
348
+ notebook: NotebookRef
349
+ requested: tuple[RecordRef, ...]
350
+ no_longer_active: tuple[RecordRef, ...]
351
+ still_active: tuple[RecordRef, ...]
352
+ observed_in_recycle_bin: tuple[DeletedRecordRef, ...]
353
+ unresolved: tuple[RecordRef, ...]
354
+ operation_semantics: Literal["move_to_recycle_bin"] = "move_to_recycle_bin"
355
+ observation_scope: Literal["active_list_and_first_256_deleted_rows"] = "active_list_and_first_256_deleted_rows"
356
+
357
+
358
+ @dataclass(frozen=True, slots=True)
359
+ class RecordRestoreResult:
360
+ notebook: NotebookRef
361
+ requested: tuple[DeletedRecordRef, ...]
362
+ observed_active: tuple[RecordRef, ...]
363
+ still_deleted: tuple[DeletedRecordRef, ...]
364
+ unresolved: tuple[DeletedRecordRef, ...]
365
+ provider_per_item_results_available: Literal[False] = False
366
+ observation_scope: Literal["active_list_and_first_256_deleted_rows"] = "active_list_and_first_256_deleted_rows"
367
+
368
+
369
+ @dataclass(frozen=True, slots=True)
370
+ class ExportedRecord:
371
+ ref: RecordRef
372
+ notebook_title: str | None
373
+ title: str
374
+ summary: str | None
375
+ data: tuple[object, ...]
376
+
377
+
378
+ @dataclass(frozen=True, slots=True)
379
+ class RecordExport:
380
+ records: tuple[ExportedRecord, ...]
381
+ forwarded: bool
382
+
383
+
384
+ @dataclass(frozen=True, slots=True)
385
+ class RecordFieldExtraction:
386
+ alias: str
387
+ path: tuple[str | int, ...]
388
+
389
+
390
+ @dataclass(frozen=True, slots=True)
391
+ class RecordSearchMatch:
392
+ ref: RecordRef
393
+ notebook_id: int
394
+ notebook_title: str
395
+ title: str
396
+ extracted: Mapping[str, object]
397
+ missing_extractions: tuple[str, ...]
398
+
399
+
400
+ @dataclass(frozen=True, slots=True)
401
+ class RecordSearchResult:
402
+ matches: tuple[RecordSearchMatch, ...]
403
+ forwarded: bool
404
+ ordering: Literal["provider_unspecified"] = "provider_unspecified"
405
+
406
+
407
+ @dataclass(frozen=True, slots=True)
408
+ class PublicRecordRef:
409
+ public_entry_id: int
410
+ source_notebook_id: int
411
+ source_record_database_id: int
412
+ record_uid: str | None
413
+
414
+
415
+ @dataclass(frozen=True, slots=True)
416
+ class PublicRecordSummary:
417
+ ref: PublicRecordRef
418
+ title: str | None
419
+ summary: str | None
420
+ locked: bool
421
+ source_notebook_title: str | None
422
+ owner: UserRef | None
423
+ owner_name: str | None
424
+ owner_email: str | None
425
+ owned_by_caller: bool
426
+
427
+
428
+ @dataclass(frozen=True, slots=True)
429
+ class PublicRecordCollection:
430
+ notebook: PublicNotebookRef
431
+ records: tuple[PublicRecordSummary, ...]
432
+ ordering: Literal["provider_unspecified"] = "provider_unspecified"
433
+
434
+
435
+ @dataclass(frozen=True, slots=True)
436
+ class RecordPagePermissions:
437
+ editable: bool
438
+ can_create: bool
439
+ can_delete: bool
440
+ can_manage_subtypes: bool
441
+ can_read_audit: bool
442
+ can_manage_users: bool
443
+ can_sign: bool
444
+
445
+
446
+ @dataclass(frozen=True, slots=True)
447
+ class RecordPageContext:
448
+ notebook: NotebookRef
449
+ notebook_title: str | None
450
+ owner_name: str | None
451
+ template_id: int | None
452
+ data_server: str
453
+ permissions: RecordPagePermissions
454
+ effective_page_size: int
455
+
456
+
457
+ @dataclass(frozen=True, slots=True)
458
+ class RecordPage:
459
+ records: tuple[RecordSummary, ...]
460
+ subtypes: tuple[RecordSubtypeSummary, ...]
461
+ context: RecordPageContext
462
+ page: int
463
+ total_count: int
464
+ matching_record_database_ids: tuple[int, ...]
465
+ trash_count: int
466
+ has_more: bool
467
+ keywords: tuple[object, ...]
468
+ has_more_basis: Literal["derived_from_matching_ids"] = "derived_from_matching_ids"
469
+ ordering: Literal["provider_configured_unstable"] = "provider_configured_unstable"
470
+
471
+
472
+ @dataclass(frozen=True, slots=True)
473
+ class DeletedRecordSummary:
474
+ ref: DeletedRecordRef
475
+ title: str
476
+ summary: str | None
477
+ created_at: str
478
+ modified_at: str
479
+ deleted_at: str | None
480
+
481
+
482
+ @dataclass(frozen=True, slots=True)
483
+ class DeletedRecordPage:
484
+ notebook: NotebookRef
485
+ records: tuple[DeletedRecordSummary, ...]
486
+ page: int
487
+ requested_page_size: int
488
+ total_count: int
489
+ has_more: bool
490
+ forwarded: bool
491
+ has_more_basis: Literal["derived_from_total"] = "derived_from_total"
492
+ ordering: Literal["provider_unspecified"] = "provider_unspecified"
493
+
494
+
495
+ @dataclass(frozen=True, slots=True)
496
+ class RecordCommentRef:
497
+ comment_id: int
498
+
499
+
500
+ @dataclass(frozen=True, slots=True)
501
+ class RecordAttachmentLocation:
502
+ """One observed attachment occurrence in canonical record content."""
503
+
504
+ kind: Literal["form_file", "table_file", "files_module", "images_module", "richtext"]
505
+ module: str
506
+ field: str | None
507
+ position: int
508
+
509
+
510
+ @dataclass(frozen=True, slots=True)
511
+ class RecordAttachmentRef:
512
+ """Attachment metadata observed in an authorized record read."""
513
+
514
+ source: RecordLocator
515
+ sha256: str = field(repr=False)
516
+ filename: str
517
+ size: int
518
+ observed_version: RecordVersionRef | None = None
519
+ location: RecordAttachmentLocation | None = None
520
+
521
+
522
+ @dataclass(frozen=True, slots=True)
523
+ class StagedRecordAttachment:
524
+ """A completed record upload that has not yet been bound by a record update."""
525
+
526
+ source: RecordLocator
527
+ temporary_file_id: int
528
+ binding: UploadBindingRef
529
+ filename: str
530
+ size: int
531
+ sha256: str = field(repr=False)
532
+ binding_state: Literal["staged_not_bound"] = "staged_not_bound"
533
+
534
+
535
+ @dataclass(frozen=True, slots=True)
536
+ class RecordValueUpdate:
537
+ path: tuple[str | int, ...]
538
+ value: object
539
+
540
+
541
+ @dataclass(frozen=True, slots=True)
542
+ class RecordValueDeletion:
543
+ path: tuple[str | int, ...]
544
+
545
+
546
+ @dataclass(frozen=True, slots=True)
547
+ class RecordModuleCreation:
548
+ name: str
549
+ module_type: Literal["form", "table", "files", "richtext", "code"]
550
+ data: str | None = None
551
+ language: (
552
+ Literal["json", "python", "javascript", "html", "css", "xml", "php", "java", "cpp", "sql", "latex", "other"]
553
+ | None
554
+ ) = None
555
+ style: Literal["ternary", "echarts"] | None = None
556
+
557
+
558
+ @dataclass(frozen=True, slots=True)
559
+ class RecordModuleDeletion:
560
+ name: str
561
+
562
+
563
+ @dataclass(frozen=True, slots=True)
564
+ class RecordValueAddition:
565
+ module: str
566
+ value: object = None
567
+ name: str | None = None
568
+ value_type: Literal["text", "richtext", "multiline", "number", "date", "time", "file", "bool"] | None = None
569
+ row: Literal[0, -1] | None = None
570
+ path: tuple[str, ...] | None = None
571
+
572
+
573
+ @dataclass(frozen=True, slots=True)
574
+ class RecordFormAttachmentRemoval:
575
+ attachment: RecordAttachmentRef
576
+
577
+
578
+ @dataclass(frozen=True, slots=True)
579
+ class RecordTableAttachmentReplacement:
580
+ existing: RecordAttachmentRef
581
+ replacement: StagedRecordAttachment
582
+
583
+
584
+ @dataclass(frozen=True, slots=True)
585
+ class RecordFilesAttachmentAppend:
586
+ module: str
587
+ attachment: StagedRecordAttachment
588
+ caption: str | None = None
589
+ path: tuple[str, ...] | None = None
590
+
591
+
592
+ @dataclass(frozen=True, slots=True)
593
+ class RecordFilesAttachmentReplacement:
594
+ existing: RecordAttachmentRef
595
+ replacement: StagedRecordAttachment
596
+ caption: str | None = None
597
+
598
+
599
+ @dataclass(frozen=True, slots=True)
600
+ class RecordFilesAttachmentRemoval:
601
+ attachment: RecordAttachmentRef
602
+
603
+
604
+ def _empty_record_attachment_bindings() -> Mapping[str, StagedRecordAttachment]:
605
+ return {}
606
+
607
+
608
+ @dataclass(frozen=True, slots=True)
609
+ class RecordRichTextUpdate:
610
+ module: str
611
+ html: str
612
+ attachments: Mapping[str, StagedRecordAttachment] = field(default_factory=_empty_record_attachment_bindings)
613
+
614
+
615
+ @dataclass(frozen=True, slots=True)
616
+ class RecordPatch:
617
+ value_updates: tuple[RecordValueUpdate, ...] = ()
618
+ value_deletions: tuple[RecordValueDeletion, ...] = ()
619
+ module_creations: tuple[RecordModuleCreation, ...] = ()
620
+ module_deletions: tuple[RecordModuleDeletion, ...] = ()
621
+ value_additions: tuple[RecordValueAddition, ...] = ()
622
+ attachment_changes: tuple[
623
+ RecordFormAttachmentRemoval
624
+ | RecordTableAttachmentReplacement
625
+ | RecordFilesAttachmentAppend
626
+ | RecordFilesAttachmentReplacement
627
+ | RecordFilesAttachmentRemoval,
628
+ ...,
629
+ ] = ()
630
+ rich_text_updates: tuple[RecordRichTextUpdate, ...] = ()
631
+
632
+
633
+ @dataclass(frozen=True, slots=True)
634
+ class RecordVersionSummary:
635
+ ref: RecordVersionRef
636
+ locked_at_ms: int
637
+ modified_at_ms: int
638
+ locked: bool
639
+ author_userid: int
640
+ author_name: str | None
641
+
642
+
643
+ @dataclass(frozen=True, slots=True)
644
+ class RecordSignature:
645
+ signer_userid: int
646
+ username: str | None
647
+ photo_signature: str | None
648
+ signed_at: str | None
649
+ timestamp_result: str
650
+ user_result: str
651
+
652
+
653
+ @dataclass(frozen=True, slots=True)
654
+ class Record:
655
+ ref: RecordRef
656
+ notebook: NotebookRef
657
+ selected_version: RecordVersionRef | None
658
+ selected_record_uid: str | None
659
+ title: str | None
660
+ keywords: tuple[str, ...] | None
661
+ modules: tuple[dict[str, object], ...]
662
+ template_id: int | None
663
+ modified_at_ms: int
664
+ author_name: str | None
665
+ versions: tuple[RecordVersionSummary, ...]
666
+ current_version: RecordVersionRef | None
667
+ locked_at_ms: int
668
+ locked: bool
669
+ signatures: tuple[RecordSignature, ...]
670
+ signed: bool
671
+ owner: bool
672
+ editable: bool
673
+ notebook_title: str | None
674
+ data_server: str
675
+ notebook_keywords: tuple[object, ...]
676
+ pdf_enabled: bool
677
+ template_render_metadata: str
678
+ template_title: str
679
+ template_intro: str
680
+ attachments: tuple[RecordAttachmentRef, ...]
681
+ content_sha256: str
682
+
683
+
684
+ @dataclass(frozen=True, slots=True)
685
+ class RecordUpdateResult:
686
+ source: RecordLocator
687
+ before: Record
688
+ observed_after: Record | None
689
+ persistence: Literal["persisted", "pending_browser_save", "provider_acknowledged_unclassified"]
690
+ confirmation: Literal["observed_changed", "observed_unchanged", "pending_browser_save_not_read_back"]
691
+ requested_operation_count: int
692
+ provider_message: str
693
+ atomicity: Literal["provider_unspecified"] = "provider_unspecified"
694
+ concurrency_control: Literal["client_read_precondition_not_provider_cas"] = (
695
+ "client_read_precondition_not_provider_cas"
696
+ )
697
+ retryable: Literal[False] = False
698
+
699
+
700
+ @dataclass(frozen=True, slots=True)
701
+ class RecordCommentAttachmentRef:
702
+ """Attachment metadata observed in an authorized comment read.
703
+
704
+ This reference preserves its record/comment context for correct request
705
+ construction. It is not a Provider-side authorization credential: current
706
+ Providers do not verify that the file belongs to that context (PVD-038).
707
+ """
708
+
709
+ source: RecordLocator
710
+ comment: RecordCommentRef
711
+ sha256: str = field(repr=False)
712
+ filename: str
713
+ size: int
714
+
715
+
716
+ @dataclass(frozen=True, slots=True)
717
+ class StagedRecordCommentAttachment:
718
+ source: RecordLocator
719
+ binding: UploadBindingRef
720
+ temporary_file_id: int
721
+ filename: str
722
+ size: int
723
+ sha256: str = field(repr=False)
724
+ abort_supported: Literal[False] = False
725
+ binding_user_verified_on_save: Literal[False] = False
726
+ _temporary_url: str = field(repr=False, compare=False, default="")
727
+ _canonical_reference: str = field(repr=False, compare=False, default="")
728
+
729
+
730
+ def _empty_comment_attachment_bindings() -> Mapping[str, StagedRecordCommentAttachment]:
731
+ return {}
732
+
733
+
734
+ @dataclass(frozen=True, slots=True)
735
+ class RecordCommentDraft:
736
+ html: str
737
+ attachments: Mapping[str, StagedRecordCommentAttachment] = field(default_factory=_empty_comment_attachment_bindings)
738
+
739
+
740
+ @dataclass(frozen=True, slots=True)
741
+ class RecordComment:
742
+ source: RecordLocator
743
+ ref: RecordCommentRef
744
+ modified_at: str
745
+ body: str
746
+ author_name: str | None
747
+ owned_by_caller: bool
748
+ attachments: tuple[RecordCommentAttachmentRef, ...]
749
+
750
+
751
+ @dataclass(frozen=True, slots=True)
752
+ class RecordComments:
753
+ source: RecordLocator
754
+ comments: tuple[RecordComment, ...]
755
+ ordering: Literal["provider_unspecified"] = "provider_unspecified"
756
+
757
+
758
+ @dataclass(frozen=True, slots=True)
759
+ class RecordCommentSaveResult:
760
+ source: RecordLocator
761
+ action: Literal["create", "update"]
762
+ requested_comment: RecordCommentRef | None
763
+ observed: tuple[RecordComment, ...]
764
+ confirmation: Literal[
765
+ "unique_created_comment_observed",
766
+ "created_comment_identity_ambiguous",
767
+ "created_comment_not_observed",
768
+ "updated_comment_observed_matching",
769
+ "updated_comment_observed_mismatched",
770
+ "updated_comment_not_observed",
771
+ ]
772
+ identity_source: Literal["readback", "unknown"]
773
+ forwarded: bool
774
+ provider_returned_identity: Literal[False] = False
775
+ retryable: Literal[False] = False
776
+
777
+
778
+ @dataclass(frozen=True, slots=True)
779
+ class RecordCommentDeleteResult:
780
+ comment: RecordCommentRef
781
+ source: RecordLocator
782
+ confirmation: Literal["not_observed_after_delete", "still_observed_after_delete"]
783
+ forwarded: bool
784
+ affected_row_count: None = None
785
+ retryable: Literal[False] = False
786
+
787
+
788
+ @dataclass(frozen=True, slots=True)
789
+ class RelatedRecordIdentity:
790
+ declared_notebook_id: int | None
791
+ resolved_notebook_id: int | None
792
+ record_database_id: int
793
+ record_uid: str | None
794
+ notebook_title: str | None
795
+
796
+
797
+ @dataclass(frozen=True, slots=True)
798
+ class RecordRelationRef:
799
+ relation_id: int
800
+ source: RecordLocator
801
+
802
+
803
+ @dataclass(frozen=True, slots=True)
804
+ class RecordRelation:
805
+ ref: RecordRelationRef
806
+ target: RelatedRecordIdentity
807
+ title: str | None
808
+ summary: str | None
809
+
810
+
811
+ @dataclass(frozen=True, slots=True)
812
+ class RecordRelations:
813
+ relations: tuple[RecordRelation, ...]
814
+ legacy_comments: tuple[RecordComment, ...]
815
+ editable: bool
816
+ ordering: Literal["provider_unspecified"] = "provider_unspecified"
817
+
818
+
819
+ @dataclass(frozen=True, slots=True)
820
+ class RecordRelationAddResult:
821
+ source: RecordLocator
822
+ target: RecordLocator
823
+ observed: tuple[RecordRelation, ...]
824
+ confirmation: Literal["observed_after_add", "not_observed_after_add"]
825
+ forwarded: bool
826
+ provider_returned_identity: Literal[False] = False
827
+ preflight: Literal["client_verified_non_atomic"] = "client_verified_non_atomic"
828
+ retryable: Literal[False] = False
829
+
830
+
831
+ @dataclass(frozen=True, slots=True)
832
+ class RecordRelationDeleteResult:
833
+ requested: RecordRelation
834
+ confirmation: Literal["not_observed_after_delete", "still_observed_after_delete"]
835
+ other_relations_missing_after_write: tuple[RecordRelationRef, ...]
836
+ forwarded: bool
837
+ affected_row_count: None = None
838
+ preflight: Literal["client_verified_non_atomic"] = "client_verified_non_atomic"
839
+ retryable: Literal[False] = False
840
+
841
+
842
+ @dataclass(frozen=True, slots=True)
843
+ class _ParsedAttachment:
844
+ sha256: str
845
+ filename: str
846
+ size: int
847
+
848
+
849
+ class _RichTextAttachmentParser(HTMLParser):
850
+ def __init__(self) -> None:
851
+ super().__init__(convert_charrefs=True)
852
+ self.attachments: list[_ParsedAttachment] = []
853
+
854
+ def handle_starttag( # pyright: ignore[reportImplicitOverride]
855
+ self, tag: str, attrs: list[tuple[str, str | None]]
856
+ ) -> None:
857
+ target_attribute = "src" if tag.casefold() == "img" else "href" if tag.casefold() == "a" else None
858
+ if target_attribute is None:
859
+ return
860
+ value = next((value for name, value in attrs if name.casefold() == target_attribute), None)
861
+ if value is None:
862
+ return
863
+ attachment = _parse_elnurl(value)
864
+ if attachment is not None:
865
+ self.attachments.append(attachment)
866
+
867
+
868
+ def _parse_elnurl(value: str) -> _ParsedAttachment | None:
869
+ match = _ELNURL_PATTERN.fullmatch(value)
870
+ if match is None:
871
+ return None
872
+ filename = unquote(match.group(3))
873
+ if not filename:
874
+ return None
875
+ return _ParsedAttachment(sha256=match.group(1).lower(), filename=filename, size=int(match.group(2)))
876
+
877
+
878
+ def _rich_text_attachments(value: str) -> tuple[_ParsedAttachment, ...]:
879
+ parser = _RichTextAttachmentParser()
880
+ parser.feed(value)
881
+ parser.close()
882
+ return tuple(parser.attachments)
883
+
884
+
885
+ def _nested_record_attachments(value: object) -> tuple[_ParsedAttachment, ...]:
886
+ attachments: list[_ParsedAttachment] = []
887
+
888
+ def visit(item: object) -> None:
889
+ if isinstance(item, str):
890
+ attachments.extend(_rich_text_attachments(item))
891
+ return
892
+ if isinstance(item, list):
893
+ for child in cast(list[object], item):
894
+ visit(child)
895
+ return
896
+ if not isinstance(item, dict):
897
+ return
898
+ mapping = cast(dict[str, object], item)
899
+ sha256 = mapping.get("hash")
900
+ filename = mapping.get("filename")
901
+ size = mapping.get("size")
902
+ if (
903
+ isinstance(sha256, str)
904
+ and _ELNURL_PATTERN.fullmatch(f"elnurl://h={sha256}&s=0&f=x") is not None
905
+ and isinstance(filename, str)
906
+ and filename
907
+ and type(size) is int
908
+ and size >= 0
909
+ ):
910
+ attachments.append(_ParsedAttachment(sha256=sha256.lower(), filename=filename, size=size))
911
+ for child in mapping.values():
912
+ visit(child)
913
+
914
+ visit(value)
915
+ return tuple(attachments)
916
+
917
+
918
+ def _content_sha256(modules: Sequence[Mapping[str, object]]) -> str:
919
+ encoded = json.dumps(list(modules), ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
920
+ return hashlib.sha256(encoded).hexdigest()
921
+
922
+
923
+ def _contains_native_attachment_reference(value: object) -> bool:
924
+ if isinstance(value, str):
925
+ return _NATIVE_ATTACHMENT_PATTERN.search(value) is not None
926
+ if isinstance(value, Mapping):
927
+ return any(
928
+ _contains_native_attachment_reference(item) for item in cast(Mapping[object, object], value).values()
929
+ )
930
+ if isinstance(value, (list, tuple)):
931
+ return any(
932
+ _contains_native_attachment_reference(item) for item in cast(list[object] | tuple[object, ...], value)
933
+ )
934
+ return False
935
+
936
+
937
+ def _record_attachment_refs(
938
+ modules: Sequence[Mapping[str, object]], *, source: RecordLocator, version: RecordVersionRef | None
939
+ ) -> tuple[RecordAttachmentRef, ...]:
940
+ located: list[tuple[_ParsedAttachment, RecordAttachmentLocation]] = []
941
+
942
+ def add_structured(
943
+ value: object,
944
+ *,
945
+ kind: Literal["form_file", "table_file", "files_module", "images_module"],
946
+ module: str,
947
+ field_name: str | None,
948
+ position: int,
949
+ ) -> None:
950
+ if not isinstance(value, dict):
951
+ return
952
+ mapping = cast(dict[str, object], value)
953
+ sha256 = mapping.get("hash")
954
+ filename = mapping.get("filename")
955
+ size = mapping.get("size")
956
+ if (
957
+ isinstance(sha256, str)
958
+ and re.fullmatch(r"[0-9a-f]{64}", sha256) is not None
959
+ and isinstance(filename, str)
960
+ and filename
961
+ and type(size) is int
962
+ and size >= 0
963
+ ):
964
+ located.append(
965
+ (
966
+ _ParsedAttachment(sha256=sha256, filename=filename, size=size),
967
+ RecordAttachmentLocation(kind=kind, module=module, field=field_name, position=position),
968
+ )
969
+ )
970
+
971
+ for module in modules:
972
+ module_name = module.get("name")
973
+ module_type = module.get("type")
974
+ if not isinstance(module_name, str) or not module_name or not isinstance(module_type, str):
975
+ continue
976
+ values = module.get("data")
977
+ if module_type in {"form", "table"} and isinstance(values, list):
978
+ kind: Literal["form_file", "table_file"] = "form_file" if module_type == "form" else "table_file"
979
+ for raw_field in cast(list[object], values):
980
+ if not isinstance(raw_field, dict):
981
+ continue
982
+ field_mapping = cast(dict[str, object], raw_field)
983
+ field_name = field_mapping.get("name")
984
+ field_values = field_mapping.get("data")
985
+ if (
986
+ field_mapping.get("type") != "file"
987
+ or not isinstance(field_name, str)
988
+ or not field_name
989
+ or not isinstance(field_values, list)
990
+ ):
991
+ continue
992
+ for position, value in enumerate(cast(list[object], field_values)):
993
+ add_structured(value, kind=kind, module=module_name, field_name=field_name, position=position)
994
+ elif module_type in {"files", "images"} and isinstance(values, list):
995
+ file_kind: Literal["files_module", "images_module"] = (
996
+ "files_module" if module_type == "files" else "images_module"
997
+ )
998
+ for position, value in enumerate(cast(list[object], values)):
999
+ add_structured(value, kind=file_kind, module=module_name, field_name=None, position=position)
1000
+ elif module_type == "richtext" and isinstance(values, str):
1001
+ for position, attachment in enumerate(_rich_text_attachments(values)):
1002
+ located.append(
1003
+ (
1004
+ attachment,
1005
+ RecordAttachmentLocation(kind="richtext", module=module_name, field=None, position=position),
1006
+ )
1007
+ )
1008
+
1009
+ known_values = {(attachment.sha256, attachment.filename, attachment.size) for attachment, _ in located}
1010
+ unlocated = [
1011
+ attachment
1012
+ for attachment in _nested_record_attachments(list(modules))
1013
+ if (attachment.sha256, attachment.filename, attachment.size) not in known_values
1014
+ ]
1015
+ return tuple(
1016
+ RecordAttachmentRef(
1017
+ source=source,
1018
+ sha256=attachment.sha256,
1019
+ filename=attachment.filename,
1020
+ size=attachment.size,
1021
+ observed_version=version,
1022
+ location=location,
1023
+ )
1024
+ for attachment, location in located
1025
+ ) + tuple(
1026
+ RecordAttachmentRef(
1027
+ source=source,
1028
+ sha256=attachment.sha256,
1029
+ filename=attachment.filename,
1030
+ size=attachment.size,
1031
+ observed_version=version,
1032
+ )
1033
+ for attachment in unlocated
1034
+ )
1035
+
1036
+
1037
+ class Records:
1038
+ """Record-oriented Matelab operations."""
1039
+
1040
+ def __init__(self, transport: SessionTransport) -> None:
1041
+ self._transport: SessionTransport = transport
1042
+
1043
+ async def list(
1044
+ self,
1045
+ *,
1046
+ notebook: NotebookRef,
1047
+ date_start: str | None = None,
1048
+ date_end: str | None = None,
1049
+ keywords: Sequence[str] | None = None,
1050
+ content: str | None = None,
1051
+ ) -> RecordCollection:
1052
+ if notebook.title is None:
1053
+ raise MatelabUsageError("Cannot list records for a notebook whose Provider title is null.")
1054
+ try:
1055
+ request = WireListRecordsRequest(
1056
+ eln=notebook.title,
1057
+ user=notebook.owner_userid if notebook.scope == "shared" else None,
1058
+ date_start=date_start if date_start is not None else "",
1059
+ date_end=date_end if date_end is not None else "",
1060
+ keywords=list(keywords) if keywords is not None else [],
1061
+ content=content if content is not None else "",
1062
+ )
1063
+ except ValidationError:
1064
+ raise MatelabUsageError("Record-list filters do not satisfy the Integration Contract.") from None
1065
+ response = await self._transport.request(
1066
+ _LIST_RECORDS, payload=request.model_dump(mode="json", exclude_defaults=True, exclude_none=True)
1067
+ )
1068
+ return RecordCollection(
1069
+ records=tuple(self._record_summary(item) for item in response.items),
1070
+ subtypes=tuple(self._subtype_summary(item) for item in response.subtypes),
1071
+ trash_count=response.trash_num,
1072
+ )
1073
+
1074
+ async def create_blank(
1075
+ self,
1076
+ *,
1077
+ notebook: NotebookRef,
1078
+ title: str,
1079
+ summary: str | None = None,
1080
+ record_uid: str | None = None,
1081
+ path: Sequence[str] | None = None,
1082
+ ) -> BlankRecordCreateResult:
1083
+ notebook_title = self._notebook_title(notebook)
1084
+ requested_uid = record_uid or None
1085
+ try:
1086
+ request = WireCreateRecordRequest(
1087
+ eln=notebook_title,
1088
+ title=title,
1089
+ describe=summary,
1090
+ uid=requested_uid,
1091
+ user=notebook.owner_userid if notebook.scope == "shared" else None,
1092
+ path=list(path) if path is not None else None,
1093
+ )
1094
+ except ValidationError:
1095
+ raise MatelabUsageError("Blank-record input does not satisfy the Integration Contract.") from None
1096
+ _ = await self._transport.request(_CREATE_BLANK_RECORD, payload=request.model_dump(mode="json"))
1097
+ if requested_uid is None:
1098
+ return BlankRecordCreateResult(
1099
+ notebook=notebook,
1100
+ title=title,
1101
+ requested_record_uid=None,
1102
+ observed=None,
1103
+ confirmation="provider_generated_identity_not_returned",
1104
+ )
1105
+ after = await self._verified_list(notebook, "blank-record creation")
1106
+ observed = next((item for item in after.records if item.ref.record_uid == requested_uid), None)
1107
+ return BlankRecordCreateResult(
1108
+ notebook=notebook,
1109
+ title=title,
1110
+ requested_record_uid=requested_uid,
1111
+ observed=observed,
1112
+ confirmation="observed_by_requested_uid" if observed is not None else "not_observed_by_requested_uid",
1113
+ )
1114
+
1115
+ async def import_dataset(
1116
+ self, *, notebook: NotebookRef, template: RecordImportTemplate, items: Sequence[RecordImportItem]
1117
+ ) -> RecordImportResult:
1118
+ notebook_title = self._notebook_title(notebook)
1119
+ selected = tuple(items)
1120
+ if template.template.template_id < 1 or not selected or len(selected) > 100:
1121
+ raise MatelabUsageError("Record import requires a valid template and one through one hundred items.")
1122
+ if len({item.record_uid for item in selected}) != len(selected):
1123
+ raise MatelabUsageError("Imported record UIDs must be distinct within the batch.")
1124
+ payload: dict[str, object] = {
1125
+ "eln": notebook_title,
1126
+ "template": template.title,
1127
+ "dataset": [
1128
+ {
1129
+ "uid": item.record_uid,
1130
+ "title": item.title,
1131
+ "data": dict(item.data),
1132
+ "keyword": item.keyword,
1133
+ "path": list(item.path) if item.path is not None else None,
1134
+ }
1135
+ for item in selected
1136
+ ],
1137
+ "user": notebook.owner_userid if notebook.scope == "shared" else None,
1138
+ "template_user": template.owner.userid if template.owner is not None else None,
1139
+ }
1140
+ try:
1141
+ request = WireImportRecordsRequest.model_validate(payload)
1142
+ _ = json.dumps(request.model_dump(mode="json"), ensure_ascii=False)
1143
+ except (ValidationError, TypeError, ValueError):
1144
+ raise MatelabUsageError("Record import dataset does not satisfy the Integration Contract.") from None
1145
+ response = await self._transport.request(_IMPORT_RECORDS, payload=request.model_dump(mode="json"))
1146
+ if response.eln_id != notebook.notebook_id:
1147
+ raise MatelabProtocolError("Matelab returned a different target notebook for the record import.")
1148
+ return RecordImportResult(
1149
+ notebook=notebook,
1150
+ template=template,
1151
+ requested_record_uids=tuple(item.record_uid for item in selected),
1152
+ created_record_database_ids=tuple(item.root for item in response.id),
1153
+ provider_notebook_id=response.eln_id,
1154
+ identity_count_status=(
1155
+ "one_id_per_item"
1156
+ if len(response.id) == len(selected)
1157
+ else "fewer_ids_than_items"
1158
+ if len(response.id) < len(selected)
1159
+ else "more_ids_than_items"
1160
+ ),
1161
+ )
1162
+
1163
+ async def copy(
1164
+ self,
1165
+ source: RecordLocator,
1166
+ *,
1167
+ title: str,
1168
+ target_notebook: NotebookRef | None = None,
1169
+ record_uid: str | None = None,
1170
+ ) -> RecordCopyResult:
1171
+ target = target_notebook or source.notebook
1172
+ _ = self._notebook_title(target)
1173
+ try:
1174
+ request = WireCopyRecordRequest(
1175
+ eln=source.notebook.notebook_id,
1176
+ eln_to=0 if target.notebook_id == source.notebook.notebook_id else target.notebook_id,
1177
+ id=source.record.record_database_id,
1178
+ uid=record_uid,
1179
+ title=title,
1180
+ )
1181
+ except ValidationError:
1182
+ raise MatelabUsageError("Record copy input does not satisfy the Integration Contract.") from None
1183
+ response = await self._transport.request(_COPY_RECORD, payload=request.model_dump(mode="json"))
1184
+ after = await self._verified_list(target, "record copy")
1185
+ observed = next((item for item in after.records if item.ref.record_database_id == response.id), None)
1186
+ if observed is not None and record_uid and observed.ref.record_uid != record_uid:
1187
+ raise MatelabProtocolError("Matelab returned a different UID for the copied record.")
1188
+ return RecordCopyResult(
1189
+ source=source,
1190
+ target_notebook=target,
1191
+ record_database_id=response.id,
1192
+ record=observed.ref if observed is not None else None,
1193
+ observed=observed,
1194
+ forwarded=response.code is WireSuccessCode.int_10,
1195
+ confirmation="observed_by_returned_id" if observed is not None else "returned_id_not_observed",
1196
+ )
1197
+
1198
+ async def delete(self, *, notebook: NotebookRef, records: Sequence[RecordRef]) -> RecordDeleteResult:
1199
+ _ = self._notebook_title(notebook)
1200
+ selected = tuple(records)
1201
+ uids = tuple(record.record_uid for record in selected)
1202
+ if not selected or len(uids) != len(set(uids)):
1203
+ raise MatelabUsageError("Record deletion requires a non-empty set of distinct record UIDs.")
1204
+ try:
1205
+ request = WireDeleteRecordsRequest.model_validate(
1206
+ {
1207
+ "eln": notebook.title,
1208
+ "uids": list(uids),
1209
+ "user": notebook.owner_userid if notebook.scope == "shared" else None,
1210
+ }
1211
+ )
1212
+ except ValidationError:
1213
+ raise MatelabUsageError("Record deletion input does not satisfy the Integration Contract.") from None
1214
+ _ = await self._transport.request(_DELETE_RECORDS, payload=request.model_dump(mode="json"))
1215
+ active, deleted = await self._verified_lifecycle_state(notebook, "record deletion")
1216
+ active_uids = {item.ref.record_uid for item in active.records}
1217
+ deleted_by_identity = {(item.ref.record_database_id, item.ref.record_uid): item.ref for item in deleted.records}
1218
+ still_active = tuple(record for record in selected if record.record_uid in active_uids)
1219
+ no_longer_active = tuple(record for record in selected if record.record_uid not in active_uids)
1220
+ observed_deleted = tuple(
1221
+ deleted_by_identity[(record.record_database_id, record.record_uid)]
1222
+ for record in selected
1223
+ if (record.record_database_id, record.record_uid) in deleted_by_identity
1224
+ )
1225
+ unresolved = tuple(
1226
+ record
1227
+ for record in no_longer_active
1228
+ if (record.record_database_id, record.record_uid) not in deleted_by_identity
1229
+ )
1230
+ return RecordDeleteResult(
1231
+ notebook=notebook,
1232
+ requested=selected,
1233
+ no_longer_active=no_longer_active,
1234
+ still_active=still_active,
1235
+ observed_in_recycle_bin=observed_deleted,
1236
+ unresolved=unresolved,
1237
+ )
1238
+
1239
+ async def restore(self, *, notebook: NotebookRef, records: Sequence[DeletedRecordRef]) -> RecordRestoreResult:
1240
+ _ = self._notebook_title(notebook)
1241
+ selected = tuple(records)
1242
+ ids = tuple(record.record_database_id for record in selected)
1243
+ if not selected or any(record_id < 1 for record_id in ids) or len(ids) != len(set(ids)):
1244
+ raise MatelabUsageError("Record restoration requires distinct positive deleted-record identities.")
1245
+ try:
1246
+ request = WireRestoreRecordsRequest(eln=notebook.notebook_id, ids=json.dumps(ids, separators=(",", ":")))
1247
+ except ValidationError:
1248
+ raise MatelabUsageError("Record restore input does not satisfy the Integration Contract.") from None
1249
+ _ = await self._transport.request(_RESTORE_RECORDS, payload=request.model_dump(mode="json"))
1250
+ active, deleted = await self._verified_lifecycle_state(notebook, "record restoration")
1251
+ active_by_identity = {(item.ref.record_database_id, item.ref.record_uid): item.ref for item in active.records}
1252
+ deleted_identities = {(item.ref.record_database_id, item.ref.record_uid) for item in deleted.records}
1253
+ observed_active = tuple(
1254
+ active_by_identity[(item.record_database_id, item.record_uid)]
1255
+ for item in selected
1256
+ if (item.record_database_id, item.record_uid) in active_by_identity
1257
+ )
1258
+ still_deleted = tuple(
1259
+ item for item in selected if (item.record_database_id, item.record_uid) in deleted_identities
1260
+ )
1261
+ observed_identities = {(item.record_database_id, item.record_uid) for item in observed_active}
1262
+ unresolved = tuple(
1263
+ item
1264
+ for item in selected
1265
+ if (item.record_database_id, item.record_uid) not in observed_identities
1266
+ and (item.record_database_id, item.record_uid) not in deleted_identities
1267
+ )
1268
+ return RecordRestoreResult(
1269
+ notebook=notebook,
1270
+ requested=selected,
1271
+ observed_active=observed_active,
1272
+ still_deleted=still_deleted,
1273
+ unresolved=unresolved,
1274
+ )
1275
+
1276
+ async def upload_attachment(
1277
+ self,
1278
+ source: RecordLocator,
1279
+ *,
1280
+ filename: str,
1281
+ content: IO[bytes] | bytes,
1282
+ size: int,
1283
+ sha256: str,
1284
+ binding: UploadBindingRef | None = None,
1285
+ content_type: str = "application/octet-stream",
1286
+ ) -> StagedRecordAttachment:
1287
+ notebook_title = self._notebook_title(source.notebook)
1288
+ 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)},
1310
+ )
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
+ return StagedRecordAttachment(
1322
+ source=source,
1323
+ temporary_file_id=response.id,
1324
+ binding=selected_binding,
1325
+ filename=response.filename,
1326
+ size=response.size,
1327
+ sha256=response.hash.root,
1328
+ )
1329
+
1330
+ async def update(
1331
+ self,
1332
+ source: RecordLocator,
1333
+ patch: RecordPatch,
1334
+ *,
1335
+ expected_content_sha256: str | None = None,
1336
+ password: str | None = None,
1337
+ ) -> RecordUpdateResult:
1338
+ before = await self.read(notebook=source.notebook, record=source.record)
1339
+ if expected_content_sha256 is not None:
1340
+ if re.fullmatch(r"[0-9a-f]{64}", expected_content_sha256) is None:
1341
+ raise MatelabUsageError("Expected record content fingerprint must be a lowercase SHA-256.")
1342
+ if before.content_sha256 != expected_content_sha256:
1343
+ raise MatelabUsageError(
1344
+ "Record content changed before the update; this client-side precondition is not Provider CAS."
1345
+ )
1346
+ mutation_payload, operation_count = self._record_patch_payload(source, before, patch)
1347
+ try:
1348
+ request = WireUpdateRecordRequest.model_validate(
1349
+ {
1350
+ "eln": self._notebook_title(source.notebook),
1351
+ "uid": source.record.record_uid,
1352
+ "user": source.notebook.owner_userid if source.notebook.scope == "shared" else None,
1353
+ "password": password,
1354
+ **mutation_payload,
1355
+ }
1356
+ )
1357
+ _ = json.dumps(request.model_dump(mode="json", by_alias=True), ensure_ascii=False)
1358
+ except (ValidationError, TypeError, ValueError):
1359
+ raise MatelabUsageError("Record patch does not satisfy the Integration Contract.") from None
1360
+ response = await self._transport.request(
1361
+ _UPDATE_RECORD, payload=request.model_dump(mode="json", by_alias=True, exclude_none=True)
1362
+ )
1363
+ message = response.msg.strip()
1364
+ if message in _COLLABORATION_UPDATE_MESSAGES:
1365
+ return RecordUpdateResult(
1366
+ source=source,
1367
+ before=before,
1368
+ observed_after=None,
1369
+ persistence="pending_browser_save",
1370
+ confirmation="pending_browser_save_not_read_back",
1371
+ requested_operation_count=operation_count,
1372
+ provider_message=response.msg,
1373
+ )
1374
+ after = await self._verified_read(source, "record update")
1375
+ return RecordUpdateResult(
1376
+ source=source,
1377
+ before=before,
1378
+ observed_after=after,
1379
+ persistence="persisted" if message in _DATABASE_UPDATE_MESSAGES else "provider_acknowledged_unclassified",
1380
+ confirmation="observed_changed" if after.content_sha256 != before.content_sha256 else "observed_unchanged",
1381
+ requested_operation_count=operation_count,
1382
+ provider_message=response.msg,
1383
+ )
1384
+
1385
+ async def export(self, records: Sequence[RecordLocator]) -> RecordExport:
1386
+ try:
1387
+ request = WireExportRecordsRequest.model_validate(
1388
+ {
1389
+ "uids": [
1390
+ {"uid": record.record.record_uid, "eln": self._notebook_selector(record.notebook)}
1391
+ for record in records
1392
+ ]
1393
+ }
1394
+ )
1395
+ except ValidationError:
1396
+ raise MatelabUsageError("Record export selection does not satisfy the Integration Contract.") from None
1397
+ response = await self._transport.request(_EXPORT_RECORDS, payload=request.model_dump(mode="json"))
1398
+ return RecordExport(
1399
+ records=tuple(
1400
+ ExportedRecord(
1401
+ ref=RecordRef(record_database_id=item.id, record_uid=item.uid),
1402
+ notebook_title=item.eln_name,
1403
+ title=item.title,
1404
+ summary=item.comm,
1405
+ data=tuple(cast(list[object], item.data)),
1406
+ )
1407
+ for item in response.dataset
1408
+ ),
1409
+ forwarded=response.code is WireSuccessCode.int_10,
1410
+ )
1411
+
1412
+ async def search(
1413
+ self,
1414
+ *,
1415
+ extractions: Sequence[RecordFieldExtraction],
1416
+ notebooks: Sequence[NotebookRef] | None = None,
1417
+ date_start: str | None = None,
1418
+ date_end: str | None = None,
1419
+ keywords: Sequence[str] | None = None,
1420
+ ) -> RecordSearchResult:
1421
+ aliases = tuple(extraction.alias for extraction in extractions)
1422
+ if len(aliases) != len(set(aliases)) or any(alias in _SEARCH_RESERVED_FIELDS for alias in aliases):
1423
+ raise MatelabUsageError("Record extraction aliases must be unique and cannot replace identity fields.")
1424
+ try:
1425
+ request = WireSearchRecordsRequest.model_validate(
1426
+ {
1427
+ "eln": [self._notebook_selector(notebook) for notebook in notebooks or ()],
1428
+ "requests": [
1429
+ {"uid": extraction.alias, "path": list(extraction.path)} for extraction in extractions
1430
+ ],
1431
+ "date_start": date_start,
1432
+ "date_end": date_end,
1433
+ "keywords": list(keywords) if keywords is not None else None,
1434
+ }
1435
+ )
1436
+ except ValidationError:
1437
+ raise MatelabUsageError("Record search input does not satisfy the Integration Contract.") from None
1438
+ response = await self._transport.request(
1439
+ _SEARCH_RECORDS, payload=request.model_dump(mode="json", exclude_defaults=True, exclude_none=True)
1440
+ )
1441
+ matches: list[RecordSearchMatch] = []
1442
+ for item in response.data:
1443
+ extra = cast(dict[str, object], item.model_extra or {})
1444
+ extracted = {alias: extra[alias] for alias in aliases if alias in extra}
1445
+ matches.append(
1446
+ RecordSearchMatch(
1447
+ ref=RecordRef(record_database_id=item.id, record_uid=item.uid),
1448
+ notebook_id=item.eln_id,
1449
+ notebook_title=item.eln_name,
1450
+ title=item.title,
1451
+ extracted=MappingProxyType(extracted),
1452
+ missing_extractions=tuple(alias for alias in aliases if alias not in extra),
1453
+ )
1454
+ )
1455
+ return RecordSearchResult(matches=tuple(matches), forwarded=response.code is WireSuccessCode.int_10)
1456
+
1457
+ async def list_public(self, notebook: PublicNotebookRef) -> PublicRecordCollection:
1458
+ try:
1459
+ request = WirePublicRecordListRequest(eln=notebook.title)
1460
+ except ValidationError:
1461
+ raise MatelabUsageError("Public notebook selector does not satisfy the Integration Contract.") from None
1462
+ response = await self._transport.request(_LIST_PUBLIC_RECORDS, payload=request.model_dump(mode="json"))
1463
+ return PublicRecordCollection(
1464
+ notebook=notebook,
1465
+ records=tuple(
1466
+ PublicRecordSummary(
1467
+ ref=PublicRecordRef(
1468
+ public_entry_id=item.id,
1469
+ source_notebook_id=item.eln_id,
1470
+ source_record_database_id=item.item_id,
1471
+ record_uid=item.uid,
1472
+ ),
1473
+ title=item.title,
1474
+ summary=item.comm,
1475
+ locked=item.locked,
1476
+ source_notebook_title=item.eln_name,
1477
+ owner=UserRef(userid=item.userid) if item.userid is not None and item.userid > 0 else None,
1478
+ owner_name=item.username,
1479
+ owner_email=item.email,
1480
+ owned_by_caller=item.owner,
1481
+ )
1482
+ for item in response.items
1483
+ ),
1484
+ )
1485
+
1486
+ async def page(
1487
+ self,
1488
+ notebook: NotebookRef,
1489
+ *,
1490
+ page: int = 1,
1491
+ subtype_id: int = -1,
1492
+ date_start: str = "",
1493
+ date_end: str = "",
1494
+ content: str = "",
1495
+ keywords: Sequence[str] = (),
1496
+ ) -> RecordPage:
1497
+ encoded_keywords = json.dumps(list(keywords), ensure_ascii=False, separators=(",", ":")) if keywords else ""
1498
+ try:
1499
+ request = WireRecordPageQuery(
1500
+ eln=notebook.notebook_id,
1501
+ page=page,
1502
+ page_size=0,
1503
+ subtype=subtype_id,
1504
+ default=WirePageDefault.integer_1,
1505
+ order_desc=WireOrderDesc.integer_1,
1506
+ ds=date_start,
1507
+ de=date_end,
1508
+ content=content,
1509
+ keywords=encoded_keywords,
1510
+ )
1511
+ except ValidationError:
1512
+ raise MatelabUsageError("Record-page filters do not satisfy the Integration Contract.") from None
1513
+ response = await self._transport.request(_LIST_RECORD_PAGE, payload=request.model_dump(mode="json"))
1514
+ if response.eln.id != notebook.notebook_id:
1515
+ raise MatelabProtocolError("Matelab returned a different notebook for the record page.")
1516
+ matching_ids = tuple(item.root for item in response.ids)
1517
+ effective_page_size = response.eln.page_size
1518
+ return RecordPage(
1519
+ records=tuple(self._record_summary(item) for item in response.items),
1520
+ subtypes=tuple(self._subtype_summary(item) for item in response.subtypes),
1521
+ context=RecordPageContext(
1522
+ notebook=notebook,
1523
+ notebook_title=response.eln.eln_text,
1524
+ owner_name=response.eln.username,
1525
+ template_id=response.eln.template_id,
1526
+ data_server=response.eln.server,
1527
+ permissions=RecordPagePermissions(
1528
+ editable=response.eln.editable,
1529
+ can_create=response.eln.create,
1530
+ can_delete=response.eln.delete,
1531
+ can_manage_subtypes=response.eln.subtype,
1532
+ can_read_audit=response.eln.readlog,
1533
+ can_manage_users=response.eln.user,
1534
+ can_sign=response.eln.sign,
1535
+ ),
1536
+ effective_page_size=effective_page_size,
1537
+ ),
1538
+ page=response.page,
1539
+ total_count=len(matching_ids),
1540
+ matching_record_database_ids=matching_ids,
1541
+ trash_count=response.trash_num,
1542
+ has_more=response.page * effective_page_size < len(matching_ids),
1543
+ keywords=tuple(cast(list[object], response.keywords)),
1544
+ )
1545
+
1546
+ async def recycle_bin(self, notebook: NotebookRef, *, page: int = 1, page_size: int = 24) -> DeletedRecordPage:
1547
+ if not 10 <= page_size <= 256:
1548
+ raise MatelabUsageError("Recycle-bin page size must be between 10 and 256.")
1549
+ try:
1550
+ request = WireDeletedRecordPageQuery(eln=notebook.notebook_id, page=page, page_size=page_size)
1551
+ except ValidationError:
1552
+ raise MatelabUsageError("Recycle-bin page input does not satisfy the Integration Contract.") from None
1553
+ response = await self._transport.request(_LIST_DELETED_RECORDS, payload=request.model_dump(mode="json"))
1554
+ if response.eln.id != notebook.notebook_id:
1555
+ raise MatelabProtocolError("Matelab returned a different notebook for the recycle bin.")
1556
+ return DeletedRecordPage(
1557
+ notebook=notebook,
1558
+ records=tuple(
1559
+ DeletedRecordSummary(
1560
+ ref=DeletedRecordRef(record_database_id=item.id, record_uid=item.sn),
1561
+ title=item.title,
1562
+ summary=item.comm,
1563
+ created_at=item.datetime_create,
1564
+ modified_at=item.datetime_modify,
1565
+ deleted_at=item.deleted_dtime,
1566
+ )
1567
+ for item in response.items
1568
+ ),
1569
+ page=response.page,
1570
+ requested_page_size=request.page_size,
1571
+ total_count=response.count,
1572
+ has_more=response.page * request.page_size < response.count,
1573
+ forwarded=response.code is WireSuccessCode.int_10,
1574
+ )
1575
+
1576
+ async def relations(self, *, notebook: NotebookRef, record: RecordRef) -> RecordRelations:
1577
+ try:
1578
+ request = WireRecordRelationsQuery(eln=notebook.notebook_id, item_id=record.record_database_id)
1579
+ except ValidationError:
1580
+ raise MatelabUsageError("Record relation source does not satisfy the Integration Contract.") from None
1581
+ response = await self._transport.request(_LIST_RELATIONS, payload=request.model_dump(mode="json"))
1582
+ source = RecordLocator(notebook=notebook, record=record)
1583
+ return RecordRelations(
1584
+ relations=tuple(
1585
+ RecordRelation(
1586
+ ref=RecordRelationRef(relation_id=item.id, source=source),
1587
+ target=RelatedRecordIdentity(
1588
+ declared_notebook_id=item.related_eln,
1589
+ resolved_notebook_id=item.eln_id,
1590
+ record_database_id=item.related_id,
1591
+ record_uid=item.uid,
1592
+ notebook_title=item.eln,
1593
+ ),
1594
+ title=item.title,
1595
+ summary=item.comm,
1596
+ )
1597
+ for item in response.items
1598
+ ),
1599
+ legacy_comments=tuple(self._comment(item, source=source) for item in response.more),
1600
+ editable=response.editable,
1601
+ )
1602
+
1603
+ async def add_relation(self, *, source: RecordLocator, target: RecordLocator) -> RecordRelationAddResult:
1604
+ source_record = await self.read(notebook=source.notebook, record=source.record)
1605
+ source_relations = await self.relations(notebook=source.notebook, record=source.record)
1606
+ if not source_relations.editable:
1607
+ raise MatelabUsageError("The selected source record is not currently editable.")
1608
+ target_record = (
1609
+ source_record if target == source else await self.read(notebook=target.notebook, record=target.record)
1610
+ )
1611
+ if source_record.data_server != target_record.data_server:
1612
+ raise MatelabUsageError(
1613
+ "Record relation endpoints resolve to different data servers; Provider validation is insufficient."
1614
+ )
1615
+ try:
1616
+ request = WireAddRelationRequest(
1617
+ eln_id=source.notebook.notebook_id,
1618
+ item_id=source.record.record_database_id,
1619
+ related_eln=target.notebook.notebook_id,
1620
+ related_id=target.record.record_database_id,
1621
+ )
1622
+ except ValidationError:
1623
+ raise MatelabUsageError("Record relation endpoints do not satisfy the Integration Contract.") from None
1624
+ response = await self._transport.request(_ADD_RELATION, payload=request.model_dump(mode="json"))
1625
+ after = await self._verified_relations(source, "record relation addition")
1626
+ observed = tuple(
1627
+ relation
1628
+ for relation in after.relations
1629
+ if relation.target.declared_notebook_id == target.notebook.notebook_id
1630
+ and relation.target.resolved_notebook_id == target.notebook.notebook_id
1631
+ and relation.target.record_database_id == target.record.record_database_id
1632
+ and relation.target.record_uid == target.record.record_uid
1633
+ )
1634
+ return RecordRelationAddResult(
1635
+ source=source,
1636
+ target=target,
1637
+ observed=observed,
1638
+ confirmation="observed_after_add" if observed else "not_observed_after_add",
1639
+ forwarded=response.code is WireSuccessCode.int_10,
1640
+ )
1641
+
1642
+ async def delete_relation(self, relation: RecordRelation) -> RecordRelationDeleteResult:
1643
+ source = relation.ref.source
1644
+ before = await self.relations(notebook=source.notebook, record=source.record)
1645
+ if not before.editable:
1646
+ raise MatelabUsageError("The selected source record is not currently editable.")
1647
+ current = next((item for item in before.relations if item.ref == relation.ref), None)
1648
+ if current is None or current.target != relation.target:
1649
+ raise MatelabUsageError("The selected relation is no longer present with the observed target identity.")
1650
+ colliding_targets = tuple(
1651
+ item
1652
+ for item in before.relations
1653
+ if item.ref != relation.ref
1654
+ and item.target.record_database_id == relation.target.record_database_id
1655
+ and item.target.declared_notebook_id != relation.target.declared_notebook_id
1656
+ )
1657
+ if colliding_targets:
1658
+ raise MatelabUsageError(
1659
+ "Relation deletion is unsafe because the Provider ignores target notebook identity (PVD-020)."
1660
+ )
1661
+ try:
1662
+ request = WireDeleteRelationRequest(
1663
+ eln=source.notebook.notebook_id,
1664
+ item_id=source.record.record_database_id,
1665
+ related_id=relation.target.record_database_id,
1666
+ )
1667
+ except ValidationError:
1668
+ raise MatelabUsageError("Record relation identity does not satisfy the Integration Contract.") from None
1669
+ response = await self._transport.request(_DELETE_RELATION, payload=request.model_dump(mode="json"))
1670
+ after = await self._verified_relations(source, "record relation deletion")
1671
+ remaining_refs = {item.ref for item in after.relations}
1672
+ other_missing = tuple(
1673
+ item.ref for item in before.relations if item.ref != relation.ref and item.ref not in remaining_refs
1674
+ )
1675
+ return RecordRelationDeleteResult(
1676
+ requested=relation,
1677
+ confirmation=(
1678
+ "still_observed_after_delete" if relation.ref in remaining_refs else "not_observed_after_delete"
1679
+ ),
1680
+ other_relations_missing_after_write=other_missing,
1681
+ forwarded=response.code is WireSuccessCode.int_10,
1682
+ )
1683
+
1684
+ async def comments(self, *, notebook: NotebookRef, record: RecordRef) -> RecordComments:
1685
+ try:
1686
+ request = WireRecordCommentsQuery(eln=notebook.notebook_id, item_id=record.record_database_id)
1687
+ except ValidationError:
1688
+ raise MatelabUsageError("Record comment source does not satisfy the Integration Contract.") from None
1689
+ response = await self._transport.request(_LIST_COMMENTS, payload=request.model_dump(mode="json"))
1690
+ source = RecordLocator(notebook=notebook, record=record)
1691
+ return RecordComments(
1692
+ source=source, comments=tuple(self._comment(item, source=source) for item in response.mores)
1693
+ )
1694
+
1695
+ async def upload_comment_attachment(
1696
+ self,
1697
+ source: RecordLocator,
1698
+ *,
1699
+ filename: str,
1700
+ content: IO[bytes] | bytes,
1701
+ size: int,
1702
+ sha256: str,
1703
+ binding: UploadBindingRef | None = None,
1704
+ content_type: str = "application/octet-stream",
1705
+ ) -> StagedRecordCommentAttachment:
1706
+ selected_binding = binding or UploadBindingRef.new()
1707
+ self._validate_upload_metadata(
1708
+ filename=filename, content=content, size=size, sha256=sha256, content_type=content_type
1709
+ )
1710
+ try:
1711
+ request = WireUploadRecordCommentAttachmentRequest.model_validate(
1712
+ {
1713
+ "uid": selected_binding.uid,
1714
+ "eln": source.notebook.notebook_id,
1715
+ "i": source.record.record_database_id,
1716
+ "upload": b"validation-placeholder",
1717
+ }
1718
+ )
1719
+ except ValidationError:
1720
+ raise MatelabUsageError(
1721
+ "Record-comment attachment upload does not satisfy the Integration Contract."
1722
+ ) from None
1723
+ response = await self._transport.request(
1724
+ _UPLOAD_COMMENT_ATTACHMENT,
1725
+ payload=request.model_dump(mode="json", exclude={"upload"}),
1726
+ files={"upload": (filename, content, content_type)},
1727
+ )
1728
+ returned_notebook_id, temporary_id, returned_sha256, returned_size, returned_filename, canonical_reference = (
1729
+ self._parse_comment_upload_query(response.query)
1730
+ )
1731
+ if (
1732
+ returned_notebook_id != source.notebook.notebook_id
1733
+ or response.filename != filename
1734
+ or returned_filename != filename
1735
+ or returned_sha256 != sha256
1736
+ or returned_size != size
1737
+ ):
1738
+ raise MatelabProtocolError(
1739
+ "Matelab staged the comment attachment but returned different upload metadata; "
1740
+ + "the temporary upload cannot be aborted through this Contract."
1741
+ )
1742
+ return StagedRecordCommentAttachment(
1743
+ source=source,
1744
+ binding=selected_binding,
1745
+ temporary_file_id=temporary_id,
1746
+ filename=filename,
1747
+ size=size,
1748
+ sha256=sha256,
1749
+ _temporary_url=self._transport.absolute_url(response.path, response.query),
1750
+ _canonical_reference=canonical_reference,
1751
+ )
1752
+
1753
+ async def create_comment(self, source: RecordLocator, draft: RecordCommentDraft) -> RecordCommentSaveResult:
1754
+ before = await self.comments(notebook=source.notebook, record=source.record)
1755
+ wire_html, canonical_html, binding_uid = self._render_comment_draft(source, draft)
1756
+ response = await self._save_comment_request(
1757
+ source=source, comment_id=0, html=wire_html, binding_uid=binding_uid
1758
+ )
1759
+ after = await self._verified_comments(source, "record comment creation")
1760
+ previous_ids = {comment.ref.comment_id for comment in before.comments}
1761
+ observed = tuple(
1762
+ comment
1763
+ for comment in after.comments
1764
+ if comment.ref.comment_id not in previous_ids and comment.owned_by_caller and comment.body == canonical_html
1765
+ )
1766
+ if len(observed) == 1:
1767
+ confirmation: Literal[
1768
+ "unique_created_comment_observed", "created_comment_identity_ambiguous", "created_comment_not_observed"
1769
+ ] = "unique_created_comment_observed"
1770
+ elif observed:
1771
+ confirmation = "created_comment_identity_ambiguous"
1772
+ else:
1773
+ confirmation = "created_comment_not_observed"
1774
+ return RecordCommentSaveResult(
1775
+ source=source,
1776
+ action="create",
1777
+ requested_comment=None,
1778
+ observed=observed,
1779
+ confirmation=confirmation,
1780
+ identity_source="readback" if len(observed) == 1 else "unknown",
1781
+ forwarded=response.code is WireSuccessCode.int_10,
1782
+ )
1783
+
1784
+ async def update_comment(self, comment: RecordComment, draft: RecordCommentDraft) -> RecordCommentSaveResult:
1785
+ source = comment.source
1786
+ before = await self.comments(notebook=source.notebook, record=source.record)
1787
+ current = next((item for item in before.comments if item.ref == comment.ref), None)
1788
+ if current is None or not current.owned_by_caller:
1789
+ raise MatelabUsageError("Only a currently observed caller-owned comment can be updated.")
1790
+ wire_html, canonical_html, binding_uid = self._render_comment_draft(source, draft)
1791
+ response = await self._save_comment_request(
1792
+ source=source, comment_id=comment.ref.comment_id, html=wire_html, binding_uid=binding_uid
1793
+ )
1794
+ after = await self._verified_comments(source, "record comment update")
1795
+ observed = tuple(item for item in after.comments if item.ref == comment.ref)
1796
+ confirmation: Literal[
1797
+ "updated_comment_observed_matching", "updated_comment_observed_mismatched", "updated_comment_not_observed"
1798
+ ]
1799
+ if not observed:
1800
+ confirmation = "updated_comment_not_observed"
1801
+ elif observed[0].body == canonical_html:
1802
+ confirmation = "updated_comment_observed_matching"
1803
+ else:
1804
+ confirmation = "updated_comment_observed_mismatched"
1805
+ return RecordCommentSaveResult(
1806
+ source=source,
1807
+ action="update",
1808
+ requested_comment=comment.ref,
1809
+ observed=observed,
1810
+ confirmation=confirmation,
1811
+ identity_source="readback" if observed else "unknown",
1812
+ forwarded=response.code is WireSuccessCode.int_10,
1813
+ )
1814
+
1815
+ async def delete_comment(self, comment: RecordComment) -> RecordCommentDeleteResult:
1816
+ source = comment.source
1817
+ before = await self.comments(notebook=source.notebook, record=source.record)
1818
+ current = next((item for item in before.comments if item.ref == comment.ref), None)
1819
+ if current is None or not current.owned_by_caller:
1820
+ raise MatelabUsageError("Only a currently observed caller-owned comment can be deleted.")
1821
+ try:
1822
+ request = WireDeleteRecordCommentRequest(
1823
+ eln=source.notebook.notebook_id, item_id=source.record.record_database_id, id=comment.ref.comment_id
1824
+ )
1825
+ except ValidationError:
1826
+ raise MatelabUsageError("Record comment identity does not satisfy the Integration Contract.") from None
1827
+ response = await self._transport.request(
1828
+ _DELETE_COMMENT, files=multipart_fields(request.model_dump(mode="json"))
1829
+ )
1830
+ after = await self._verified_comments(source, "record comment deletion")
1831
+ still_observed = any(item.ref == comment.ref for item in after.comments)
1832
+ return RecordCommentDeleteResult(
1833
+ comment=comment.ref,
1834
+ source=source,
1835
+ confirmation="still_observed_after_delete" if still_observed else "not_observed_after_delete",
1836
+ forwarded=response.code is WireSuccessCode.int_10,
1837
+ )
1838
+
1839
+ async def download_attachment(
1840
+ self, attachment: RecordAttachmentRef, *, byte_range: ByteRange | None = None
1841
+ ) -> DownloadStream:
1842
+ try:
1843
+ request = WireDownloadRecordAttachmentQuery.model_validate(
1844
+ {
1845
+ "eln": attachment.source.notebook.notebook_id,
1846
+ "i": attachment.source.record.record_database_id,
1847
+ "u": attachment.source.record.record_uid,
1848
+ "h": attachment.sha256,
1849
+ "f": attachment.filename,
1850
+ "s": attachment.size,
1851
+ "c": 0,
1852
+ "thumb": 0,
1853
+ }
1854
+ )
1855
+ except ValidationError:
1856
+ raise MatelabUsageError("Record attachment reference does not satisfy the Integration Contract.") from None
1857
+ return await self._transport.stream(
1858
+ _DOWNLOAD_RECORD_ATTACHMENT,
1859
+ payload=request.model_dump(mode="json"),
1860
+ range_header=byte_range.to_header() if byte_range is not None else None,
1861
+ )
1862
+
1863
+ async def download_comment_attachment(
1864
+ self, attachment: RecordCommentAttachmentRef, *, byte_range: ByteRange | None = None
1865
+ ) -> DownloadStream:
1866
+ """Download an observed comment attachment despite Provider issue PVD-038.
1867
+
1868
+ The SDK preserves the source notebook, record, and comment association,
1869
+ but the current Provider only authorizes the supplied notebook and does
1870
+ not independently verify that the attachment belongs to it.
1871
+ """
1872
+ try:
1873
+ request = WireDownloadRecordCommentAttachmentQuery.model_validate(
1874
+ {
1875
+ "eln": attachment.source.notebook.notebook_id,
1876
+ "i": attachment.comment.comment_id,
1877
+ "h": attachment.sha256,
1878
+ "s": attachment.size,
1879
+ "f": attachment.filename,
1880
+ }
1881
+ )
1882
+ except ValidationError:
1883
+ raise MatelabUsageError(
1884
+ "Record-comment attachment reference does not satisfy the Integration Contract."
1885
+ ) from None
1886
+ return await self._transport.stream(
1887
+ _DOWNLOAD_COMMENT_ATTACHMENT,
1888
+ payload=request.model_dump(mode="json"),
1889
+ range_header=byte_range.to_header() if byte_range is not None else None,
1890
+ )
1891
+
1892
+ async def read(
1893
+ self, *, notebook: NotebookRef, record: RecordRef, version: RecordVersionRef | None = None
1894
+ ) -> Record:
1895
+ if version is not None and (
1896
+ version.notebook_id != notebook.notebook_id
1897
+ or version.record_database_id != record.record_database_id
1898
+ or version.record_uid != record.record_uid
1899
+ ):
1900
+ raise MatelabUsageError("Record version ref does not belong to the selected notebook and record.")
1901
+ if version is not None:
1902
+ current = await self.read(notebook=notebook, record=record)
1903
+ if version not in (item.ref for item in current.versions):
1904
+ raise MatelabUsageError("Record version is no longer present in the current modify_log.")
1905
+ try:
1906
+ request = WireReadRecordQuery(
1907
+ eln=notebook.notebook_id,
1908
+ id=record.record_database_id,
1909
+ version=version.version_id if version is not None else 0,
1910
+ )
1911
+ except ValidationError:
1912
+ raise MatelabUsageError("Record identity does not satisfy the Integration Contract.") from None
1913
+ response = await self._transport.request(
1914
+ _READ_RECORD, payload=request.model_dump(mode="json", exclude_defaults=True, exclude_none=True)
1915
+ )
1916
+ if response.eln_id != notebook.notebook_id or response.item_id != record.record_database_id:
1917
+ raise MatelabProtocolError("Matelab returned a different notebook or record identity.")
1918
+ if version is None and response.uid != record.record_uid:
1919
+ raise MatelabProtocolError("Matelab returned a different record UID.")
1920
+
1921
+ versions = tuple(
1922
+ RecordVersionSummary(
1923
+ ref=RecordVersionRef(
1924
+ notebook_id=notebook.notebook_id,
1925
+ record_database_id=record.record_database_id,
1926
+ record_uid=record.record_uid,
1927
+ version_id=item.id,
1928
+ ),
1929
+ locked_at_ms=item.datetime_locked,
1930
+ modified_at_ms=item.datetime_modify,
1931
+ locked=bool(item.locked),
1932
+ author_userid=item.userid,
1933
+ author_name=item.username,
1934
+ )
1935
+ for item in response.modify_log
1936
+ )
1937
+ current_version = next((item.ref for item in versions if item.ref.version_id == response.current_version), None)
1938
+ modules = tuple(cast(dict[str, object], item.model_dump(mode="json")) for item in response.modules)
1939
+ source = RecordLocator(notebook=notebook, record=record)
1940
+ return Record(
1941
+ ref=record,
1942
+ notebook=notebook,
1943
+ selected_version=version,
1944
+ selected_record_uid=response.uid,
1945
+ title=response.title,
1946
+ keywords=tuple(response.keywords) if response.keywords is not None else None,
1947
+ modules=modules,
1948
+ template_id=response.template_id,
1949
+ modified_at_ms=response.datetime_modify,
1950
+ author_name=response.username,
1951
+ versions=versions,
1952
+ current_version=current_version,
1953
+ locked_at_ms=response.datetime_locked,
1954
+ locked=response.locked,
1955
+ signatures=tuple(
1956
+ RecordSignature(
1957
+ signer_userid=item.userid,
1958
+ username=item.username,
1959
+ photo_signature=item.photo_sign,
1960
+ signed_at=item.datetime_sign,
1961
+ timestamp_result=item.timestamp_result,
1962
+ user_result=item.user_result,
1963
+ )
1964
+ for item in response.signs
1965
+ ),
1966
+ signed=response.signed,
1967
+ owner=response.owner,
1968
+ editable=response.editable,
1969
+ notebook_title=response.eln_text,
1970
+ data_server=response.eln_server,
1971
+ notebook_keywords=tuple(cast(list[object], response.keyword_eln)),
1972
+ pdf_enabled=response.pdf_enable,
1973
+ template_render_metadata=response.template_show,
1974
+ template_title=response.template_text,
1975
+ template_intro=response.template_intro,
1976
+ attachments=_record_attachment_refs(modules, source=source, version=version),
1977
+ content_sha256=_content_sha256(modules),
1978
+ )
1979
+
1980
+ def _record_patch_payload(
1981
+ self, source: RecordLocator, before: Record, patch: RecordPatch
1982
+ ) -> tuple[dict[str, object], int]:
1983
+ modules: dict[str, Mapping[str, object]] = {}
1984
+ for module in before.modules:
1985
+ name = module.get("name")
1986
+ if not isinstance(name, str) or not name or name in modules:
1987
+ raise MatelabUsageError("Current record modules do not have unique non-empty names.")
1988
+ modules[name] = module
1989
+
1990
+ modify: list[dict[str, object]] = []
1991
+ delete_values: list[dict[str, object]] = []
1992
+ create_modules: list[dict[str, object]] = []
1993
+ delete_modules: list[dict[str, object]] = []
1994
+ add_values: list[dict[str, object]] = []
1995
+ claimed_paths: set[tuple[str | int, ...]] = set()
1996
+ used_staged: set[tuple[int, str]] = set()
1997
+
1998
+ def claim(path: tuple[str | int, ...]) -> None:
1999
+ if path in claimed_paths:
2000
+ raise MatelabUsageError("A record patch cannot mutate the same path more than once.")
2001
+ claimed_paths.add(path)
2002
+
2003
+ for update in patch.value_updates:
2004
+ self._validate_nonattachment_path(update.path, modules)
2005
+ if update.value is None:
2006
+ raise MatelabUsageError("Record value replacement cannot use null; use a deletion operation.")
2007
+ if _contains_native_attachment_reference(update.value):
2008
+ raise MatelabUsageError("Provider-native attachment references require a staged attachment object.")
2009
+ claim(update.path)
2010
+ modify.append({"path": list(update.path), "data": update.value})
2011
+
2012
+ for deletion in patch.value_deletions:
2013
+ self._validate_nonattachment_path(deletion.path, modules)
2014
+ claim(deletion.path)
2015
+ delete_values.append({"path": list(deletion.path)})
2016
+
2017
+ created_types: dict[str, str] = {}
2018
+ for creation in patch.module_creations:
2019
+ if creation.name in modules or creation.name in created_types:
2020
+ raise MatelabUsageError("A record patch cannot create an existing or duplicate module name.")
2021
+ if creation.module_type == "code" and creation.language is None:
2022
+ raise MatelabUsageError("A new code module requires a language.")
2023
+ if creation.data is not None and _ATTACHMENT_MARKER_PATTERN.search(creation.data):
2024
+ raise MatelabUsageError(
2025
+ "New rich-text attachments must use a dedicated rich-text update after the module exists."
2026
+ )
2027
+ if creation.data is not None and _contains_native_attachment_reference(creation.data):
2028
+ raise MatelabUsageError("Provider-native attachment references require a staged attachment object.")
2029
+ created_types[creation.name] = creation.module_type
2030
+ create_modules.append(
2031
+ {
2032
+ "name": creation.name,
2033
+ "type": creation.module_type,
2034
+ "data": creation.data,
2035
+ "language": creation.language,
2036
+ "style": creation.style,
2037
+ }
2038
+ )
2039
+
2040
+ deleted_names: set[str] = set()
2041
+ for deletion in patch.module_deletions:
2042
+ if deletion.name in deleted_names or deletion.name not in modules:
2043
+ raise MatelabUsageError("A record patch can delete each existing module at most once.")
2044
+ if any(
2045
+ attachment.location is not None and attachment.location.module == deletion.name
2046
+ for attachment in before.attachments
2047
+ ):
2048
+ raise MatelabUsageError(
2049
+ "A module containing attachments cannot be deleted through the safe record patch interface."
2050
+ )
2051
+ deleted_names.add(deletion.name)
2052
+ delete_modules.append({"name": deletion.name})
2053
+
2054
+ for addition in patch.value_additions:
2055
+ module_type = self._module_type(addition.module, modules, created_types)
2056
+ if module_type in {"files", "images", "richtext"} or addition.value_type == "file":
2057
+ raise MatelabUsageError("Attachment-bearing additions require a dedicated attachment operation.")
2058
+ if (
2059
+ addition.name is not None
2060
+ and module_type in {"form", "table"}
2061
+ and self._module_field_type(modules.get(addition.module), addition.name) == "file"
2062
+ ):
2063
+ raise MatelabUsageError("File fields require a dedicated attachment operation.")
2064
+ if _contains_native_attachment_reference(addition.value):
2065
+ raise MatelabUsageError("Provider-native attachment references require a staged attachment object.")
2066
+ add_values.append(
2067
+ {
2068
+ "module": addition.module,
2069
+ "name": addition.name,
2070
+ "type": addition.value_type,
2071
+ "row": addition.row,
2072
+ "data": addition.value,
2073
+ "path": list(addition.path) if addition.path is not None else None,
2074
+ }
2075
+ )
2076
+
2077
+ for change in patch.attachment_changes:
2078
+ if isinstance(change, RecordFormAttachmentRemoval):
2079
+ current = self._current_attachment(source, before, change.attachment)
2080
+ location = current.location
2081
+ if location is None or location.kind != "form_file" or location.field is None:
2082
+ raise MatelabUsageError("Form attachment removal requires an observed form-file occurrence.")
2083
+ if (
2084
+ sum(
2085
+ isinstance(other, RecordFormAttachmentRemoval)
2086
+ and other.attachment.location is not None
2087
+ and other.attachment.location.module == location.module
2088
+ and other.attachment.location.field == location.field
2089
+ for other in patch.attachment_changes
2090
+ )
2091
+ > 1
2092
+ ):
2093
+ raise MatelabUsageError(
2094
+ "Remove multiple form-file occurrences in separate read/patch cycles to avoid index shifts."
2095
+ )
2096
+ path = (location.module, location.field, location.position)
2097
+ claim(path)
2098
+ delete_values.append({"path": list(path)})
2099
+ continue
2100
+
2101
+ if isinstance(change, RecordTableAttachmentReplacement):
2102
+ current = self._current_attachment(source, before, change.existing)
2103
+ location = current.location
2104
+ if location is None or location.kind != "table_file" or location.field is None:
2105
+ raise MatelabUsageError("Table attachment replacement requires an observed table-file occurrence.")
2106
+ if any(
2107
+ deletion.path
2108
+ and deletion.path[0] == location.module
2109
+ and isinstance(deletion.path[1] if len(deletion.path) > 1 else None, int)
2110
+ for deletion in patch.value_deletions
2111
+ ):
2112
+ raise MatelabUsageError(
2113
+ "Table-row deletion and table-file replacement require separate read/patch cycles."
2114
+ )
2115
+ self._claim_staged_attachment(source, change.replacement, used_staged)
2116
+ path = (location.module, location.field, location.position)
2117
+ claim(path)
2118
+ modify.append({"path": list(path), "data": self._native_attachment_ref(change.replacement)})
2119
+ continue
2120
+
2121
+ if isinstance(change, RecordFilesAttachmentAppend):
2122
+ if self._module_type(change.module, modules, created_types) != "files":
2123
+ raise MatelabUsageError("Files attachment append requires a canonical files module.")
2124
+ self._claim_staged_attachment(source, change.attachment, used_staged)
2125
+ add_values.append(
2126
+ {
2127
+ "module": change.module,
2128
+ "data": {"file": self._native_attachment_ref(change.attachment), "text": change.caption},
2129
+ "path": list(change.path) if change.path is not None else None,
2130
+ }
2131
+ )
2132
+ continue
2133
+
2134
+ if isinstance(change, RecordFilesAttachmentReplacement):
2135
+ current = self._current_attachment(source, before, change.existing)
2136
+ location = current.location
2137
+ if location is None or location.kind != "files_module":
2138
+ raise MatelabUsageError(
2139
+ "Files attachment replacement requires an observed canonical files-module occurrence."
2140
+ )
2141
+ if any(
2142
+ isinstance(other, RecordFilesAttachmentRemoval)
2143
+ and other.attachment.location is not None
2144
+ and other.attachment.location.module == location.module
2145
+ for other in patch.attachment_changes
2146
+ ):
2147
+ raise MatelabUsageError(
2148
+ "Files removal and positional replacement require separate read/patch cycles."
2149
+ )
2150
+ self._claim_staged_attachment(source, change.replacement, used_staged)
2151
+ path = (location.module, location.position)
2152
+ claim(path)
2153
+ modify.append(
2154
+ {
2155
+ "path": list(path),
2156
+ "data": {"file": self._native_attachment_ref(change.replacement), "text": change.caption},
2157
+ }
2158
+ )
2159
+ continue
2160
+
2161
+ current = self._current_attachment(source, before, change.attachment)
2162
+ location = current.location
2163
+ if location is None or location.kind not in {"files_module", "images_module"}:
2164
+ raise MatelabUsageError("Files attachment removal requires an observed files/images-module occurrence.")
2165
+ same_hash = tuple(
2166
+ attachment
2167
+ for attachment in before.attachments
2168
+ if attachment.location is not None
2169
+ and attachment.location.kind == location.kind
2170
+ and attachment.location.module == location.module
2171
+ and attachment.sha256 == current.sha256
2172
+ )
2173
+ if len(same_hash) != 1:
2174
+ raise MatelabUsageError(
2175
+ "The Provider deletes files by hash, so a duplicate-hash occurrence is unsafe (PVD-016)."
2176
+ )
2177
+ path = (location.module, current.sha256)
2178
+ claim(path)
2179
+ delete_values.append({"path": list(path)})
2180
+
2181
+ for rich_text in patch.rich_text_updates:
2182
+ module = modules.get(rich_text.module)
2183
+ if module is None or module.get("type") != "richtext":
2184
+ raise MatelabUsageError("Rich-text attachment updates require an existing richtext module.")
2185
+ path = (rich_text.module,)
2186
+ claim(path)
2187
+ modify.append({"path": list(path), "data": self._render_record_rich_text(source, rich_text, used_staged)})
2188
+
2189
+ operation_count = len(modify) + len(delete_values) + len(create_modules) + len(delete_modules) + len(add_values)
2190
+ if operation_count == 0:
2191
+ raise MatelabUsageError("Record patch must contain at least one mutation.")
2192
+ payload: dict[str, object] = {}
2193
+ if modify:
2194
+ payload["modify"] = modify
2195
+ if delete_values:
2196
+ payload["del"] = delete_values
2197
+ if create_modules:
2198
+ payload["addModule"] = create_modules
2199
+ if delete_modules:
2200
+ payload["delModule"] = delete_modules
2201
+ if add_values:
2202
+ payload["add"] = add_values
2203
+ return payload, operation_count
2204
+
2205
+ @staticmethod
2206
+ def _module_type(name: str, modules: Mapping[str, Mapping[str, object]], created_types: Mapping[str, str]) -> str:
2207
+ if name in created_types:
2208
+ return created_types[name]
2209
+ module = modules.get(name)
2210
+ module_type = module.get("type") if module is not None else None
2211
+ if not isinstance(module_type, str):
2212
+ raise MatelabUsageError("Record patch targets a module that is not currently present.")
2213
+ return module_type
2214
+
2215
+ @staticmethod
2216
+ def _module_field_type(module: Mapping[str, object] | None, field_name: str) -> str | None:
2217
+ if module is None or not isinstance(raw_fields := module.get("data"), list):
2218
+ return None
2219
+ for raw_field in cast(list[object], raw_fields):
2220
+ if not isinstance(raw_field, dict):
2221
+ continue
2222
+ field_mapping = cast(dict[str, object], raw_field)
2223
+ if field_mapping.get("name") == field_name and isinstance(field_mapping.get("type"), str):
2224
+ return cast(str, field_mapping["type"])
2225
+ return None
2226
+
2227
+ @classmethod
2228
+ def _validate_nonattachment_path(
2229
+ cls, path: tuple[str | int, ...], modules: Mapping[str, Mapping[str, object]]
2230
+ ) -> None:
2231
+ if not path or not isinstance(path[0], str) or not path[0]:
2232
+ raise MatelabUsageError("Record patch paths must begin with an exact module name.")
2233
+ module = modules.get(path[0])
2234
+ if module is None:
2235
+ raise MatelabUsageError("Record patch path targets a module that is not currently present.")
2236
+ module_type = module.get("type")
2237
+ if module_type in {"files", "images", "richtext"}:
2238
+ raise MatelabUsageError("Attachment-bearing modules require a dedicated mutation type.")
2239
+ if (
2240
+ module_type in {"form", "table"}
2241
+ and len(path) >= 2
2242
+ and isinstance(path[1], str)
2243
+ and cls._module_field_type(module, path[1]) == "file"
2244
+ ):
2245
+ raise MatelabUsageError("File fields require a dedicated attachment mutation type.")
2246
+
2247
+ @staticmethod
2248
+ def _current_attachment(
2249
+ source: RecordLocator, before: Record, requested: RecordAttachmentRef
2250
+ ) -> RecordAttachmentRef:
2251
+ if requested.source != source or requested.observed_version is not None or requested.location is None:
2252
+ raise MatelabUsageError("Attachment mutation requires a current occurrence observed on the target record.")
2253
+ current = next(
2254
+ (
2255
+ attachment
2256
+ for attachment in before.attachments
2257
+ if attachment.location == requested.location
2258
+ and attachment.sha256 == requested.sha256
2259
+ and attachment.filename == requested.filename
2260
+ and attachment.size == requested.size
2261
+ ),
2262
+ None,
2263
+ )
2264
+ if current is None:
2265
+ raise MatelabUsageError("The selected attachment occurrence changed before the mutation.")
2266
+ return current
2267
+
2268
+ @staticmethod
2269
+ def _claim_staged_attachment(
2270
+ source: RecordLocator, attachment: StagedRecordAttachment, used: set[tuple[int, str]]
2271
+ ) -> None:
2272
+ if attachment.source != source:
2273
+ raise MatelabUsageError("Staged record attachment belongs to a different record.")
2274
+ if (
2275
+ attachment.temporary_file_id < 1
2276
+ or not attachment.binding.uid
2277
+ or len(attachment.binding.uid) > 45
2278
+ or not attachment.filename
2279
+ or "\r" in attachment.filename
2280
+ or "\n" in attachment.filename
2281
+ or attachment.size < 0
2282
+ or re.fullmatch(r"[0-9a-f]{64}", attachment.sha256) is None
2283
+ ):
2284
+ raise MatelabUsageError("Staged record attachment metadata is invalid.")
2285
+ identity = (attachment.temporary_file_id, attachment.binding.uid)
2286
+ if identity in used:
2287
+ raise MatelabUsageError("One staged record attachment can be consumed only once in a patch.")
2288
+ used.add(identity)
2289
+
2290
+ @staticmethod
2291
+ def _native_attachment_ref(attachment: StagedRecordAttachment) -> str:
2292
+ if "\r" in attachment.filename or "\n" in attachment.filename:
2293
+ raise MatelabUsageError("Attachment filename cannot contain line breaks.")
2294
+ return f"#hash{{{attachment.filename}, {attachment.sha256}}}"
2295
+
2296
+ def _render_record_rich_text(
2297
+ self, source: RecordLocator, update: RecordRichTextUpdate, used_staged: set[tuple[int, str]]
2298
+ ) -> str:
2299
+ attachments = dict(update.attachments)
2300
+ if _contains_native_attachment_reference(update.html):
2301
+ raise MatelabUsageError("Provider-native attachment references require a staged attachment object.")
2302
+ markers = set(_ATTACHMENT_MARKER_PATTERN.findall(update.html))
2303
+ if markers != set(attachments):
2304
+ raise MatelabUsageError("Rich-text attachment names must exactly match matelab-attachment:<name> markers.")
2305
+ rendered = update.html
2306
+ for name, attachment in attachments.items():
2307
+ self._claim_staged_attachment(source, attachment, used_staged)
2308
+ rendered = rendered.replace(f"matelab-attachment:{name}", self._native_attachment_ref(attachment))
2309
+ if _ATTACHMENT_MARKER_PATTERN.search(rendered):
2310
+ raise MatelabUsageError("Rich-text content contains an unresolved attachment marker.")
2311
+ return rendered
2312
+
2313
+ @staticmethod
2314
+ def _validate_upload_metadata(
2315
+ *, filename: str, content: IO[bytes] | bytes, size: int, sha256: str, content_type: str
2316
+ ) -> None:
2317
+ if (
2318
+ not filename
2319
+ or len(filename) > 1000
2320
+ or "\r" in filename
2321
+ or "\n" in filename
2322
+ or type(size) is not int
2323
+ or size < 0
2324
+ or re.fullmatch(r"[0-9a-f]{64}", sha256) is None
2325
+ or not content_type
2326
+ or "\r" in content_type
2327
+ or "\n" in content_type
2328
+ ):
2329
+ raise MatelabUsageError("Attachment metadata does not satisfy the Integration Contract.")
2330
+ if isinstance(content, bytes) and (len(content) != size or hashlib.sha256(content).hexdigest() != sha256):
2331
+ raise MatelabUsageError("Attachment bytes do not match the declared size and SHA-256.")
2332
+
2333
+ @staticmethod
2334
+ def _parse_comment_upload_query(query: str) -> tuple[int, int, str, int, str, str]:
2335
+ try:
2336
+ parsed = parse_qs(query, keep_blank_values=True, strict_parsing=True)
2337
+ except ValueError:
2338
+ raise MatelabProtocolError("Matelab returned an invalid comment attachment reference.") from None
2339
+ if any(len(values) != 1 for values in parsed.values()) or not {"eln", "id", "h", "s", "f"} <= parsed.keys():
2340
+ raise MatelabProtocolError("Matelab returned an invalid comment attachment reference.")
2341
+ try:
2342
+ notebook_id = int(parsed["eln"][0])
2343
+ temporary_id = int(parsed["id"][0])
2344
+ size = int(parsed["s"][0])
2345
+ except ValueError:
2346
+ raise MatelabProtocolError("Matelab returned an invalid comment attachment reference.") from None
2347
+ sha256 = parsed["h"][0]
2348
+ filename = parsed["f"][0]
2349
+ if (
2350
+ notebook_id < 1
2351
+ or temporary_id < 1
2352
+ or size < 0
2353
+ or re.fullmatch(r"[0-9a-f]{64}", sha256) is None
2354
+ or not filename
2355
+ ):
2356
+ raise MatelabProtocolError("Matelab returned an invalid comment attachment reference.")
2357
+ raw_filename = next((part.partition("=")[2] for part in query.split("&") if part.partition("=")[0] == "f"), "")
2358
+ canonical_reference = f"elnurl://h={sha256}&s={size}&f={raw_filename}"
2359
+ return notebook_id, temporary_id, sha256, size, filename, canonical_reference
2360
+
2361
+ def _render_comment_draft(self, source: RecordLocator, draft: RecordCommentDraft) -> tuple[str, str, str]:
2362
+ attachments = dict(draft.attachments)
2363
+ if _COMMENT_TEMP_URL_PATTERN.search(draft.html):
2364
+ raise MatelabUsageError("Temporary comment URLs require a staged attachment object.")
2365
+ markers = set(_ATTACHMENT_MARKER_PATTERN.findall(draft.html))
2366
+ if markers != set(attachments):
2367
+ raise MatelabUsageError("Comment attachment names must exactly match matelab-attachment:<name> markers.")
2368
+ binding_uids = {attachment.binding.uid for attachment in attachments.values()}
2369
+ if len(binding_uids) > 1:
2370
+ raise MatelabUsageError("All attachments in one comment save must use the same upload binding.")
2371
+ wire_html = draft.html
2372
+ canonical_html = draft.html
2373
+ for name, attachment in attachments.items():
2374
+ if (
2375
+ attachment.source != source
2376
+ or attachment.temporary_file_id < 1
2377
+ or not attachment.binding.uid
2378
+ or len(attachment.binding.uid) > 45
2379
+ or not attachment._temporary_url # pyright: ignore[reportPrivateUsage]
2380
+ or not attachment._canonical_reference # pyright: ignore[reportPrivateUsage]
2381
+ or attachment.size < 0
2382
+ or re.fullmatch(r"[0-9a-f]{64}", attachment.sha256) is None
2383
+ ):
2384
+ raise MatelabUsageError("Staged comment attachment belongs to a different record.")
2385
+ marker = f"matelab-attachment:{name}"
2386
+ wire_html = wire_html.replace(
2387
+ marker,
2388
+ attachment._temporary_url, # pyright: ignore[reportPrivateUsage]
2389
+ )
2390
+ canonical_html = canonical_html.replace(
2391
+ marker,
2392
+ attachment._canonical_reference, # pyright: ignore[reportPrivateUsage]
2393
+ )
2394
+ if _ATTACHMENT_MARKER_PATTERN.search(wire_html):
2395
+ raise MatelabUsageError("Comment content contains an unresolved attachment marker.")
2396
+ return wire_html.strip(), canonical_html.strip(), next(iter(binding_uids), "")
2397
+
2398
+ async def _save_comment_request(
2399
+ self, *, source: RecordLocator, comment_id: int, html: str, binding_uid: str
2400
+ ) -> WireForwardableSuccessResponse:
2401
+ try:
2402
+ request = WireSaveRecordCommentRequest(
2403
+ id=comment_id,
2404
+ eln=source.notebook.notebook_id,
2405
+ item_id=source.record.record_database_id,
2406
+ more=html,
2407
+ uid=binding_uid,
2408
+ )
2409
+ except ValidationError:
2410
+ raise MatelabUsageError("Record comment does not satisfy the Integration Contract.") from None
2411
+ return await self._transport.request(_SAVE_COMMENT, files=multipart_fields(request.model_dump(mode="json")))
2412
+
2413
+ async def _verified_read(self, source: RecordLocator, action: str) -> Record:
2414
+ try:
2415
+ return await self.read(notebook=source.notebook, record=source.record)
2416
+ except MatelabError as exc:
2417
+ raise MatelabVerificationError(
2418
+ f"Matelab acknowledged the {action}, but readback failed; the mutation may have persisted."
2419
+ ) from exc
2420
+
2421
+ async def _verified_relations(self, source: RecordLocator, action: str) -> RecordRelations:
2422
+ try:
2423
+ return await self.relations(notebook=source.notebook, record=source.record)
2424
+ except MatelabError as exc:
2425
+ raise MatelabVerificationError(
2426
+ f"Matelab acknowledged the {action}, but readback failed; the mutation may have persisted."
2427
+ ) from exc
2428
+
2429
+ async def _verified_comments(self, source: RecordLocator, action: str) -> RecordComments:
2430
+ try:
2431
+ return await self.comments(notebook=source.notebook, record=source.record)
2432
+ except MatelabError as exc:
2433
+ raise MatelabVerificationError(
2434
+ f"Matelab acknowledged the {action}, but readback failed; the mutation may have persisted."
2435
+ ) from exc
2436
+
2437
+ @staticmethod
2438
+ def _notebook_selector(notebook: NotebookRef) -> str | dict[str, object]:
2439
+ title = Records._notebook_title(notebook)
2440
+ if notebook.scope == "shared":
2441
+ return {"title": title, "user": notebook.owner_userid}
2442
+ return title
2443
+
2444
+ @staticmethod
2445
+ def _notebook_title(notebook: NotebookRef) -> str:
2446
+ if notebook.title is None:
2447
+ raise MatelabUsageError("Notebook selector cannot use a null Provider title.")
2448
+ return notebook.title
2449
+
2450
+ async def _verified_list(self, notebook: NotebookRef, action: str) -> RecordCollection:
2451
+ try:
2452
+ return await self.list(notebook=notebook)
2453
+ except MatelabError as exc:
2454
+ raise MatelabVerificationError(
2455
+ f"Matelab acknowledged the {action}, but readback failed; the mutation may have persisted."
2456
+ ) from exc
2457
+
2458
+ async def _verified_lifecycle_state(
2459
+ self, notebook: NotebookRef, action: str
2460
+ ) -> tuple[RecordCollection, DeletedRecordPage]:
2461
+ try:
2462
+ return (await self.list(notebook=notebook), await self.recycle_bin(notebook, page=1, page_size=256))
2463
+ except MatelabError as exc:
2464
+ raise MatelabVerificationError(
2465
+ f"Matelab acknowledged the {action}, but readback failed; the mutation may have persisted."
2466
+ ) from exc
2467
+
2468
+ @staticmethod
2469
+ def _record_summary(item: WireRecordListItem) -> RecordSummary:
2470
+ return RecordSummary(
2471
+ ref=RecordRef(record_database_id=item.id, record_uid=item.sn),
2472
+ title=item.title,
2473
+ summary=item.comm,
2474
+ created_at=item.datetime_create,
2475
+ modified_at=item.datetime_modify,
2476
+ keywords=tuple(item.keywords),
2477
+ template_id=item.template_id,
2478
+ subtype_id=item.subtype,
2479
+ encrypted=bool(item.encrypt),
2480
+ audit_count=item.log,
2481
+ )
2482
+
2483
+ @staticmethod
2484
+ def _subtype_summary(wire: WireRecordSubtype) -> RecordSubtypeSummary:
2485
+ return RecordSubtypeSummary(
2486
+ subtype_id=wire.id,
2487
+ title=wire.showtext,
2488
+ parent_subtype_id=wire.parent,
2489
+ record_count=wire.items_num,
2490
+ child_subtype_count=wire.subtype_num,
2491
+ )
2492
+
2493
+ @staticmethod
2494
+ def _comment(wire: WireRecordComment, *, source: RecordLocator) -> RecordComment:
2495
+ ref = RecordCommentRef(comment_id=wire.id)
2496
+ attachments = tuple(
2497
+ RecordCommentAttachmentRef(
2498
+ source=source, comment=ref, sha256=attachment.sha256, filename=attachment.filename, size=attachment.size
2499
+ )
2500
+ for attachment in _rich_text_attachments(wire.data)
2501
+ if attachment.size >= 1 and len(attachment.filename) <= 1000
2502
+ )
2503
+ return RecordComment(
2504
+ source=source,
2505
+ ref=ref,
2506
+ modified_at=wire.datetime,
2507
+ body=wire.data,
2508
+ author_name=wire.username,
2509
+ owned_by_caller=wire.owner,
2510
+ attachments=attachments,
2511
+ )