matelab-python-sdk 0.2.0a8__tar.gz → 0.2.0a9__tar.gz
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_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/CONTEXT.md +2 -2
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/PKG-INFO +1 -1
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/contracts/matelab-integration-v1.lock.json +3 -3
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/contracts/matelab-integration-v1.openapi.yaml +63 -13
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/docs/api.md +45 -15
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/docs/operation-coverage.yaml +1 -1
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/pyproject.toml +1 -1
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/_generated/models.py +1 -1
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/domains/records.py +49 -22
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/conftest.py +13 -1
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_record_lifecycle.py +101 -4
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_record_mutations.py +143 -1
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/typing/consumer.py +16 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/uv.lock +1 -1
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/.env.example +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/.github/workflows/release.yml +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/.gitignore +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/AGENTS.md +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/LICENSE +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/NOTICE +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/README.md +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/docs/agent-architecture.md +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/docs/agent-workflows.md +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/docs/development.md +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/scripts/check_installed_package.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/scripts/check_release.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/scripts/generate_models.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/__init__.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/_generated/__init__.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/_hashes.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/_transport.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/_wire_validation.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/client.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/domains/__init__.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/domains/_attachment_content.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/domains/cloud_drive.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/domains/groups.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/domains/literature.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/domains/notebooks.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/domains/templates.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/domains/uploads.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/domains/users.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/errors.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/py.typed +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/session.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/streaming.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/provider/test_provider_smoke.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_async_attachment_upload.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_client.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_cloud_drive.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_comments_and_downloads.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_consumer_smoke.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_errors.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_extended_records.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_generation.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_groups.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_notebook_lifecycle.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_notebooks.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_operation_coverage.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_records.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_release.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_streaming.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_template_lifecycle.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_templates.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_transport.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_uploads_and_literature.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_users.py +0 -0
- {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/typing/pyproject.toml +0 -0
|
@@ -86,8 +86,8 @@ A notebook-scoped hierarchical container used to organize records. Records at th
|
|
|
86
86
|
_Avoid_: Subtype, record type, template
|
|
87
87
|
|
|
88
88
|
**Record import value**:
|
|
89
|
-
A template-shaped value composed of JSON-compatible scalars and containers, with a staged attachment
|
|
90
|
-
allowed wherever the template expects a file.
|
|
89
|
+
A template-shaped value composed of JSON-compatible scalars and containers, with a staged attachment or existing
|
|
90
|
+
cloud file allowed wherever the template expects a file.
|
|
91
91
|
_Avoid_: Arbitrary object, stored record module, Provider attachment reference
|
|
92
92
|
|
|
93
93
|
**Encrypted record modules**:
|
{matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/contracts/matelab-integration-v1.lock.json
RENAMED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
"schema_version": 1,
|
|
3
3
|
"contract_line": "integration-v1",
|
|
4
4
|
"release": {
|
|
5
|
-
"tag": "v2026.
|
|
6
|
-
"commit": "
|
|
5
|
+
"tag": "v2026.11.1",
|
|
6
|
+
"commit": "402a158052995850e0b7fad5ed5399c8a2eed294"
|
|
7
7
|
},
|
|
8
8
|
"openapi": {
|
|
9
9
|
"source_path": "openapi/matelab-integration-v1.openapi.yaml",
|
|
10
10
|
"snapshot": "matelab-integration-v1.openapi.yaml",
|
|
11
|
-
"sha256": "
|
|
11
|
+
"sha256": "f9d3bd0baa4e5e70fe13870c21d9be70e40e2686831a0ffcc4632b39f83a3257"
|
|
12
12
|
}
|
|
13
13
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
openapi: 3.1.0
|
|
2
2
|
info:
|
|
3
3
|
title: Matelab Integration Contract
|
|
4
|
-
version: "2026.
|
|
4
|
+
version: "2026.11.1"
|
|
5
5
|
description: |
|
|
6
6
|
Current PHP wire contract for generating SDKs against the selected
|
|
7
7
|
`matelab-server` Provider version.
|
|
@@ -10,15 +10,20 @@ info:
|
|
|
10
10
|
and attachment operations that resolve a data server support the
|
|
11
11
|
`localhost` branch; remote forwarding is excluded pending downstream
|
|
12
12
|
wire evidence and end-to-end verification (PVD-049).
|
|
13
|
+
Integration requests omit the optional browser `Version` header. If it is
|
|
14
|
+
supplied, PHP applies `intval` and compares it with the current minimum
|
|
15
|
+
20261029; a lower value returns HTTP 200 with string `code` and `errcode`
|
|
16
|
+
equal to `reload`. That browser refresh branch is outside the integer
|
|
17
|
+
response envelopes selected by this integration contract.
|
|
13
18
|
x-contract-line: integration-v1
|
|
14
19
|
x-provider-evidence:
|
|
15
20
|
repository: /home/tnzhu/projects/matelab-server
|
|
16
|
-
commit:
|
|
21
|
+
commit: 00f17b6ce0906bb78b0a3e9bb8c50abee90ca3d0
|
|
17
22
|
verificationRuntime:
|
|
18
|
-
baseUrl:
|
|
23
|
+
baseUrl: https://matelab.iphy.ac.cn/eln-test/api
|
|
19
24
|
container: matelab-test-server-1
|
|
20
25
|
coreSourceComparison: sha256-exact
|
|
21
|
-
coreSourceFileCount:
|
|
26
|
+
coreSourceFileCount: 152
|
|
22
27
|
dataServerScope: localhost
|
|
23
28
|
files:
|
|
24
29
|
- controller/Actions.php
|
|
@@ -56,9 +61,9 @@ info:
|
|
|
56
61
|
- sql/matelab.sql
|
|
57
62
|
- sql/recommend.sql
|
|
58
63
|
x-provider-audit:
|
|
59
|
-
currentCommit:
|
|
60
|
-
previousReleaseEvidenceCommit:
|
|
61
|
-
lastProviderVerifiedCommit:
|
|
64
|
+
currentCommit: 00f17b6ce0906bb78b0a3e9bb8c50abee90ca3d0
|
|
65
|
+
previousReleaseEvidenceCommit: 161c4560ae1cb6267d46841d6fe8adc8270ee1bc
|
|
66
|
+
lastProviderVerifiedCommit: 00f17b6ce0906bb78b0a3e9bb8c50abee90ca3d0
|
|
62
67
|
contractStatus: current-provider-aligned
|
|
63
68
|
# Summary of changes adopted by current Supported Routes since the last
|
|
64
69
|
# released contract, including contract corrections. The Provider diff is
|
|
@@ -66,8 +71,8 @@ info:
|
|
|
66
71
|
# updates against that fixed release baseline; use [] if no relevant changes.
|
|
67
72
|
# Entries are not per-change verification results or a cumulative history.
|
|
68
73
|
incorporatedChanges:
|
|
69
|
-
- {operationId:
|
|
70
|
-
- {operationId:
|
|
74
|
+
- {operationId: importRecordDataset, change: authorized-cloud-drive-hash-references-pass-permission-query}
|
|
75
|
+
- {operationId: updateRecordData, change: authorized-cloud-drive-hash-references-pass-permission-query}
|
|
71
76
|
x-consumer-evidence:
|
|
72
77
|
- repository: /home/tnzhu/projects/matelab-mcp
|
|
73
78
|
commit: 94a129b2df0ef0fbcf179c10550fb7c9a5dd2e6f
|
|
@@ -88,10 +93,11 @@ info:
|
|
|
88
93
|
identity must come from token issuance. ELN-group and notebook-list
|
|
89
94
|
calls still provide Consumer evidence for their canonical GET forms.
|
|
90
95
|
- repository: /home/tnzhu/projects/matelab-front
|
|
91
|
-
commit:
|
|
96
|
+
commit: 55bc8d95d9d4ef02841621f0b793d273cea66137
|
|
92
97
|
files:
|
|
93
98
|
- src/App.vue
|
|
94
99
|
- src/fn/index.js
|
|
100
|
+
- src/store/index.js
|
|
95
101
|
- src/components/fullview.vue
|
|
96
102
|
- src/components/userSelect.vue
|
|
97
103
|
- src/components/users.vue
|
|
@@ -107,7 +113,8 @@ info:
|
|
|
107
113
|
The current Front dev commit reads environment/menu from GET
|
|
108
114
|
`/actions/app` and obtains session identity from token issuance.
|
|
109
115
|
The shared upload client sends byte offsets to `/actions/bigupload`
|
|
110
|
-
and uses the acknowledged offset for the next fragment.
|
|
116
|
+
and uses the acknowledged offset for the next fragment. GET requests
|
|
117
|
+
also carry the browser version stored in `src/store/index.js`.
|
|
111
118
|
Existing record, comment, template, literature and cloud-drive calls
|
|
112
119
|
retain their Consumer use cases; no new routes are added by this audit.
|
|
113
120
|
x-record-attachment-staging:
|
|
@@ -1627,7 +1634,13 @@ paths:
|
|
|
1627
1634
|
Template-shaped file values supplied by the request must be strings in
|
|
1628
1635
|
the `#file{name}` or `#hash{filename.ext, hash}` form. The current
|
|
1629
1636
|
validator rejects even valid `#base64` input (PVD-042); inline base64
|
|
1630
|
-
is outside the callable contract.
|
|
1637
|
+
is outside the callable contract. Existing cloud-drive files can be
|
|
1638
|
+
referenced by `#hash{filename.ext, hash}` when the selected notebook
|
|
1639
|
+
owner's userid owns the personal-root file (`root_id=0`), or has a
|
|
1640
|
+
`files_permissions` row for its shared root. An existing cloud quote
|
|
1641
|
+
alone does not grant access. The reference creates a record attachment
|
|
1642
|
+
quote without consuming the cloud-drive file.
|
|
1643
|
+
In particular,
|
|
1631
1644
|
table/form file arrays cannot contain explicit JSON null values on the
|
|
1632
1645
|
current Provider. To create empty table file cells, omit the file column
|
|
1633
1646
|
and let the Provider complete it with canonical null cells.
|
|
@@ -1645,6 +1658,17 @@ paths:
|
|
|
1645
1658
|
template: Example Template
|
|
1646
1659
|
dataset:
|
|
1647
1660
|
- {uid: GENERATED-WIRE-UID, title: Imported Example, data: {Notes: Sanitized}, keyword: calibration}
|
|
1661
|
+
existing_cloud_attachment:
|
|
1662
|
+
summary: Reuse an authorized cloud-drive file without consuming it
|
|
1663
|
+
value:
|
|
1664
|
+
eln: Example Notebook
|
|
1665
|
+
template: Example Template
|
|
1666
|
+
dataset:
|
|
1667
|
+
- uid: REC-CLOUD-EXAMPLE-001
|
|
1668
|
+
title: Existing cloud attachment
|
|
1669
|
+
data:
|
|
1670
|
+
Attachments:
|
|
1671
|
+
File: ["#hash{evidence.zip, 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef}"]
|
|
1648
1672
|
preuploaded_attachment:
|
|
1649
1673
|
summary: Upload-event UID and future record UID are deliberately different
|
|
1650
1674
|
value:
|
|
@@ -1678,6 +1702,12 @@ paths:
|
|
|
1678
1702
|
value: {code: 0, msg: Data imported, errcode: 0, errmsg: Data imported, id: [9004], eln_id: 12}
|
|
1679
1703
|
error:
|
|
1680
1704
|
value: {code: 4, msg: Multiple errors, errcode: 4, errmsg: Multiple errors, errs: [Invalid imported field]}
|
|
1705
|
+
inaccessible_hash:
|
|
1706
|
+
value:
|
|
1707
|
+
code: 2
|
|
1708
|
+
msg: The referenced hash 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef does not exist
|
|
1709
|
+
errcode: 2
|
|
1710
|
+
errmsg: The referenced hash 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef does not exist
|
|
1681
1711
|
/eln_api/export:
|
|
1682
1712
|
post:
|
|
1683
1713
|
operationId: exportRecords
|
|
@@ -1772,7 +1802,12 @@ paths:
|
|
|
1772
1802
|
strings containing canonical `elnurl://h=...&s=...&f=...` references.
|
|
1773
1803
|
The Provider scans the incoming richtext value, resolves hashes against
|
|
1774
1804
|
caller-owned staging or authorized existing file quotes, and binds the
|
|
1775
|
-
resulting attachments while saving the record.
|
|
1805
|
+
resulting attachments while saving the record. Template-shaped
|
|
1806
|
+
`#hash{filename.ext, hash}` values also accept existing cloud-drive
|
|
1807
|
+
files: permission is checked against the selected notebook owner's
|
|
1808
|
+
userid, using personal-root ownership or a `files_permissions` row for
|
|
1809
|
+
the shared root. The cloud file is retained; an inaccessible hash is
|
|
1810
|
+
rejected before record content is written.
|
|
1776
1811
|
|
|
1777
1812
|
This operation does not modify record title or other record metadata.
|
|
1778
1813
|
`UpdateRecordRequest` rejects a top-level `title`; the Front uses the
|
|
@@ -1827,6 +1862,15 @@ paths:
|
|
|
1827
1862
|
examples:
|
|
1828
1863
|
update:
|
|
1829
1864
|
value: {eln: Example Notebook, uid: REC-EXAMPLE-001, modify: [{path: [Notes], data: Sanitized update}]}
|
|
1865
|
+
add_existing_cloud_attachment_field:
|
|
1866
|
+
value:
|
|
1867
|
+
eln: Example Notebook
|
|
1868
|
+
uid: REC-EXAMPLE-001
|
|
1869
|
+
add:
|
|
1870
|
+
- module: Attachments
|
|
1871
|
+
name: Cloud file
|
|
1872
|
+
type: file
|
|
1873
|
+
data: ["#hash{evidence.zip, 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef}"]
|
|
1830
1874
|
add_preuploaded_attachment_field:
|
|
1831
1875
|
value:
|
|
1832
1876
|
eln: Example Notebook
|
|
@@ -1930,6 +1974,12 @@ paths:
|
|
|
1930
1974
|
value: {code: 0, msg: Collaboration data update completed, errcode: 0, errmsg: Collaboration data update completed}
|
|
1931
1975
|
error:
|
|
1932
1976
|
value: {code: 4, msg: Multiple errors, errcode: 4, errmsg: Multiple errors, errs: [Invalid update path]}
|
|
1977
|
+
inaccessible_hash:
|
|
1978
|
+
value:
|
|
1979
|
+
code: 2
|
|
1980
|
+
msg: The referenced hash 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef does not exist
|
|
1981
|
+
errcode: 2
|
|
1982
|
+
errmsg: The referenced hash 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef does not exist
|
|
1933
1983
|
/eln_api/delete:
|
|
1934
1984
|
post:
|
|
1935
1985
|
operationId: deleteRecordsByUid
|
|
@@ -5,18 +5,20 @@ See the [README](../README.md) for installation and quick start and [CONTEXT.md]
|
|
|
5
5
|
|
|
6
6
|
## Contract
|
|
7
7
|
|
|
8
|
-
The SDK is pinned to the immutable `matelab-spec v2026.
|
|
8
|
+
The SDK is pinned to the immutable `matelab-spec v2026.11.1` Contract Release. The sole pin,
|
|
9
9
|
[contracts/matelab-integration-v1.lock.json](../contracts/matelab-integration-v1.lock.json), records peeled commit
|
|
10
|
-
`
|
|
11
|
-
and SHA-256 `
|
|
12
|
-
`
|
|
10
|
+
`402a158052995850e0b7fad5ed5399c8a2eed294`, source `openapi/matelab-integration-v1.openapi.yaml`, the local snapshot path,
|
|
11
|
+
and SHA-256 `f9d3bd0baa4e5e70fe13870c21d9be70e40e2686831a0ffcc4632b39f83a3257`. The annotated tag object is
|
|
12
|
+
`18dbfd99f36ee7fc8cd620a251c7973d86e8dedd`.
|
|
13
13
|
|
|
14
|
-
The Contract uses Provider source evidence from `
|
|
14
|
+
The Contract uses Provider source evidence from `00f17b6ce0906bb78b0a3e9bb8c50abee90ca3d0`; v2026.11.1 validation ran
|
|
15
15
|
against an isolated test instance and does not establish production behavior. The Front Consumer evidence
|
|
16
|
-
`
|
|
16
|
+
`55bc8d95d9d4ef02841621f0b793d273cea66137` informs cross-Consumer capability review but does not define the SDK interface.
|
|
17
17
|
|
|
18
18
|
This snapshot covers the Workerman runtime with the `localhost` data-server branch for ELN records and attachments;
|
|
19
19
|
remote data-server forwarding is outside its verified callable scope (PVD-049).
|
|
20
|
+
The SDK does not add the optional browser `Version` header. Integrators should also omit it from injected HTTP clients:
|
|
21
|
+
an outdated value triggers a string `reload` envelope outside the integration response schemas.
|
|
20
22
|
|
|
21
23
|
## Design
|
|
22
24
|
|
|
@@ -191,7 +193,7 @@ record_ids = await client.records.import_dataset(
|
|
|
191
193
|
```
|
|
192
194
|
|
|
193
195
|
`RecordImportItem.data` uses the recursive `RecordImportValue` type: JSON-compatible scalars and nested mappings,
|
|
194
|
-
lists, or tuples may contain a `StagedAttachment` wherever the template expects a file. Arbitrary Python
|
|
196
|
+
lists, or tuples may contain a `StagedAttachment` or `CloudFile` wherever the template expects a file. Arbitrary Python
|
|
195
197
|
objects are outside the interface and are also rejected at runtime before a Provider request.
|
|
196
198
|
|
|
197
199
|
`content` accepts `bytes`, a synchronous `IO[bytes]`, or an `AsyncIterable[bytes]`. Callers do not supply a size,
|
|
@@ -219,6 +221,33 @@ The Provider supplies no staging status, abort, TTL, atomicity, idempotency, or
|
|
|
219
221
|
persist handles must durably claim their own `uploaded -> finalizing/indeterminate` transition before calling a
|
|
220
222
|
finalizer.
|
|
221
223
|
|
|
224
|
+
### Existing cloud-drive attachments
|
|
225
|
+
|
|
226
|
+
Use a `CloudFile` returned by `cloud_drive.list()` directly in template-shaped import data or in a new form file field:
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
from matelab import RecordFormAttachmentFieldAddition, RecordImportItem
|
|
230
|
+
|
|
231
|
+
cloud_file = (await client.cloud_drive.list()).files[0]
|
|
232
|
+
item = RecordImportItem(
|
|
233
|
+
record_uid="REC-CLOUD-001", title="Cloud attachment", data={"Attachments": {"File": [cloud_file]}}
|
|
234
|
+
)
|
|
235
|
+
await client.records.import_dataset(notebook=notebook, template_title="Example Template", items=(item,))
|
|
236
|
+
await client.records.update(
|
|
237
|
+
source, (RecordFormAttachmentFieldAddition(module="Attachments", name="Cloud file", attachment=cloud_file),)
|
|
238
|
+
)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
The SDK encodes the reference; the Provider retains the source cloud file. The same `CloudFile` may appear in
|
|
242
|
+
multiple records or requests; it does not enter the SDK's one-shot staging claims. Imports containing any
|
|
243
|
+
`StagedAttachment` retain the single-record and single-use restrictions. Cloud-file form additions may be combined
|
|
244
|
+
with other non-conflicting changes; other attachment update intents still require `StagedAttachment`.
|
|
245
|
+
|
|
246
|
+
The Provider checks access for the selected notebook owner's userid: personal-root ownership or permission on the
|
|
247
|
+
file's shared root is required; an existing file quote alone grants no access. Inaccessible hashes are rejected
|
|
248
|
+
before record content is written. Avoid overlapping reuse with a pending upload of the same bytes: Provider hash
|
|
249
|
+
lookup can select and consume that upload and use its filename, while retaining the source cloud file.
|
|
250
|
+
|
|
222
251
|
### Attachment-bearing record update boundary
|
|
223
252
|
|
|
224
253
|
`records.update(source, changes, ...)` accepts one non-empty sequence of `RecordChange` intents. Value updates and
|
|
@@ -254,7 +283,8 @@ await client.records.update(
|
|
|
254
283
|
)
|
|
255
284
|
```
|
|
256
285
|
|
|
257
|
-
|
|
286
|
+
With `StagedAttachment`, these three operations use the Provider's staged-name finalizer and must be the only mutation
|
|
287
|
+
in their update request.
|
|
258
288
|
`RecordTableFileCellSet` requires an existing file column and a cell whose immediate canonical value is exactly
|
|
259
289
|
`null`. `RecordTableRowAttachmentAppend` addresses the new row by the row count from the SDK's immediate read and
|
|
260
290
|
supports one file column. A locally rejected target does not spend the handle; once transport starts, an ambiguous
|
|
@@ -318,9 +348,10 @@ preserves that omission in `Record.modules` and does not invent a timestamp; nul
|
|
|
318
348
|
attachment shapes remain response errors. This exception can be removed after the Provider canonicalizes current and
|
|
319
349
|
historical record content.
|
|
320
350
|
|
|
321
|
-
|
|
322
|
-
finalization request
|
|
323
|
-
must not be called while a concurrent editor is known to be active. The Provider offers
|
|
351
|
+
Staged attachment inputs reject handles from another notebook or authenticated uploader and duplicate use within one
|
|
352
|
+
finalization request. Raw Provider attachment strings remain outside the public interface. Row/index-based operations
|
|
353
|
+
use an immediate SDK read and must not be called while a concurrent editor is known to be active. The Provider offers
|
|
354
|
+
no expected hash or revision,
|
|
324
355
|
so the SDK does not present its read-before-write checks as concurrency control.
|
|
325
356
|
|
|
326
357
|
File-bearing structure changes do not consume staging:
|
|
@@ -354,10 +385,9 @@ This requests only a canonical record-content mutation. A successful response ac
|
|
|
354
385
|
it does not prove attachment-quote cleanup or byte deletion. Collaboration-pending is reported as
|
|
355
386
|
`"pending"`, not persisted.
|
|
356
387
|
|
|
357
|
-
`RecordRichTextUpdate` accepts
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
used for the other finalizer through the same client.
|
|
388
|
+
`RecordRichTextUpdate` accepts `StagedAttachment`. Imports containing a staged attachment are limited to one record;
|
|
389
|
+
callers must use a fresh staged attachment for each request. Once an import or update request using a staged attachment
|
|
390
|
+
starts, that handle cannot be retried or used for the other finalizer through the same client.
|
|
361
391
|
|
|
362
392
|
## Operation coverage
|
|
363
393
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "matelab-python-sdk"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.0a9"
|
|
8
8
|
description = "Reusable async Python client for the Matelab Integration Contract"
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
10
|
keywords = ["matelab", "electronic-lab-notebook", "eln", "asyncio", "sdk"]
|
|
@@ -16,6 +16,7 @@ from matelab._hashes import LOWERCASE_SHA256_PATTERN
|
|
|
16
16
|
from matelab._transport import Encoding, Operation, SessionTransport, StreamOperation
|
|
17
17
|
from matelab._wire_validation import WireValue, encode_json_field, validate_multipart_request, validate_request
|
|
18
18
|
from matelab.domains._attachment_content import prepare_attachment_content
|
|
19
|
+
from matelab.domains.cloud_drive import CloudFile
|
|
19
20
|
from matelab.domains.notebooks import NotebookPermissions, NotebookRef, validate_notebook_scope
|
|
20
21
|
from matelab.domains.uploads import UploadBindingRef
|
|
21
22
|
from matelab.domains.users import UserRef
|
|
@@ -432,6 +433,7 @@ RecordImportValue: TypeAlias = (
|
|
|
432
433
|
| bool
|
|
433
434
|
| None
|
|
434
435
|
| StagedAttachment
|
|
436
|
+
| CloudFile
|
|
435
437
|
| Mapping[str, "RecordImportValue"]
|
|
436
438
|
| list["RecordImportValue"]
|
|
437
439
|
| tuple["RecordImportValue", ...]
|
|
@@ -488,11 +490,11 @@ class RecordValueAddition:
|
|
|
488
490
|
|
|
489
491
|
@dataclass(frozen=True, slots=True)
|
|
490
492
|
class RecordFormAttachmentFieldAddition:
|
|
491
|
-
"""Add one
|
|
493
|
+
"""Add one file field from a staged upload or an existing cloud file."""
|
|
492
494
|
|
|
493
495
|
module: str
|
|
494
496
|
name: str
|
|
495
|
-
attachment: StagedAttachment
|
|
497
|
+
attachment: StagedAttachment | CloudFile
|
|
496
498
|
|
|
497
499
|
|
|
498
500
|
@dataclass(frozen=True, slots=True)
|
|
@@ -929,8 +931,8 @@ def _current_attachment(source: RecordLocator, current: Record, requested: Recor
|
|
|
929
931
|
return attachment
|
|
930
932
|
|
|
931
933
|
|
|
932
|
-
def _iter_import_attachments(value: RecordImportValue) -> Iterator[StagedAttachment]:
|
|
933
|
-
if isinstance(value, StagedAttachment):
|
|
934
|
+
def _iter_import_attachments(value: RecordImportValue) -> Iterator[StagedAttachment | CloudFile]:
|
|
935
|
+
if isinstance(value, StagedAttachment | CloudFile):
|
|
934
936
|
yield value
|
|
935
937
|
elif isinstance(value, Mapping):
|
|
936
938
|
for item in value.values():
|
|
@@ -940,13 +942,13 @@ def _iter_import_attachments(value: RecordImportValue) -> Iterator[StagedAttachm
|
|
|
940
942
|
yield from _iter_import_attachments(item)
|
|
941
943
|
|
|
942
944
|
|
|
943
|
-
def _iter_dataset_attachments(items: Sequence[RecordImportItem]) -> Iterator[StagedAttachment]:
|
|
945
|
+
def _iter_dataset_attachments(items: Sequence[RecordImportItem]) -> Iterator[StagedAttachment | CloudFile]:
|
|
944
946
|
for item in items:
|
|
945
947
|
for value in item.data.values():
|
|
946
948
|
yield from _iter_import_attachments(value)
|
|
947
949
|
|
|
948
950
|
|
|
949
|
-
def _iter_update_attachments(changes: tuple[RecordChange, ...]) -> Iterator[StagedAttachment]:
|
|
951
|
+
def _iter_update_attachments(changes: tuple[RecordChange, ...]) -> Iterator[StagedAttachment | CloudFile]:
|
|
950
952
|
for change in changes:
|
|
951
953
|
if isinstance(
|
|
952
954
|
change,
|
|
@@ -965,6 +967,7 @@ def _iter_update_attachments(changes: tuple[RecordChange, ...]) -> Iterator[Stag
|
|
|
965
967
|
def _validate_staged_name_update_shape(changes: tuple[RecordChange, ...]) -> None:
|
|
966
968
|
has_finalizer = any(
|
|
967
969
|
isinstance(change, RecordFormAttachmentFieldAddition | RecordTableFileCellSet | RecordTableRowAttachmentAppend)
|
|
970
|
+
and isinstance(change.attachment, StagedAttachment)
|
|
968
971
|
or isinstance(change, RecordFilesAttachmentAppend)
|
|
969
972
|
and bool(change.folder_path)
|
|
970
973
|
for change in changes
|
|
@@ -994,7 +997,7 @@ def _record_update_requires_current_state(changes: tuple[RecordChange, ...]) ->
|
|
|
994
997
|
)
|
|
995
998
|
|
|
996
999
|
|
|
997
|
-
def _encode_record_attachment_reference(attachment: StagedAttachment) -> str:
|
|
1000
|
+
def _encode_record_attachment_reference(attachment: StagedAttachment | CloudFile) -> str:
|
|
998
1001
|
return f"#hash{{{attachment.filename}, {attachment.sha256}}}"
|
|
999
1002
|
|
|
1000
1003
|
|
|
@@ -1925,7 +1928,7 @@ class Records:
|
|
|
1925
1928
|
| RecordTableRowAttachmentAppend
|
|
1926
1929
|
| RecordFilesAttachmentAppend,
|
|
1927
1930
|
):
|
|
1928
|
-
self.
|
|
1931
|
+
self._compile_record_attachment_addition(change, patch)
|
|
1929
1932
|
else:
|
|
1930
1933
|
self._compile_observed_record_attachment_change(
|
|
1931
1934
|
source, current, change, removals, value_deletions, patch
|
|
@@ -1939,7 +1942,7 @@ class Records:
|
|
|
1939
1942
|
patch.claim(path)
|
|
1940
1943
|
patch.modify.append({"path": list(path), "data": _encode_record_rich_text(change)})
|
|
1941
1944
|
|
|
1942
|
-
def
|
|
1945
|
+
def _compile_record_attachment_addition(
|
|
1943
1946
|
self,
|
|
1944
1947
|
change: (
|
|
1945
1948
|
RecordFormAttachmentFieldAddition
|
|
@@ -1953,19 +1956,31 @@ class Records:
|
|
|
1953
1956
|
patch.reject_deleted_module(change.module)
|
|
1954
1957
|
module = patch.modules.get(change.module)
|
|
1955
1958
|
if module is None or module.get("type") != "form":
|
|
1956
|
-
raise MatelabUsageError("A record attachment
|
|
1957
|
-
if
|
|
1958
|
-
|
|
1959
|
+
raise MatelabUsageError("A record attachment requires an existing form module.")
|
|
1960
|
+
if (
|
|
1961
|
+
not change.name
|
|
1962
|
+
or _module_field(module, change.name) is not None
|
|
1963
|
+
or any(
|
|
1964
|
+
isinstance(addition, dict)
|
|
1965
|
+
and addition.get("module") == change.module
|
|
1966
|
+
and addition.get("name") == change.name
|
|
1967
|
+
for addition in patch.add_values
|
|
1968
|
+
)
|
|
1969
|
+
):
|
|
1970
|
+
raise MatelabUsageError("A record attachment requires a new, unique non-empty form field name.")
|
|
1971
|
+
patch.claim((change.module, change.name))
|
|
1972
|
+
attachment = change.attachment
|
|
1973
|
+
if isinstance(attachment, CloudFile):
|
|
1974
|
+
model = wire_models.AddValueOperation
|
|
1975
|
+
reference = _encode_record_attachment_reference(attachment)
|
|
1976
|
+
else:
|
|
1977
|
+
model = wire_models.AddNewFormFileFieldWithStagedAttachmentOperation
|
|
1978
|
+
reference = f"#file{{{attachment.binding.uid}}}"
|
|
1959
1979
|
patch.add_values.append(
|
|
1960
1980
|
_operation_payload(
|
|
1961
|
-
|
|
1962
|
-
{
|
|
1963
|
-
|
|
1964
|
-
"name": change.name,
|
|
1965
|
-
"type": "file",
|
|
1966
|
-
"data": [f"#file{{{change.attachment.binding.uid}}}"],
|
|
1967
|
-
},
|
|
1968
|
-
"New form-file attachment finalizer does not satisfy the contract.",
|
|
1981
|
+
model,
|
|
1982
|
+
{"module": change.module, "name": change.name, "type": "file", "data": [reference]},
|
|
1983
|
+
"New form-file attachment does not satisfy the contract.",
|
|
1969
1984
|
)
|
|
1970
1985
|
)
|
|
1971
1986
|
return
|
|
@@ -2235,11 +2250,21 @@ class Records:
|
|
|
2235
2250
|
)
|
|
2236
2251
|
|
|
2237
2252
|
def _staged_attachment_keys(
|
|
2238
|
-
self, notebook: NotebookRef, attachments: Iterable[StagedAttachment]
|
|
2253
|
+
self, notebook: NotebookRef, attachments: Iterable[StagedAttachment | CloudFile]
|
|
2239
2254
|
) -> set[_StagedAttachmentKey]:
|
|
2240
2255
|
uploader = UserRef(userid=self._transport.require_session().identity.userid)
|
|
2241
2256
|
keys: set[_StagedAttachmentKey] = set()
|
|
2242
2257
|
for attachment in attachments:
|
|
2258
|
+
if isinstance(attachment, CloudFile):
|
|
2259
|
+
if (
|
|
2260
|
+
not attachment.filename
|
|
2261
|
+
or len(attachment.filename) > 255
|
|
2262
|
+
or "\r" in attachment.filename
|
|
2263
|
+
or "\n" in attachment.filename
|
|
2264
|
+
or LOWERCASE_SHA256_PATTERN.fullmatch(attachment.sha256) is None
|
|
2265
|
+
):
|
|
2266
|
+
raise MatelabUsageError("Cloud-file metadata does not satisfy the Integration Contract.")
|
|
2267
|
+
continue
|
|
2243
2268
|
if attachment.notebook != notebook:
|
|
2244
2269
|
raise MatelabUsageError("Staged attachment belongs to a different notebook.")
|
|
2245
2270
|
if attachment.uploader != uploader:
|
|
@@ -2262,9 +2287,11 @@ class Records:
|
|
|
2262
2287
|
"""Encode one domain import value."""
|
|
2263
2288
|
if isinstance(value, StagedAttachment):
|
|
2264
2289
|
return f"#file{{{value.binding.uid}}}"
|
|
2290
|
+
if isinstance(value, CloudFile):
|
|
2291
|
+
return _encode_record_attachment_reference(value)
|
|
2265
2292
|
if isinstance(value, str):
|
|
2266
2293
|
if _contains_native_attachment_reference(value):
|
|
2267
|
-
raise MatelabUsageError("Provider-native attachment references require a
|
|
2294
|
+
raise MatelabUsageError("Provider-native attachment references require a typed attachment object.")
|
|
2268
2295
|
return value
|
|
2269
2296
|
if isinstance(value, Mapping):
|
|
2270
2297
|
return {name: self._encode_import_value(item) for name, item in value.items()}
|
|
@@ -8,7 +8,7 @@ from typing import Any, cast
|
|
|
8
8
|
import pytest
|
|
9
9
|
import yaml
|
|
10
10
|
|
|
11
|
-
from matelab import Identity, Session, Token
|
|
11
|
+
from matelab import CloudFile, CloudFileRef, Identity, Session, Token
|
|
12
12
|
|
|
13
13
|
ROOT = Path(__file__).resolve().parents[1]
|
|
14
14
|
SPEC = ROOT / "contracts" / "matelab-integration-v1.openapi.yaml"
|
|
@@ -24,6 +24,18 @@ def provider_session() -> Session:
|
|
|
24
24
|
)
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def cloud_file() -> CloudFile:
|
|
29
|
+
return CloudFile(
|
|
30
|
+
ref=CloudFileRef(cloud_file_id=81),
|
|
31
|
+
filename="evidence.zip",
|
|
32
|
+
sha256="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
|
33
|
+
size=5,
|
|
34
|
+
modified_at="2026-01-02 08:00:00",
|
|
35
|
+
description="",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
27
39
|
@pytest.fixture
|
|
28
40
|
def provider_envelope() -> Callable[..., dict[str, object]]:
|
|
29
41
|
def build(*, code: int = 0, message: str = "", **payload: object) -> dict[str, object]:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
from collections.abc import Callable
|
|
3
|
+
from dataclasses import replace
|
|
3
4
|
from typing import cast
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
@@ -7,10 +8,12 @@ from httpx2 import AsyncClient, MockTransport, Request, Response
|
|
|
7
8
|
|
|
8
9
|
from matelab import (
|
|
9
10
|
AsyncMatelab,
|
|
11
|
+
CloudFile,
|
|
10
12
|
Identity,
|
|
11
13
|
MatelabAuthenticationError,
|
|
12
14
|
MatelabUsageError,
|
|
13
15
|
NotebookRef,
|
|
16
|
+
RecordFormAttachmentFieldAddition,
|
|
14
17
|
RecordImportItem,
|
|
15
18
|
RecordLocator,
|
|
16
19
|
RecordRef,
|
|
@@ -152,8 +155,93 @@ async def test_import_dataset_validates_wire_shape_without_interpreting_notebook
|
|
|
152
155
|
|
|
153
156
|
|
|
154
157
|
@pytest.mark.asyncio
|
|
155
|
-
async def
|
|
156
|
-
provider_session: Session, provider_envelope: Callable[..., dict[str, object]]
|
|
158
|
+
async def test_import_dataset_reuses_cloud_file_across_records_and_requests(
|
|
159
|
+
cloud_file: CloudFile, provider_session: Session, provider_envelope: Callable[..., dict[str, object]]
|
|
160
|
+
) -> None:
|
|
161
|
+
notebook = NotebookRef(notebook_id=23, title="Shared Notebook", owner_userid=202, scope="shared")
|
|
162
|
+
request_count = 0
|
|
163
|
+
|
|
164
|
+
async def handler(request: Request) -> Response:
|
|
165
|
+
nonlocal request_count
|
|
166
|
+
request_count += 1
|
|
167
|
+
assert request.method == "POST"
|
|
168
|
+
assert request.url.path == "/api/eln_api/import"
|
|
169
|
+
assert request.headers["Authorization"] == "Bearer access"
|
|
170
|
+
assert request.headers["Content-Type"] == "application/json"
|
|
171
|
+
expected_records = (1, 2) if request_count == 1 else (3,)
|
|
172
|
+
assert json.loads(request.content) == {
|
|
173
|
+
"eln": "Shared Notebook",
|
|
174
|
+
"user": 202,
|
|
175
|
+
"template": "Example Template",
|
|
176
|
+
"dataset": [
|
|
177
|
+
{
|
|
178
|
+
"uid": f"IMPORT-CLOUD-{number}",
|
|
179
|
+
"title": "Existing cloud attachment",
|
|
180
|
+
"data": {
|
|
181
|
+
"Attachments": {
|
|
182
|
+
"File": [
|
|
183
|
+
"#hash{evidence.zip, 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef}"
|
|
184
|
+
]
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
for number in expected_records
|
|
189
|
+
],
|
|
190
|
+
}
|
|
191
|
+
return Response(200, json=provider_envelope(id=[9100 + number for number in expected_records], eln_id=23))
|
|
192
|
+
|
|
193
|
+
async with AsyncClient(base_url="https://matelab.example/api", transport=MockTransport(handler)) as http_client:
|
|
194
|
+
client = AsyncMatelab("https://ignored.example", session=provider_session, http_client=http_client)
|
|
195
|
+
results: list[tuple[int, ...]] = []
|
|
196
|
+
for numbers in ((1, 2), (3,)):
|
|
197
|
+
items = tuple(
|
|
198
|
+
RecordImportItem(
|
|
199
|
+
record_uid=f"IMPORT-CLOUD-{number}",
|
|
200
|
+
title="Existing cloud attachment",
|
|
201
|
+
data={"Attachments": {"File": (cloud_file,)}},
|
|
202
|
+
)
|
|
203
|
+
for number in numbers
|
|
204
|
+
)
|
|
205
|
+
results.append(
|
|
206
|
+
await client.records.import_dataset(notebook=notebook, template_title="Example Template", items=items)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
assert results == [(9101, 9102), (9103,)]
|
|
210
|
+
assert request_count == 2
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@pytest.mark.asyncio
|
|
214
|
+
@pytest.mark.parametrize(
|
|
215
|
+
"metadata", ({"filename": ""}, {"filename": "x" * 256}, {"filename": "file\n.zip"}, {"sha256": "A" * 64})
|
|
216
|
+
)
|
|
217
|
+
async def test_invalid_cloud_file_metadata_is_rejected_before_import_or_update_http(
|
|
218
|
+
metadata: dict[str, str], cloud_file: CloudFile, provider_session: Session
|
|
219
|
+
) -> None:
|
|
220
|
+
invalid_file = replace(cloud_file, **metadata)
|
|
221
|
+
notebook = NotebookRef(notebook_id=12, title="Owned Notebook", owner_userid=101, scope="owned")
|
|
222
|
+
source = RecordLocator(notebook=notebook, record=RecordRef(record_id=9001, record_uid="REC-001"))
|
|
223
|
+
|
|
224
|
+
async def handler(_: Request) -> Response:
|
|
225
|
+
raise AssertionError("Invalid cloud-file metadata must be rejected before HTTP")
|
|
226
|
+
|
|
227
|
+
async with AsyncClient(base_url="https://matelab.example/api", transport=MockTransport(handler)) as http_client:
|
|
228
|
+
client = AsyncMatelab("https://ignored.example", session=provider_session, http_client=http_client)
|
|
229
|
+
with pytest.raises(MatelabUsageError, match="Cloud-file metadata"):
|
|
230
|
+
_ = await client.records.import_dataset(
|
|
231
|
+
notebook=notebook,
|
|
232
|
+
template_title="Example Template",
|
|
233
|
+
items=(RecordImportItem(record_uid="IMPORT-001", title="Cloud file", data={"File": [invalid_file]}),),
|
|
234
|
+
)
|
|
235
|
+
with pytest.raises(MatelabUsageError, match="Cloud-file metadata"):
|
|
236
|
+
_ = await client.records.update(
|
|
237
|
+
source,
|
|
238
|
+
(RecordFormAttachmentFieldAddition(module="Attachments", name="Cloud file", attachment=invalid_file),),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@pytest.mark.asyncio
|
|
243
|
+
async def test_import_dataset_consumes_only_the_staged_attachment_in_a_mixed_import(
|
|
244
|
+
cloud_file: CloudFile, provider_session: Session, provider_envelope: Callable[..., dict[str, object]]
|
|
157
245
|
) -> None:
|
|
158
246
|
notebook = NotebookRef(notebook_id=12, title="Owned Notebook", owner_userid=101, scope="owned")
|
|
159
247
|
staged = StagedAttachment(
|
|
@@ -164,7 +252,9 @@ async def test_import_dataset_consumes_one_staged_import_attachment_once(
|
|
|
164
252
|
sha256="a" * 64,
|
|
165
253
|
)
|
|
166
254
|
item = RecordImportItem(
|
|
167
|
-
record_uid="IMPORT-STAGED-001",
|
|
255
|
+
record_uid="IMPORT-STAGED-001",
|
|
256
|
+
title="Imported attachment",
|
|
257
|
+
data={"Attachments": {"File": [staged, cloud_file]}},
|
|
168
258
|
)
|
|
169
259
|
request_count = 0
|
|
170
260
|
|
|
@@ -179,7 +269,14 @@ async def test_import_dataset_consumes_one_staged_import_attachment_once(
|
|
|
179
269
|
{
|
|
180
270
|
"uid": "IMPORT-STAGED-001",
|
|
181
271
|
"title": "Imported attachment",
|
|
182
|
-
"data": {
|
|
272
|
+
"data": {
|
|
273
|
+
"Attachments": {
|
|
274
|
+
"File": [
|
|
275
|
+
"#file{staged-import}",
|
|
276
|
+
"#hash{evidence.zip, 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef}",
|
|
277
|
+
]
|
|
278
|
+
}
|
|
279
|
+
},
|
|
183
280
|
}
|
|
184
281
|
],
|
|
185
282
|
}
|
|
@@ -10,8 +10,10 @@ from pydantic import JsonValue
|
|
|
10
10
|
|
|
11
11
|
from matelab import (
|
|
12
12
|
AsyncMatelab,
|
|
13
|
+
CloudFile,
|
|
13
14
|
Identity,
|
|
14
15
|
MatelabAuthenticationError,
|
|
16
|
+
MatelabProviderError,
|
|
15
17
|
MatelabTransportError,
|
|
16
18
|
MatelabUsageError,
|
|
17
19
|
NotebookRef,
|
|
@@ -630,6 +632,132 @@ async def test_upload_attachment_uses_one_generated_staging_identity_and_scopes_
|
|
|
630
632
|
assert digest not in repr(staged)
|
|
631
633
|
|
|
632
634
|
|
|
635
|
+
@pytest.mark.asyncio
|
|
636
|
+
async def test_update_reuses_cloud_file_for_form_fields_with_other_changes(
|
|
637
|
+
cloud_file: CloudFile, provider_session: Session, provider_envelope: Callable[..., dict[str, object]]
|
|
638
|
+
) -> None:
|
|
639
|
+
source = RecordLocator(
|
|
640
|
+
notebook=NotebookRef(notebook_id=23, title="Shared Notebook", owner_userid=202, scope="shared"),
|
|
641
|
+
record=RecordRef(record_id=9001, record_uid="REC-001"),
|
|
642
|
+
)
|
|
643
|
+
modules: list[dict[str, object]] = [
|
|
644
|
+
{"name": "Attachments", "type": "form", "data": [{"name": "Reviewed", "type": "bool", "data": False}]}
|
|
645
|
+
]
|
|
646
|
+
paths: list[str] = []
|
|
647
|
+
|
|
648
|
+
async def handler(request: Request) -> Response:
|
|
649
|
+
paths.append(request.url.path)
|
|
650
|
+
if request.url.path == "/api/eln_items/item_view":
|
|
651
|
+
assert request.method == "GET"
|
|
652
|
+
return Response(200, json=_item_view(provider_envelope, source, modules))
|
|
653
|
+
assert request.method == "POST"
|
|
654
|
+
assert request.url.path == "/api/eln_api/update"
|
|
655
|
+
assert request.headers["Authorization"] == "Bearer access"
|
|
656
|
+
assert request.headers["Content-Type"] == "application/json"
|
|
657
|
+
assert json.loads(request.content) == {
|
|
658
|
+
"eln": "Shared Notebook",
|
|
659
|
+
"user": 202,
|
|
660
|
+
"uid": "REC-001",
|
|
661
|
+
"modify": [{"path": ["Attachments", "Reviewed"], "data": True}],
|
|
662
|
+
"add": [
|
|
663
|
+
{
|
|
664
|
+
"module": "Attachments",
|
|
665
|
+
"name": name,
|
|
666
|
+
"type": "file",
|
|
667
|
+
"data": ["#hash{evidence.zip, 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef}"],
|
|
668
|
+
}
|
|
669
|
+
for name in ("Cloud file", "Copy")
|
|
670
|
+
],
|
|
671
|
+
}
|
|
672
|
+
return Response(200, json=provider_envelope(message="Database data update successful"))
|
|
673
|
+
|
|
674
|
+
async with AsyncClient(base_url="https://matelab.example/api", transport=MockTransport(handler)) as http_client:
|
|
675
|
+
client = AsyncMatelab("https://ignored.example", session=provider_session, http_client=http_client)
|
|
676
|
+
result = await client.records.update(
|
|
677
|
+
source,
|
|
678
|
+
(
|
|
679
|
+
RecordValueUpdate(path=("Attachments", "Reviewed"), value=True),
|
|
680
|
+
RecordFormAttachmentFieldAddition(module="Attachments", name="Cloud file", attachment=cloud_file),
|
|
681
|
+
RecordFormAttachmentFieldAddition(module="Attachments", name="Copy", attachment=cloud_file),
|
|
682
|
+
),
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
assert result == "persisted"
|
|
686
|
+
assert paths == ["/api/eln_items/item_view", "/api/eln_api/update"]
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
@pytest.mark.asyncio
|
|
690
|
+
@pytest.mark.parametrize("field_name", ("Existing", "Duplicate", "Scalar addition"))
|
|
691
|
+
async def test_update_cloud_file_rejects_existing_or_duplicate_form_field_names(
|
|
692
|
+
field_name: str,
|
|
693
|
+
cloud_file: CloudFile,
|
|
694
|
+
provider_session: Session,
|
|
695
|
+
provider_envelope: Callable[..., dict[str, object]],
|
|
696
|
+
) -> None:
|
|
697
|
+
source = _source()
|
|
698
|
+
read_count = 0
|
|
699
|
+
|
|
700
|
+
async def handler(request: Request) -> Response:
|
|
701
|
+
nonlocal read_count
|
|
702
|
+
assert request.url.path == "/api/eln_items/item_view"
|
|
703
|
+
read_count += 1
|
|
704
|
+
modules = [
|
|
705
|
+
{"name": "Attachments", "type": "form", "data": [{"name": "Existing", "type": "text", "data": "value"}]}
|
|
706
|
+
]
|
|
707
|
+
return Response(200, json=_item_view(provider_envelope, source, modules))
|
|
708
|
+
|
|
709
|
+
change = RecordFormAttachmentFieldAddition(module="Attachments", name=field_name, attachment=cloud_file)
|
|
710
|
+
if field_name == "Existing":
|
|
711
|
+
changes = (change,)
|
|
712
|
+
elif field_name == "Duplicate":
|
|
713
|
+
changes = (change, change)
|
|
714
|
+
else:
|
|
715
|
+
changes = (RecordValueAddition(module="Attachments", name=field_name, value_type="text", value="value"), change)
|
|
716
|
+
async with AsyncClient(base_url="https://matelab.example/api", transport=MockTransport(handler)) as http_client:
|
|
717
|
+
client = AsyncMatelab("https://ignored.example", session=provider_session, http_client=http_client)
|
|
718
|
+
with pytest.raises(MatelabUsageError):
|
|
719
|
+
_ = await client.records.update(source, changes)
|
|
720
|
+
|
|
721
|
+
assert read_count == 1
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
@pytest.mark.asyncio
|
|
725
|
+
async def test_cloud_file_can_be_used_again_after_provider_rejects_the_record_update(
|
|
726
|
+
cloud_file: CloudFile,
|
|
727
|
+
provider_session: Session,
|
|
728
|
+
provider_envelope: Callable[..., dict[str, object]],
|
|
729
|
+
contract_response_example: Callable[[str, str], dict[str, object]],
|
|
730
|
+
) -> None:
|
|
731
|
+
source = _source()
|
|
732
|
+
mutation_count = 0
|
|
733
|
+
|
|
734
|
+
async def handler(request: Request) -> Response:
|
|
735
|
+
nonlocal mutation_count
|
|
736
|
+
if request.url.path == "/api/eln_items/item_view":
|
|
737
|
+
return Response(
|
|
738
|
+
200, json=_item_view(provider_envelope, source, [{"name": "Attachments", "type": "form", "data": []}])
|
|
739
|
+
)
|
|
740
|
+
assert request.url.path == "/api/eln_api/update"
|
|
741
|
+
mutation_count += 1
|
|
742
|
+
if mutation_count == 1:
|
|
743
|
+
return Response(200, json=contract_response_example("updateRecordData", "inaccessible_hash"))
|
|
744
|
+
return Response(200, json=provider_envelope(message="Database data update successful"))
|
|
745
|
+
|
|
746
|
+
change = RecordFormAttachmentFieldAddition(module="Attachments", name="Cloud file", attachment=cloud_file)
|
|
747
|
+
async with AsyncClient(base_url="https://matelab.example/api", transport=MockTransport(handler)) as http_client:
|
|
748
|
+
client = AsyncMatelab("https://ignored.example", session=provider_session, http_client=http_client)
|
|
749
|
+
with pytest.raises(MatelabProviderError) as caught:
|
|
750
|
+
_ = await client.records.update(source, (change,))
|
|
751
|
+
assert caught.value.provider_code == 2
|
|
752
|
+
assert not caught.value.retryable
|
|
753
|
+
assert cloud_file.sha256 not in str(caught.value)
|
|
754
|
+
assert mutation_count == 1
|
|
755
|
+
result = await client.records.update(source, (change,))
|
|
756
|
+
|
|
757
|
+
assert mutation_count == 2
|
|
758
|
+
assert result == "persisted"
|
|
759
|
+
|
|
760
|
+
|
|
633
761
|
@pytest.mark.asyncio
|
|
634
762
|
async def test_update_finalizes_record_attachment_as_one_new_form_field(
|
|
635
763
|
provider_session: Session, provider_envelope: Callable[..., dict[str, object]]
|
|
@@ -1180,7 +1308,9 @@ async def test_table_file_cell_prewrite_rejection_preserves_stage_for_a_later_va
|
|
|
1180
1308
|
|
|
1181
1309
|
|
|
1182
1310
|
@pytest.mark.asyncio
|
|
1183
|
-
async def test_one_shot_finalizer_rejects_mixed_or_multiple_intents_before_read(
|
|
1311
|
+
async def test_one_shot_finalizer_rejects_mixed_or_multiple_intents_before_read(
|
|
1312
|
+
cloud_file: CloudFile, provider_session: Session
|
|
1313
|
+
) -> None:
|
|
1184
1314
|
source = _source()
|
|
1185
1315
|
paths: list[str] = []
|
|
1186
1316
|
|
|
@@ -1222,6 +1352,18 @@ async def test_one_shot_finalizer_rejects_mixed_or_multiple_intents_before_read(
|
|
|
1222
1352
|
),
|
|
1223
1353
|
),
|
|
1224
1354
|
)
|
|
1355
|
+
with pytest.raises(MatelabUsageError, match="only mutation"):
|
|
1356
|
+
_ = await client.records.update(
|
|
1357
|
+
source,
|
|
1358
|
+
(
|
|
1359
|
+
RecordFormAttachmentFieldAddition(
|
|
1360
|
+
module="Attachments",
|
|
1361
|
+
name="Staged file",
|
|
1362
|
+
attachment=_staged(source, 38, "staged.txt", _HASH_A, userid=101),
|
|
1363
|
+
),
|
|
1364
|
+
RecordFormAttachmentFieldAddition(module="Attachments", name="Cloud file", attachment=cloud_file),
|
|
1365
|
+
),
|
|
1366
|
+
)
|
|
1225
1367
|
|
|
1226
1368
|
assert paths == []
|
|
1227
1369
|
|
|
@@ -2,6 +2,7 @@ from collections.abc import AsyncIterator
|
|
|
2
2
|
|
|
3
3
|
from matelab import (
|
|
4
4
|
AsyncMatelab,
|
|
5
|
+
CloudFile,
|
|
5
6
|
GroupSummary,
|
|
6
7
|
LiteratureLibrarySummary,
|
|
7
8
|
LiteratureMetadataInput,
|
|
@@ -96,6 +97,21 @@ async def consume_record(client: AsyncMatelab, source: RecordLocator) -> Record:
|
|
|
96
97
|
return record
|
|
97
98
|
|
|
98
99
|
|
|
100
|
+
async def reuse_cloud_file(
|
|
101
|
+
client: AsyncMatelab, source: RecordLocator, cloud_file: CloudFile
|
|
102
|
+
) -> tuple[tuple[int, ...], str]:
|
|
103
|
+
data: dict[str, RecordImportValue] = {"Attachments": {"File": [cloud_file]}}
|
|
104
|
+
record_ids = await client.records.import_dataset(
|
|
105
|
+
notebook=source.notebook,
|
|
106
|
+
template_title="Example",
|
|
107
|
+
items=(RecordImportItem(record_uid="REC-CLOUD-001", title="Cloud file", data=data),),
|
|
108
|
+
)
|
|
109
|
+
result = await client.records.update(
|
|
110
|
+
source, (RecordFormAttachmentFieldAddition(module="Attachments", name="Cloud file", attachment=cloud_file),)
|
|
111
|
+
)
|
|
112
|
+
return record_ids, result
|
|
113
|
+
|
|
114
|
+
|
|
99
115
|
async def consume_public_record(client: AsyncMatelab, notebook: NotebookRef) -> PublicRecord | None:
|
|
100
116
|
entries: tuple[PublicRecordSummary, ...] = await client.records.list_public(notebook)
|
|
101
117
|
record: PublicRecord | None = await client.records.read_public(entries[0].ref) if entries else None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/scripts/check_installed_package.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/_generated/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/domains/_attachment_content.py
RENAMED
|
File without changes
|
{matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/domains/cloud_drive.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/provider/test_provider_smoke.py
RENAMED
|
File without changes
|
{matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_async_attachment_upload.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_comments_and_downloads.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_uploads_and_literature.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|