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