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.
Files changed (68) hide show
  1. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/CONTEXT.md +2 -2
  2. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/PKG-INFO +1 -1
  3. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/contracts/matelab-integration-v1.lock.json +3 -3
  4. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/contracts/matelab-integration-v1.openapi.yaml +63 -13
  5. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/docs/api.md +45 -15
  6. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/docs/operation-coverage.yaml +1 -1
  7. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/pyproject.toml +1 -1
  8. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/_generated/models.py +1 -1
  9. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/domains/records.py +49 -22
  10. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/conftest.py +13 -1
  11. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_record_lifecycle.py +101 -4
  12. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_record_mutations.py +143 -1
  13. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/typing/consumer.py +16 -0
  14. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/uv.lock +1 -1
  15. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/.env.example +0 -0
  16. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/.github/workflows/release.yml +0 -0
  17. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/.gitignore +0 -0
  18. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/AGENTS.md +0 -0
  19. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/LICENSE +0 -0
  20. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/NOTICE +0 -0
  21. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/README.md +0 -0
  22. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/docs/agent-architecture.md +0 -0
  23. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/docs/agent-workflows.md +0 -0
  24. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/docs/development.md +0 -0
  25. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/scripts/check_installed_package.py +0 -0
  26. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/scripts/check_release.py +0 -0
  27. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/scripts/generate_models.py +0 -0
  28. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/__init__.py +0 -0
  29. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/_generated/__init__.py +0 -0
  30. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/_hashes.py +0 -0
  31. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/_transport.py +0 -0
  32. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/_wire_validation.py +0 -0
  33. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/client.py +0 -0
  34. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/domains/__init__.py +0 -0
  35. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/domains/_attachment_content.py +0 -0
  36. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/domains/cloud_drive.py +0 -0
  37. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/domains/groups.py +0 -0
  38. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/domains/literature.py +0 -0
  39. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/domains/notebooks.py +0 -0
  40. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/domains/templates.py +0 -0
  41. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/domains/uploads.py +0 -0
  42. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/domains/users.py +0 -0
  43. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/errors.py +0 -0
  44. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/py.typed +0 -0
  45. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/session.py +0 -0
  46. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/src/matelab/streaming.py +0 -0
  47. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/provider/test_provider_smoke.py +0 -0
  48. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_async_attachment_upload.py +0 -0
  49. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_client.py +0 -0
  50. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_cloud_drive.py +0 -0
  51. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_comments_and_downloads.py +0 -0
  52. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_consumer_smoke.py +0 -0
  53. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_errors.py +0 -0
  54. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_extended_records.py +0 -0
  55. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_generation.py +0 -0
  56. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_groups.py +0 -0
  57. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_notebook_lifecycle.py +0 -0
  58. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_notebooks.py +0 -0
  59. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_operation_coverage.py +0 -0
  60. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_records.py +0 -0
  61. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_release.py +0 -0
  62. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_streaming.py +0 -0
  63. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_template_lifecycle.py +0 -0
  64. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_templates.py +0 -0
  65. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_transport.py +0 -0
  66. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_uploads_and_literature.py +0 -0
  67. {matelab_python_sdk-0.2.0a8 → matelab_python_sdk-0.2.0a9}/tests/test_users.py +0 -0
  68. {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**:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: matelab-python-sdk
3
- Version: 0.2.0a8
3
+ Version: 0.2.0a9
4
4
  Summary: Reusable async Python client for the Matelab Integration Contract
5
5
  Author: CMPDC, Institute of Physics, Chinese Academy of Sciences
6
6
  License-Expression: Apache-2.0
@@ -2,12 +2,12 @@
2
2
  "schema_version": 1,
3
3
  "contract_line": "integration-v1",
4
4
  "release": {
5
- "tag": "v2026.10.4",
6
- "commit": "c422f4f7425ade81eb5a65d9448ca4ea8bcacc79"
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": "1bc28a61647b7b8f38b86c8ea0085415616857f967c997031794a88deb2e377c"
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.10.4"
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: 161c4560ae1cb6267d46841d6fe8adc8270ee1bc
21
+ commit: 00f17b6ce0906bb78b0a3e9bb8c50abee90ca3d0
17
22
  verificationRuntime:
18
- baseUrl: http://127.0.0.1:18080/api
23
+ baseUrl: https://matelab.iphy.ac.cn/eln-test/api
19
24
  container: matelab-test-server-1
20
25
  coreSourceComparison: sha256-exact
21
- coreSourceFileCount: 157
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: 161c4560ae1cb6267d46841d6fe8adc8270ee1bc
60
- previousReleaseEvidenceCommit: b70f590571b860f043365c9f2d6f95818d595862
61
- lastProviderVerifiedCommit: 161c4560ae1cb6267d46841d6fe8adc8270ee1bc
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: stageTemporaryFile, change: filename-extension-selects-continuation-bytes-and-abort-cleans-incomplete-extension-variants}
70
- - {operationId: uploadRecordAttachment, change: temporary-bytes-retain-sanitized-source-extension}
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: 89332767c2f20de210baed1e1e42df56e7071ad6
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. In particular,
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.10.4` Contract Release. The sole pin,
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
- `c422f4f7425ade81eb5a65d9448ca4ea8bcacc79`, source `openapi/matelab-integration-v1.openapi.yaml`, the local snapshot path,
11
- and SHA-256 `1bc28a61647b7b8f38b86c8ea0085415616857f967c997031794a88deb2e377c`. The annotated tag object is
12
- `72c52e2267598f9bde820f629bc62d5982f7081c`.
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 `161c4560ae1cb6267d46841d6fe8adc8270ee1bc`; v2026.10.4 validation ran
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
- `89332767c2f20de210baed1e1e42df56e7071ad6` informs cross-Consumer capability review but does not define the SDK interface.
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
- These three operations use the Provider's staged-name finalizer and must be the only mutation in their update request.
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
- All record attachment intents reject handles from another notebook or authenticated uploader, duplicate use within one
322
- finalization request, and raw Provider attachment references. Row/index-based operations use an immediate SDK read and
323
- must not be called while a concurrent editor is known to be active. The Provider offers no expected hash or revision,
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 the same `StagedAttachment` as import and other update intents. Attachment-bearing
358
- multi-record import is forbidden, so callers must split it into single-record imports with a fresh staged attachment
359
- for each request. Once an import or update request using a staged attachment starts, that handle cannot be retried or
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
 
@@ -3,7 +3,7 @@
3
3
  schema_version: 1
4
4
  contract:
5
5
  line: integration-v1
6
- release: v2026.10.4
6
+ release: v2026.11.1
7
7
  operation_count: 71
8
8
 
9
9
  operations:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "matelab-python-sdk"
7
- version = "0.2.0a8"
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"]
@@ -11,7 +11,7 @@ from pydantic import ConfigDict, Field, RootModel, SecretStr, StrictBool, Strict
11
11
 
12
12
  from matelab._wire_validation import WireModel
13
13
 
14
- OPENAPI_INFO_VERSION = "2026.10.4"
14
+ OPENAPI_INFO_VERSION = "2026.11.1"
15
15
 
16
16
 
17
17
  class ResponseEnvelope(WireModel):
@@ -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 new file field and finalize one staged attachment."""
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._compile_staged_record_attachment_change(change, patch)
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 _compile_staged_record_attachment_change(
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 can be finalized only into an existing form module.")
1957
- if not change.name or _module_field(module, change.name) is not None:
1958
- raise MatelabUsageError("A record attachment requires a new non-empty form field name.")
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
- wire_models.AddNewFormFileFieldWithStagedAttachmentOperation,
1962
- {
1963
- "module": change.module,
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 staged attachment object.")
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 test_import_dataset_consumes_one_staged_import_attachment_once(
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", title="Imported attachment", data={"Attachments": {"File": [staged]}}
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": {"Attachments": {"File": ["#file{staged-import}"]}},
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(provider_session: Session) -> None:
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
@@ -433,7 +433,7 @@ wheels = [
433
433
 
434
434
  [[package]]
435
435
  name = "matelab-python-sdk"
436
- version = "0.2.0a8"
436
+ version = "0.2.0a9"
437
437
  source = { editable = "." }
438
438
  dependencies = [
439
439
  { name = "httpx2" },