matelab-python-sdk 0.1.0a19__tar.gz → 0.1.0a20__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 (65) hide show
  1. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/CHANGELOG.md +12 -0
  2. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/PKG-INFO +17 -12
  3. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/README.md +16 -11
  4. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/contracts/matelab-integration-v1.lock.json +3 -3
  5. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/contracts/matelab-integration-v1.openapi.yaml +43 -4
  6. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/docs/operation-coverage.yaml +2 -2
  7. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/pyproject.toml +1 -1
  8. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/src/matelab/_generated/models.py +132 -10
  9. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/src/matelab/domains/records.py +1 -7
  10. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/test_generation.py +124 -3
  11. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/test_record_mutations.py +16 -8
  12. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/test_records.py +103 -12
  13. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/uv.lock +1 -1
  14. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/.env.example +0 -0
  15. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/.github/workflows/release.yml +0 -0
  16. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/.gitignore +0 -0
  17. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/AGENTS.md +0 -0
  18. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/CONTEXT.md +0 -0
  19. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/LICENSE +0 -0
  20. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/NOTICE +0 -0
  21. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/scripts/check_installed_package.py +0 -0
  22. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/scripts/check_release.py +0 -0
  23. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/scripts/generate_models.py +0 -0
  24. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/src/matelab/__init__.py +0 -0
  25. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/src/matelab/_generated/__init__.py +0 -0
  26. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/src/matelab/_hashes.py +0 -0
  27. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/src/matelab/_transport.py +0 -0
  28. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/src/matelab/_wire_validation.py +0 -0
  29. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/src/matelab/client.py +0 -0
  30. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/src/matelab/domains/__init__.py +0 -0
  31. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/src/matelab/domains/_attachment_content.py +0 -0
  32. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/src/matelab/domains/cloud_drive.py +0 -0
  33. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/src/matelab/domains/groups.py +0 -0
  34. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/src/matelab/domains/literature.py +0 -0
  35. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/src/matelab/domains/notebooks.py +0 -0
  36. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/src/matelab/domains/templates.py +0 -0
  37. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/src/matelab/domains/uploads.py +0 -0
  38. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/src/matelab/domains/users.py +0 -0
  39. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/src/matelab/errors.py +0 -0
  40. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/src/matelab/py.typed +0 -0
  41. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/src/matelab/session.py +0 -0
  42. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/src/matelab/streaming.py +0 -0
  43. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/conftest.py +0 -0
  44. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/provider/test_provider_smoke.py +0 -0
  45. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/test_async_attachment_upload.py +0 -0
  46. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/test_client.py +0 -0
  47. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/test_cloud_drive.py +0 -0
  48. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/test_comments_and_downloads.py +0 -0
  49. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/test_consumer_smoke.py +0 -0
  50. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/test_errors.py +0 -0
  51. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/test_extended_records.py +0 -0
  52. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/test_groups.py +0 -0
  53. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/test_notebook_lifecycle.py +0 -0
  54. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/test_notebooks.py +0 -0
  55. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/test_operation_coverage.py +0 -0
  56. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/test_record_lifecycle.py +0 -0
  57. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/test_release.py +0 -0
  58. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/test_streaming.py +0 -0
  59. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/test_template_lifecycle.py +0 -0
  60. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/test_templates.py +0 -0
  61. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/test_transport.py +0 -0
  62. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/test_uploads_and_literature.py +0 -0
  63. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/test_users.py +0 -0
  64. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/typing/consumer.py +0 -0
  65. {matelab_python_sdk-0.1.0a19 → matelab_python_sdk-0.1.0a20}/tests/typing/pyproject.toml +0 -0
@@ -3,6 +3,18 @@
3
3
  This file records release-to-release changes and migration impact. It is not the authority for the current interface;
4
4
  see [README.md](README.md) for current behavior and [AGENTS.md](AGENTS.md) for maintenance policy.
5
5
 
6
+ ## 0.1.0a20 - 2026-08-24
7
+
8
+ - Adopts immutable `matelab-spec v0.4.3` at commit `7965ac9803505ae35dc5ce36f52afc3c2c133c45` and OpenAPI
9
+ SHA-256 `6576812dd13f92ad58131f4edb8dc919d1b1fe0f19a695c2d25fbe6d13a8445b`, aligned with Provider evidence
10
+ `a3e6b961800f6b1ab666f0d3f6cd4c64c2a3ce27`.
11
+ - Breaking: tightens record readback to one canonical attachment object or `null` per table file cell, removing the
12
+ obsolete attachment-array compatibility path. Template decoding now accepts nullable JSON-code data and preserves
13
+ empty-string number/boolean cells alongside their scalar and null forms.
14
+ - Records PVD-046 while keeping `templates.save_content()` deliberately narrower than the Provider: the request remains
15
+ a canonical module list, and every table file cell remains one canonical attachment object or `null`, never an
16
+ arbitrary or multi-attachment array.
17
+
6
18
  ## 0.1.0a19 - 2026-08-23
7
19
 
8
20
  - Re-adopts the force-rewritten `matelab-spec v0.4.2` at commit
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matelab-python-sdk
3
- Version: 0.1.0a19
3
+ Version: 0.1.0a20
4
4
  Summary: Reusable async Python client for the Matelab Integration Contract
5
5
  Author-email: 朱天念 <zhutiannian@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -35,10 +35,10 @@ This README describes the current SDK interface, integration semantics, examples
35
35
  [AGENTS.md](AGENTS.md) for durable maintenance rules, and `docs/operation-coverage.yaml` for the machine-readable
36
36
  operation inventory.
37
37
 
38
- The current alpha is `0.1.0a19`. `[project].version` in `pyproject.toml` is the sole SDK version source;
38
+ The current alpha is `0.1.0a20`. `[project].version` in `pyproject.toml` is the sole SDK version source;
39
39
  `uv.lock` only mirrors that source.
40
40
 
41
- The SDK is pinned to the rewritten `matelab-spec v0.4.2` Contract Release. The sole release pin is
41
+ The SDK is pinned to the immutable `matelab-spec v0.4.3` Contract Release. The sole release pin is
42
42
  `contracts/matelab-integration-v1.lock.json`, which records
43
43
  the source tag, commit, OpenAPI path, local snapshot path, and SHA-256.
44
44
 
@@ -64,7 +64,7 @@ To test the same artifact a downstream Consumer will install, build and install
64
64
 
65
65
  ```bash
66
66
  uv build --no-build-isolation --out-dir dist/release
67
- python -m pip install dist/release/matelab_python_sdk-0.1.0a19-py3-none-any.whl
67
+ python -m pip install dist/release/matelab_python_sdk-0.1.0a20-py3-none-any.whl
68
68
  ```
69
69
 
70
70
  Do not infer Provider compatibility from the SDK version alone. A release is also bound to the Contract
@@ -121,7 +121,7 @@ success code it does not allow raises `MatelabProtocolError` rather than assigni
121
121
  `MatelabTransportError.status_code` remains available for HTTP failures. Provider response bodies, top-level `msg`,
122
122
  legacy `errmsg`, opaque `errs`, uncontracted debug fields, and request payloads are not attached to exceptions because
123
123
  Provider-authored diagnostics may contain stack traces, credentials, or submitted values.
124
- The pinned v0.4.2 Contract also declares optional code-4 `errs`, but PVD-041 and PCG-015 record opaque array/object
124
+ The pinned v0.4.3 Contract also declares optional code-4 `errs`, but PVD-041 and PCG-015 record opaque array/object
125
125
  containers and insufficient evidence for requiredness, cardinality, or exclusion from other codes. The SDK
126
126
  therefore keeps those diagnostics wire-only and does not expose `errs` as stable public metadata. A typed detail
127
127
  interface remains blocked until a future Contract release can define one from stronger Provider evidence. `retryable`
@@ -348,7 +348,9 @@ await client.records.update(
348
348
 
349
349
  For a files occurrence, `caption=None` preserves the observed string caption (an observed null caption normalizes to
350
350
  the required empty string). Table replacement requires exactly one current attachment in the selected cell and does
351
- not accept a caption. Both forms require the replacement hash to differ from the current occurrence.
351
+ not accept a caption. Record reads likewise accept only one canonical attachment object or `null` in a table file cell;
352
+ an attachment array violates the pinned response contract. Both replacement forms require the replacement hash to
353
+ differ from the current occurrence.
352
354
 
353
355
  All record attachment intents reject handles from another notebook or authenticated uploader, duplicate use within one
354
356
  finalization request, and raw Provider attachment references. Row/index-based operations use an immediate SDK read and
@@ -500,9 +502,12 @@ await client.templates.save_content(template, modules)
500
502
  `TemplateModule.data_present` distinguishes a missing `data` property from an explicit null value, while
501
503
  `attributes` prevents additive canonical properties from being dropped during a read/save cycle. The SDK does not
502
504
  synthesize frontend editor UIDs, layout widths, rows, folders, option encoding, or a fixed set of display module
503
- types. The Provider supports only whole-content replacement and has no atomic patch or compare-and-swap operation
504
- (PCG-007), so
505
- a read-modify-save sequence can overwrite a concurrent change.
505
+ types. A JSON-language code module with style metadata may return its JSON string or explicit `null`; number and boolean
506
+ table cells preserve the Provider's empty string as distinct from scalar and `null` values. The Provider accepts some
507
+ non-canonical module roots and arbitrary table file arrays, but the SDK does not expose that behavior: saved content
508
+ must remain a canonical module list, and a table file cell must be one canonical attachment object or `null` (PVD-046).
509
+ The Provider supports only whole-content replacement and has no atomic patch or compare-and-swap operation (PCG-007),
510
+ so a read-modify-save sequence can overwrite a concurrent change.
506
511
 
507
512
  The market result retains the Provider's `total_count` and derives `has_more` without echoing the caller's page
508
513
  arguments; it does not claim a stable order or continuation token. Template reads remain experimental
@@ -912,6 +917,6 @@ uv run python scripts/check_release.py dist/release/*.whl dist/release/*.tar.gz
912
917
  ```
913
918
 
914
919
  Rebuilding the same commit with the same locked environment and `SOURCE_DATE_EPOCH` must produce
915
- byte-identical wheel and source distribution hashes. The release is bound to the rewritten `matelab-spec v0.4.2`,
916
- commit `1a4b77721d782fccd2b1f26bcc16a7d876347686`, and OpenAPI SHA-256
917
- `1e737421aac636356487122ea89e20d54de07c8b9c0651cf6970a7123fe3b72b`.
920
+ byte-identical wheel and source distribution hashes. The release is bound to immutable `matelab-spec v0.4.3`, commit
921
+ `7965ac9803505ae35dc5ce36f52afc3c2c133c45`, and OpenAPI SHA-256
922
+ `6576812dd13f92ad58131f4edb8dc919d1b1fe0f19a695c2d25fbe6d13a8445b`.
@@ -7,10 +7,10 @@ This README describes the current SDK interface, integration semantics, examples
7
7
  [AGENTS.md](AGENTS.md) for durable maintenance rules, and `docs/operation-coverage.yaml` for the machine-readable
8
8
  operation inventory.
9
9
 
10
- The current alpha is `0.1.0a19`. `[project].version` in `pyproject.toml` is the sole SDK version source;
10
+ The current alpha is `0.1.0a20`. `[project].version` in `pyproject.toml` is the sole SDK version source;
11
11
  `uv.lock` only mirrors that source.
12
12
 
13
- The SDK is pinned to the rewritten `matelab-spec v0.4.2` Contract Release. The sole release pin is
13
+ The SDK is pinned to the immutable `matelab-spec v0.4.3` Contract Release. The sole release pin is
14
14
  `contracts/matelab-integration-v1.lock.json`, which records
15
15
  the source tag, commit, OpenAPI path, local snapshot path, and SHA-256.
16
16
 
@@ -36,7 +36,7 @@ To test the same artifact a downstream Consumer will install, build and install
36
36
 
37
37
  ```bash
38
38
  uv build --no-build-isolation --out-dir dist/release
39
- python -m pip install dist/release/matelab_python_sdk-0.1.0a19-py3-none-any.whl
39
+ python -m pip install dist/release/matelab_python_sdk-0.1.0a20-py3-none-any.whl
40
40
  ```
41
41
 
42
42
  Do not infer Provider compatibility from the SDK version alone. A release is also bound to the Contract
@@ -93,7 +93,7 @@ success code it does not allow raises `MatelabProtocolError` rather than assigni
93
93
  `MatelabTransportError.status_code` remains available for HTTP failures. Provider response bodies, top-level `msg`,
94
94
  legacy `errmsg`, opaque `errs`, uncontracted debug fields, and request payloads are not attached to exceptions because
95
95
  Provider-authored diagnostics may contain stack traces, credentials, or submitted values.
96
- The pinned v0.4.2 Contract also declares optional code-4 `errs`, but PVD-041 and PCG-015 record opaque array/object
96
+ The pinned v0.4.3 Contract also declares optional code-4 `errs`, but PVD-041 and PCG-015 record opaque array/object
97
97
  containers and insufficient evidence for requiredness, cardinality, or exclusion from other codes. The SDK
98
98
  therefore keeps those diagnostics wire-only and does not expose `errs` as stable public metadata. A typed detail
99
99
  interface remains blocked until a future Contract release can define one from stronger Provider evidence. `retryable`
@@ -320,7 +320,9 @@ await client.records.update(
320
320
 
321
321
  For a files occurrence, `caption=None` preserves the observed string caption (an observed null caption normalizes to
322
322
  the required empty string). Table replacement requires exactly one current attachment in the selected cell and does
323
- not accept a caption. Both forms require the replacement hash to differ from the current occurrence.
323
+ not accept a caption. Record reads likewise accept only one canonical attachment object or `null` in a table file cell;
324
+ an attachment array violates the pinned response contract. Both replacement forms require the replacement hash to
325
+ differ from the current occurrence.
324
326
 
325
327
  All record attachment intents reject handles from another notebook or authenticated uploader, duplicate use within one
326
328
  finalization request, and raw Provider attachment references. Row/index-based operations use an immediate SDK read and
@@ -472,9 +474,12 @@ await client.templates.save_content(template, modules)
472
474
  `TemplateModule.data_present` distinguishes a missing `data` property from an explicit null value, while
473
475
  `attributes` prevents additive canonical properties from being dropped during a read/save cycle. The SDK does not
474
476
  synthesize frontend editor UIDs, layout widths, rows, folders, option encoding, or a fixed set of display module
475
- types. The Provider supports only whole-content replacement and has no atomic patch or compare-and-swap operation
476
- (PCG-007), so
477
- a read-modify-save sequence can overwrite a concurrent change.
477
+ types. A JSON-language code module with style metadata may return its JSON string or explicit `null`; number and boolean
478
+ table cells preserve the Provider's empty string as distinct from scalar and `null` values. The Provider accepts some
479
+ non-canonical module roots and arbitrary table file arrays, but the SDK does not expose that behavior: saved content
480
+ must remain a canonical module list, and a table file cell must be one canonical attachment object or `null` (PVD-046).
481
+ The Provider supports only whole-content replacement and has no atomic patch or compare-and-swap operation (PCG-007),
482
+ so a read-modify-save sequence can overwrite a concurrent change.
478
483
 
479
484
  The market result retains the Provider's `total_count` and derives `has_more` without echoing the caller's page
480
485
  arguments; it does not claim a stable order or continuation token. Template reads remain experimental
@@ -884,6 +889,6 @@ uv run python scripts/check_release.py dist/release/*.whl dist/release/*.tar.gz
884
889
  ```
885
890
 
886
891
  Rebuilding the same commit with the same locked environment and `SOURCE_DATE_EPOCH` must produce
887
- byte-identical wheel and source distribution hashes. The release is bound to the rewritten `matelab-spec v0.4.2`,
888
- commit `1a4b77721d782fccd2b1f26bcc16a7d876347686`, and OpenAPI SHA-256
889
- `1e737421aac636356487122ea89e20d54de07c8b9c0651cf6970a7123fe3b72b`.
892
+ byte-identical wheel and source distribution hashes. The release is bound to immutable `matelab-spec v0.4.3`, commit
893
+ `7965ac9803505ae35dc5ce36f52afc3c2c133c45`, and OpenAPI SHA-256
894
+ `6576812dd13f92ad58131f4edb8dc919d1b1fe0f19a695c2d25fbe6d13a8445b`.
@@ -2,12 +2,12 @@
2
2
  "schema_version": 1,
3
3
  "contract_line": "integration-v1",
4
4
  "release": {
5
- "tag": "v0.4.2",
6
- "commit": "1a4b77721d782fccd2b1f26bcc16a7d876347686"
5
+ "tag": "v0.4.3",
6
+ "commit": "7965ac9803505ae35dc5ce36f52afc3c2c133c45"
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": "1e737421aac636356487122ea89e20d54de07c8b9c0651cf6970a7123fe3b72b"
11
+ "sha256": "6576812dd13f92ad58131f4edb8dc919d1b1fe0f19a695c2d25fbe6d13a8445b"
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: 0.4.2
4
+ version: 0.4.3
5
5
  description: |
6
6
  Current PHP wire contract for generating SDKs against the selected
7
7
  `matelab-server` Provider version.
@@ -50,6 +50,10 @@ info:
50
50
  - {operationId: extractLiteratureMetadata, change: doi-format-validated-before-extraction}
51
51
  - {operationId: searchRecordData, change: encrypted-records-excluded}
52
52
  - {operationId: updateRecordData, change: files-folder-path-removed-from-callable-shape}
53
+ - {operationId: readRecord, change: table-file-cell-readback-modeled-as-attachment-object-or-null}
54
+ - {operationId: readTemplateContent, change: json-code-null-readback-accepted}
55
+ - {operationId: saveTemplateContent, change: empty-number-and-boolean-cells-preserved}
56
+ - {operationId: saveTemplateContent, change: noncanonical-module-structures-contract-contained}
53
57
  x-consumer-evidence:
54
58
  - repository: /home/tnzhu/projects/matelab-mcp
55
59
  commit: 94a129b2df0ef0fbcf179c10550fb7c9a5dd2e6f
@@ -827,6 +831,7 @@ paths:
827
831
  operationId: saveTemplateContent
828
832
  tags: [Templates]
829
833
  summary: Replace canonical modules for an owned template
834
+ x-known-provider-issues: [PVD-046]
830
835
  security: [{bearerAuth: []}]
831
836
  requestBody:
832
837
  required: true
@@ -2541,6 +2546,19 @@ paths:
2541
2546
  name: Notes
2542
2547
  uid: module-example
2543
2548
  data: Sanitized content
2549
+ - type: table
2550
+ name: Measurements
2551
+ uid: table-example
2552
+ rows: [row-example]
2553
+ data:
2554
+ - type: file
2555
+ name: Evidence
2556
+ uid: column-example
2557
+ data:
2558
+ - filename: evidence.txt
2559
+ hash: 74034daeef860e12596c34f7e83703e668d578f4a85e72ec1645117223d4e6a7
2560
+ size: 26
2561
+ timestamp: 1767946200000
2544
2562
  template_id: 8
2545
2563
  datetime_modify: 1767946200000
2546
2564
  username: Example User
@@ -4914,7 +4932,7 @@ components:
4914
4932
  required: [data]
4915
4933
  properties:
4916
4934
  data:
4917
- type: string
4935
+ type: [string, "null"]
4918
4936
  contentMediaType: application/json
4919
4937
  additionalProperties: true
4920
4938
  TemplateTableColumn:
@@ -4949,6 +4967,7 @@ components:
4949
4967
  oneOf:
4950
4968
  - {type: number}
4951
4969
  - {type: string, pattern: '^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$'}
4970
+ - {type: string, const: ""}
4952
4971
  - {type: "null"}
4953
4972
  - if:
4954
4973
  properties: {type: {const: bool}}
@@ -4957,7 +4976,11 @@ components:
4957
4976
  properties:
4958
4977
  data:
4959
4978
  type: array
4960
- items: {type: [boolean, "null"]}
4979
+ items:
4980
+ oneOf:
4981
+ - {type: boolean}
4982
+ - {type: string, const: ""}
4983
+ - {type: "null"}
4961
4984
  - if:
4962
4985
  properties: {type: {const: file}}
4963
4986
  required: [type]
@@ -4967,9 +4990,25 @@ components:
4967
4990
  type: array
4968
4991
  items:
4969
4992
  oneOf:
4970
- - {type: array}
4993
+ - {$ref: "#/components/schemas/RecordAttachment"}
4971
4994
  - {type: "null"}
4972
4995
  additionalProperties: true
4996
+ RecordAttachment:
4997
+ type: object
4998
+ required: [filename, hash, size, timestamp]
4999
+ properties:
5000
+ filename:
5001
+ type: string
5002
+ description: Original filename; the Provider's canonical empty attachment uses an empty string.
5003
+ hash:
5004
+ type: string
5005
+ pattern: "^(?:[0-9a-f]{64})?$"
5006
+ description: SHA-256 digest, or an empty string in the Provider's canonical empty attachment.
5007
+ size: {type: integer, minimum: 0}
5008
+ timestamp:
5009
+ type: integer
5010
+ minimum: 1
5011
+ description: Provider-generated Unix timestamp in milliseconds.
4973
5012
  TemplateFormItem:
4974
5013
  type: object
4975
5014
  required: [name]
@@ -3,7 +3,7 @@
3
3
  schema_version: 1
4
4
  contract:
5
5
  line: integration-v1
6
- release: v0.4.2
6
+ release: v0.4.3
7
7
  operation_count: 71
8
8
 
9
9
  operations:
@@ -133,7 +133,7 @@ operations:
133
133
  state: implemented
134
134
  stability: experimental
135
135
  public_interface: AsyncMatelab.templates.save_content
136
- known_issues: [PCG-007]
136
+ known_issues: [PCG-007, PVD-046]
137
137
 
138
138
  - operation_id: copyTemplate
139
139
  method: POST
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "matelab-python-sdk"
7
- version = "0.1.0a19"
7
+ version = "0.1.0a20"
8
8
  description = "Reusable async Python client for the Matelab Integration Contract"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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 = "0.4.2"
14
+ OPENAPI_INFO_VERSION = "0.4.3"
15
15
 
16
16
 
17
17
  class ResponseEnvelope(WireModel):
@@ -638,7 +638,11 @@ class SaveTemplateContentRequest(WireModel):
638
638
  "then": {
639
639
  "required": ["data"],
640
640
  "properties": {
641
- "data": {"type": "string", "contentMediaType": "application/json"}
641
+ "data": {
642
+ "type": ["string", "null"],
643
+ "contentMediaType": "application/json",
644
+ "nullable": True,
645
+ }
642
646
  },
643
647
  },
644
648
  },
@@ -686,6 +690,7 @@ class SaveTemplateContentRequest(WireModel):
686
690
  "type": "string",
687
691
  "pattern": "^[+-]?(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)(?:[eE][+-]?[0-9]+)?$",
688
692
  },
693
+ {"type": "string", "const": ""},
689
694
  {"type": "null"},
690
695
  ]
691
696
  },
@@ -699,7 +704,13 @@ class SaveTemplateContentRequest(WireModel):
699
704
  "properties": {
700
705
  "data": {
701
706
  "type": "array",
702
- "items": {"type": ["boolean", "null"], "nullable": True},
707
+ "items": {
708
+ "oneOf": [
709
+ {"type": "boolean"},
710
+ {"type": "string", "const": ""},
711
+ {"type": "null"},
712
+ ]
713
+ },
703
714
  }
704
715
  }
705
716
  },
@@ -710,7 +721,9 @@ class SaveTemplateContentRequest(WireModel):
710
721
  "properties": {
711
722
  "data": {
712
723
  "type": "array",
713
- "items": {"oneOf": [{"type": "array"}, {"type": "null"}]},
724
+ "items": {
725
+ "oneOf": [{"$ref": "#/$defs/RecordAttachment"}, {"type": "null"}]
726
+ },
714
727
  }
715
728
  }
716
729
  },
@@ -718,6 +731,27 @@ class SaveTemplateContentRequest(WireModel):
718
731
  ],
719
732
  "additionalProperties": True,
720
733
  },
734
+ "RecordAttachment": {
735
+ "type": "object",
736
+ "required": ["filename", "hash", "size", "timestamp"],
737
+ "properties": {
738
+ "filename": {
739
+ "type": "string",
740
+ "description": "Original filename; the Provider's canonical empty attachment uses an empty string.",
741
+ },
742
+ "hash": {
743
+ "type": "string",
744
+ "pattern": "^(?:[0-9a-f]{64})?$",
745
+ "description": "SHA-256 digest, or an empty string in the Provider's canonical empty attachment.",
746
+ },
747
+ "size": {"type": "integer", "minimum": 0},
748
+ "timestamp": {
749
+ "type": "integer",
750
+ "minimum": 1,
751
+ "description": "Provider-generated Unix timestamp in milliseconds.",
752
+ },
753
+ },
754
+ },
721
755
  "TemplateFormItem": {
722
756
  "type": "object",
723
757
  "required": ["name"],
@@ -789,7 +823,13 @@ class TemplateModule(WireModel):
789
823
  },
790
824
  "then": {
791
825
  "required": ["data"],
792
- "properties": {"data": {"type": "string", "contentMediaType": "application/json"}},
826
+ "properties": {
827
+ "data": {
828
+ "type": ["string", "null"],
829
+ "contentMediaType": "application/json",
830
+ "nullable": True,
831
+ }
832
+ },
793
833
  },
794
834
  },
795
835
  ],
@@ -835,6 +875,7 @@ class TemplateModule(WireModel):
835
875
  "type": "string",
836
876
  "pattern": "^[+-]?(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)(?:[eE][+-]?[0-9]+)?$",
837
877
  },
878
+ {"type": "string", "const": ""},
838
879
  {"type": "null"},
839
880
  ]
840
881
  },
@@ -848,7 +889,13 @@ class TemplateModule(WireModel):
848
889
  "properties": {
849
890
  "data": {
850
891
  "type": "array",
851
- "items": {"type": ["boolean", "null"], "nullable": True},
892
+ "items": {
893
+ "oneOf": [
894
+ {"type": "boolean"},
895
+ {"type": "string", "const": ""},
896
+ {"type": "null"},
897
+ ]
898
+ },
852
899
  }
853
900
  }
854
901
  },
@@ -859,7 +906,9 @@ class TemplateModule(WireModel):
859
906
  "properties": {
860
907
  "data": {
861
908
  "type": "array",
862
- "items": {"oneOf": [{"type": "array"}, {"type": "null"}]},
909
+ "items": {
910
+ "oneOf": [{"$ref": "#/$defs/RecordAttachment"}, {"type": "null"}]
911
+ },
863
912
  }
864
913
  }
865
914
  },
@@ -867,6 +916,27 @@ class TemplateModule(WireModel):
867
916
  ],
868
917
  "additionalProperties": True,
869
918
  },
919
+ "RecordAttachment": {
920
+ "type": "object",
921
+ "required": ["filename", "hash", "size", "timestamp"],
922
+ "properties": {
923
+ "filename": {
924
+ "type": "string",
925
+ "description": "Original filename; the Provider's canonical empty attachment uses an empty string.",
926
+ },
927
+ "hash": {
928
+ "type": "string",
929
+ "pattern": "^(?:[0-9a-f]{64})?$",
930
+ "description": "SHA-256 digest, or an empty string in the Provider's canonical empty attachment.",
931
+ },
932
+ "size": {"type": "integer", "minimum": 0},
933
+ "timestamp": {
934
+ "type": "integer",
935
+ "minimum": 1,
936
+ "description": "Provider-generated Unix timestamp in milliseconds.",
937
+ },
938
+ },
939
+ },
870
940
  "TemplateFormItem": {
871
941
  "type": "object",
872
942
  "required": ["name"],
@@ -929,6 +999,7 @@ class TemplateTableColumn(WireModel):
929
999
  "type": "string",
930
1000
  "pattern": "^[+-]?(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)(?:[eE][+-]?[0-9]+)?$",
931
1001
  },
1002
+ {"type": "string", "const": ""},
932
1003
  {"type": "null"},
933
1004
  ]
934
1005
  },
@@ -940,7 +1011,16 @@ class TemplateTableColumn(WireModel):
940
1011
  "if": {"properties": {"type": {"const": "bool"}}, "required": ["type"]},
941
1012
  "then": {
942
1013
  "properties": {
943
- "data": {"type": "array", "items": {"type": ["boolean", "null"], "nullable": True}}
1014
+ "data": {
1015
+ "type": "array",
1016
+ "items": {
1017
+ "oneOf": [
1018
+ {"type": "boolean"},
1019
+ {"type": "string", "const": ""},
1020
+ {"type": "null"},
1021
+ ]
1022
+ },
1023
+ }
944
1024
  }
945
1025
  },
946
1026
  },
@@ -948,11 +1028,37 @@ class TemplateTableColumn(WireModel):
948
1028
  "if": {"properties": {"type": {"const": "file"}}, "required": ["type"]},
949
1029
  "then": {
950
1030
  "properties": {
951
- "data": {"type": "array", "items": {"oneOf": [{"type": "array"}, {"type": "null"}]}}
1031
+ "data": {
1032
+ "type": "array",
1033
+ "items": {"oneOf": [{"$ref": "#/$defs/RecordAttachment"}, {"type": "null"}]},
1034
+ }
952
1035
  }
953
1036
  },
954
1037
  },
955
- ]
1038
+ ],
1039
+ "$defs": {
1040
+ "RecordAttachment": {
1041
+ "type": "object",
1042
+ "required": ["filename", "hash", "size", "timestamp"],
1043
+ "properties": {
1044
+ "filename": {
1045
+ "type": "string",
1046
+ "description": "Original filename; the Provider's canonical empty attachment uses an empty string.",
1047
+ },
1048
+ "hash": {
1049
+ "type": "string",
1050
+ "pattern": "^(?:[0-9a-f]{64})?$",
1051
+ "description": "SHA-256 digest, or an empty string in the Provider's canonical empty attachment.",
1052
+ },
1053
+ "size": {"type": "integer", "minimum": 0},
1054
+ "timestamp": {
1055
+ "type": "integer",
1056
+ "minimum": 1,
1057
+ "description": "Provider-generated Unix timestamp in milliseconds.",
1058
+ },
1059
+ },
1060
+ }
1061
+ },
956
1062
  }
957
1063
  },
958
1064
  )
@@ -964,6 +1070,22 @@ class TemplateTableColumn(WireModel):
964
1070
  data: list[Any]
965
1071
 
966
1072
 
1073
+ class RecordAttachment(WireModel):
1074
+ filename: str
1075
+ """
1076
+ Original filename; the Provider's canonical empty attachment uses an empty string.
1077
+ """
1078
+ hash: Annotated[str, Field(pattern="^(?:[0-9a-f]{64})?$")]
1079
+ """
1080
+ SHA-256 digest, or an empty string in the Provider's canonical empty attachment.
1081
+ """
1082
+ size: Annotated[StrictInt, Field(ge=0)]
1083
+ timestamp: Annotated[StrictInt, Field(ge=1)]
1084
+ """
1085
+ Provider-generated Unix timestamp in milliseconds.
1086
+ """
1087
+
1088
+
967
1089
  class TemplateFormItem(WireModel):
968
1090
  model_config = ConfigDict(extra="allow")
969
1091
  name: Annotated[str, Field(min_length=1, pattern=".*[^0-9.\\-+eE ].*")]
@@ -722,13 +722,7 @@ def _record_attachment_refs(
722
722
  ):
723
723
  continue
724
724
  for position, value in enumerate(field_values):
725
- if kind == "table_file" and isinstance(value, list):
726
- for attachment in value:
727
- add_structured(
728
- attachment, kind=kind, module=module_name, field_name=field_name, position=position
729
- )
730
- else:
731
- add_structured(value, kind=kind, module=module_name, field_name=field_name, position=position)
725
+ add_structured(value, kind=kind, module=module_name, field_name=field_name, position=position)
732
726
  elif module_type in {"files", "images"} and isinstance(values, list):
733
727
  file_kind = "files_module" if module_type == "files" else "images_module"
734
728
  for position, value in enumerate(values):
@@ -108,6 +108,127 @@ def test_generated_response_preserves_required_nullable_fields() -> None:
108
108
  assert response.keywords is None
109
109
 
110
110
 
111
+ def _item_view_payload(modules: list[object]) -> dict[str, object]:
112
+ return {
113
+ "code": 0,
114
+ "msg": "",
115
+ "errcode": 0,
116
+ "errmsg": "",
117
+ "title": None,
118
+ "keywords": None,
119
+ "modules": modules,
120
+ "template_id": None,
121
+ "datetime_modify": 1,
122
+ "username": None,
123
+ "modify_log": [],
124
+ "uid": None,
125
+ "current_version": 0,
126
+ "item_id": 1,
127
+ "datetime_locked": 0,
128
+ "locked": False,
129
+ "signs": [],
130
+ "signed": False,
131
+ "owner": False,
132
+ "eln_text": None,
133
+ "eln_server": "localhost",
134
+ "eln_id": 1,
135
+ "editable": False,
136
+ "keyword_eln": [],
137
+ "pdf_enable": False,
138
+ "template_show": "",
139
+ "template_text": "",
140
+ "template_intro": "",
141
+ }
142
+
143
+
144
+ def _record_attachment() -> dict[str, object]:
145
+ return {"filename": "evidence.txt", "hash": "0" * 64, "size": 26, "timestamp": 1_767_946_200_000}
146
+
147
+
148
+ def _file_table_module(cell: object) -> dict[str, object]:
149
+ return {"name": "Measurements", "type": "table", "data": [{"name": "Evidence", "type": "file", "data": [cell]}]}
150
+
151
+
152
+ def test_item_view_file_cells_are_one_record_attachment_or_null() -> None:
153
+ attachment = _record_attachment()
154
+ for cell in (attachment, None):
155
+ response = ItemViewResponse.model_validate(_item_view_payload([_file_table_module(cell)]))
156
+ assert response.modules[0].name == "Measurements"
157
+
158
+ with pytest.raises(ValidationError):
159
+ _ = ItemViewResponse.model_validate(_item_view_payload([_file_table_module([attachment])]))
160
+
161
+
162
+ def test_record_attachment_requires_all_fields_and_preserves_canonical_empty_attachment() -> None:
163
+ attachment = _record_attachment()
164
+ assert wire_models.RecordAttachment.model_validate(attachment).model_dump() == attachment
165
+ assert wire_models.RecordAttachment(filename="", hash="", size=0, timestamp=1).filename == ""
166
+
167
+ for field in attachment:
168
+ with pytest.raises(ValidationError):
169
+ _ = wire_models.RecordAttachment.model_validate(
170
+ {key: value for key, value in attachment.items() if key != field}
171
+ )
172
+ for invalid_attachment in (
173
+ {**attachment, "hash": "not-a-sha256"},
174
+ {**attachment, "size": -1},
175
+ {**attachment, "timestamp": 0},
176
+ ):
177
+ with pytest.raises(ValidationError):
178
+ _ = wire_models.RecordAttachment.model_validate(invalid_attachment)
179
+
180
+
181
+ def test_template_json_code_modules_with_style_accept_json_string_or_null_data() -> None:
182
+ for data in ('{"enabled":true}', None):
183
+ module = wire_models.TemplateModule.model_validate(
184
+ {"name": "Configuration", "type": "code", "language": "json", "style": None, "data": data}
185
+ )
186
+ assert module.data == data
187
+
188
+ without_style = wire_models.TemplateModule.model_validate(
189
+ {"name": "Configuration", "type": "code", "language": "json", "data": {"enabled": True}}
190
+ )
191
+ assert without_style.data == {"enabled": True}
192
+
193
+ invalid_modules: tuple[dict[str, object], ...] = (
194
+ {"name": "Configuration", "type": "code", "language": "json", "style": None},
195
+ {"name": "Configuration", "type": "code", "language": "json", "style": None, "data": {}},
196
+ )
197
+ for invalid_module in invalid_modules:
198
+ with pytest.raises(ValidationError):
199
+ _ = wire_models.TemplateModule.model_validate(invalid_module)
200
+
201
+
202
+ def test_template_number_and_boolean_cells_accept_empty_strings() -> None:
203
+ number = wire_models.TemplateTableColumn.model_validate(
204
+ {"name": "Reading", "type": "number", "data": [1, "1.5", "", None]}
205
+ )
206
+ boolean = wire_models.TemplateTableColumn.model_validate(
207
+ {"name": "Published", "type": "bool", "data": [True, "", None]}
208
+ )
209
+ assert number.data == [1, "1.5", "", None]
210
+ assert boolean.data == [True, "", None]
211
+
212
+ for invalid_column in (
213
+ {"name": "Reading", "type": "number", "data": ["not-a-number"]},
214
+ {"name": "Published", "type": "bool", "data": ["true"]},
215
+ ):
216
+ with pytest.raises(ValidationError):
217
+ _ = wire_models.TemplateTableColumn.model_validate(invalid_column)
218
+
219
+
220
+ def test_save_template_content_keeps_file_cells_narrowed_to_one_attachment() -> None:
221
+ attachment = _record_attachment()
222
+ content = json.dumps([_file_table_module(attachment)])
223
+ request = wire_models.SaveTemplateContentRequest.model_validate({"id": 1, "data": content})
224
+ assert request.data == content
225
+
226
+ with pytest.raises(ValidationError):
227
+ _ = wire_models.SaveTemplateContentRequest.model_validate(
228
+ {"id": 1, "data": json.dumps([_file_table_module([attachment])])}
229
+ )
230
+
231
+
111
232
  def test_generated_requests_reject_unknown_fields() -> None:
112
233
  with pytest.raises(ValidationError):
113
234
  _ = ListRecordsRequest.model_validate({"eln": "Example Notebook", "unexpected": True})
@@ -364,7 +485,7 @@ def test_generated_models_cover_every_component_schema_and_non_streaming_operati
364
485
  }
365
486
  assert expected_response_models <= class_names
366
487
  assert len(operation_ids) == 71
367
- assert wire_models.OPENAPI_INFO_VERSION == "0.4.2"
488
+ assert wire_models.OPENAPI_INFO_VERSION == "0.4.3"
368
489
 
369
490
 
370
491
  def test_generation_is_deterministic(tmp_path: Path) -> None:
@@ -476,8 +597,8 @@ def test_contract_lock_is_the_single_release_pin() -> None:
476
597
  openapi = cast(dict[str, Any], lock["openapi"])
477
598
  assert set(release) == {"tag", "commit"}
478
599
  assert set(openapi) == {"source_path", "snapshot", "sha256"}
479
- assert release == {"tag": "v0.4.2", "commit": "1a4b77721d782fccd2b1f26bcc16a7d876347686"}
480
- assert openapi["sha256"] == "1e737421aac636356487122ea89e20d54de07c8b9c0651cf6970a7123fe3b72b"
600
+ assert release == {"tag": "v0.4.3", "commit": "7965ac9803505ae35dc5ce36f52afc3c2c133c45"}
601
+ assert openapi["sha256"] == "6576812dd13f92ad58131f4edb8dc919d1b1fe0f19a695c2d25fbe6d13a8445b"
481
602
  assert re.fullmatch(r"[0-9a-f]{40}", cast(str, release["commit"]))
482
603
  assert re.fullmatch(r"[0-9a-f]{64}", cast(str, openapi["sha256"]))
483
604
  assert not (ROOT / "contracts" / "matelab-integration-v1.sha256").exists()
@@ -342,7 +342,11 @@ async def test_record_attachment_mutations_are_separate_and_collaboration_is_not
342
342
  "type": "table",
343
343
  "rows": ["row-1"],
344
344
  "data": [
345
- {"name": "Evidence", "type": "file", "data": [[{"hash": _HASH_B, "filename": "table.txt", "size": 5}]]}
345
+ {
346
+ "name": "Evidence",
347
+ "type": "file",
348
+ "data": [{"hash": _HASH_B, "filename": "table.txt", "size": 5, "timestamp": 1}],
349
+ }
346
350
  ],
347
351
  },
348
352
  {
@@ -616,7 +620,7 @@ async def test_update_sets_one_null_table_file_cell_from_record_upload(
616
620
  {
617
621
  "name": "Evidence",
618
622
  "type": "file",
619
- "data": [[{"hash": _HASH_A, "filename": "evidence.txt", "size": 5}]],
623
+ "data": [{"hash": _HASH_A, "filename": "evidence.txt", "size": 5, "timestamp": 1}],
620
624
  }
621
625
  ],
622
626
  }
@@ -676,7 +680,7 @@ async def test_update_appends_table_row_with_scalar_values_and_one_staged_file(
676
680
  {
677
681
  "name": "Evidence",
678
682
  "type": "file",
679
- "data": [None, None, [{"hash": _HASH_A, "filename": "evidence.txt", "size": 5}]],
683
+ "data": [None, None, {"hash": _HASH_A, "filename": "evidence.txt", "size": 5, "timestamp": 1}],
680
684
  },
681
685
  ],
682
686
  }
@@ -774,7 +778,11 @@ async def test_update_replaces_observed_table_or_files_occurrence_with_one_inten
774
778
  "type": "table",
775
779
  "rows": ["row-1"],
776
780
  "data": [
777
- {"name": "Evidence", "type": "file", "data": [[{"hash": _HASH_A, "filename": "evidence.txt", "size": 5}]]}
781
+ {
782
+ "name": "Evidence",
783
+ "type": "file",
784
+ "data": [{"hash": _HASH_A, "filename": "evidence.txt", "size": 5, "timestamp": 1}],
785
+ }
778
786
  ],
779
787
  }
780
788
  table_after = {
@@ -785,7 +793,7 @@ async def test_update_replaces_observed_table_or_files_occurrence_with_one_inten
785
793
  {
786
794
  "name": "Evidence",
787
795
  "type": "file",
788
- "data": [[{"hash": _HASH_B, "filename": "replacement.txt", "size": 5}]],
796
+ "data": [{"hash": _HASH_B, "filename": "replacement.txt", "size": 5, "timestamp": 1}],
789
797
  }
790
798
  ],
791
799
  }
@@ -878,7 +886,7 @@ async def test_update_adds_empty_file_column_and_deletes_complete_file_fields(
878
886
  {
879
887
  "name": "Old evidence",
880
888
  "type": "file",
881
- "data": [[{"hash": _HASH_B, "filename": "old.txt", "size": 5}], None],
889
+ "data": [{"hash": _HASH_B, "filename": "old.txt", "size": 5, "timestamp": 1}, None],
882
890
  },
883
891
  {"name": "Label", "type": "text", "data": ["A", "B"]},
884
892
  ],
@@ -1035,7 +1043,7 @@ async def test_table_file_cell_prewrite_rejection_preserves_stage_for_a_later_va
1035
1043
  {
1036
1044
  "name": "Evidence",
1037
1045
  "type": "file",
1038
- "data": [[{"hash": _HASH_B, "filename": "existing.txt", "size": 5}]],
1046
+ "data": [{"hash": _HASH_B, "filename": "existing.txt", "size": 5, "timestamp": 1}],
1039
1047
  }
1040
1048
  ],
1041
1049
  }
@@ -1057,7 +1065,7 @@ async def test_table_file_cell_prewrite_rejection_preserves_stage_for_a_later_va
1057
1065
  {
1058
1066
  "name": "Evidence",
1059
1067
  "type": "file",
1060
- "data": [[{"hash": _HASH_A, "filename": "evidence.txt", "size": 5}]],
1068
+ "data": [{"hash": _HASH_A, "filename": "evidence.txt", "size": 5, "timestamp": 1}],
1061
1069
  }
1062
1070
  ],
1063
1071
  }
@@ -145,16 +145,37 @@ async def test_read_current_record_maps_pinned_contract_success_example(
145
145
  assert record.source == source
146
146
  assert record.title == "Calibration Run"
147
147
  assert record.keywords == ("calibration",)
148
- assert record.modules == (
149
- {
150
- "name": "Notes",
151
- "type": "richtext",
152
- "data": "Sanitized content",
153
- "language": None,
154
- "style": None,
155
- "uid": "module-example",
156
- },
157
- )
148
+ assert record.modules[0] == {
149
+ "name": "Notes",
150
+ "type": "richtext",
151
+ "data": "Sanitized content",
152
+ "language": None,
153
+ "style": None,
154
+ "uid": "module-example",
155
+ }
156
+ assert record.modules[1] == {
157
+ "name": "Measurements",
158
+ "type": "table",
159
+ "data": [
160
+ {
161
+ "name": "Evidence",
162
+ "type": "file",
163
+ "data": [
164
+ {
165
+ "filename": "evidence.txt",
166
+ "hash": "74034daeef860e12596c34f7e83703e668d578f4a85e72ec1645117223d4e6a7",
167
+ "size": 26,
168
+ "timestamp": 1_767_946_200_000,
169
+ }
170
+ ],
171
+ "uid": "column-example",
172
+ }
173
+ ],
174
+ "language": None,
175
+ "style": None,
176
+ "uid": "table-example",
177
+ "rows": ["row-example"],
178
+ }
158
179
  assert record.versions == (RecordVersionRef(source=source, version_id=70001),)
159
180
 
160
181
 
@@ -183,7 +204,28 @@ async def test_read_current_record_maps_required_nullable_and_open_modules() ->
183
204
  "filename": "example.bin",
184
205
  "size": 12,
185
206
  },
186
- }
207
+ },
208
+ {
209
+ "type": "table",
210
+ "name": "Measurements",
211
+ "uid": "table-example",
212
+ "rows": ["row-1", "row-2"],
213
+ "data": [
214
+ {
215
+ "name": "Evidence",
216
+ "type": "file",
217
+ "data": [
218
+ {
219
+ "hash": "d6a8f491a743ee3c10b42b4519d7545d7f1d79a7e98ed2ef9c03c0c1d75a1a91",
220
+ "filename": "evidence.pdf",
221
+ "size": 34,
222
+ "timestamp": 1_767_946_200_000,
223
+ },
224
+ None,
225
+ ],
226
+ }
227
+ ],
228
+ },
187
229
  ],
188
230
  template_id=None,
189
231
  datetime_modify=1_767_946_200_000,
@@ -242,14 +284,63 @@ async def test_read_current_record_maps_required_nullable_and_open_modules() ->
242
284
  assert record.author_name is None
243
285
  assert record.modules[0]["uid"] == "module-example"
244
286
  assert record.modules[0]["future_module_field"] == {"supported": True}
287
+ table_data = record.modules[1]["data"]
288
+ assert isinstance(table_data, list)
289
+ file_column = table_data[0]
290
+ assert isinstance(file_column, dict)
291
+ file_cells = file_column["data"]
292
+ assert isinstance(file_cells, list)
293
+ assert file_cells[1] is None
245
294
  assert record.versions[0].version_id == 70001
246
295
  assert [(item.filename, item.size, item.observed_version) for item in record.attachments] == [
247
- ("example.bin", 12, None)
296
+ ("evidence.pdf", 34, None),
297
+ ("example.bin", 12, None),
248
298
  ]
249
299
  assert record.attachments[0].source == source
300
+ assert record.attachments[0].location is not None
301
+ assert record.attachments[0].location.kind == "table_file"
302
+ assert record.attachments[0].location.position == 0
250
303
  assert "74034dae" not in repr(record.attachments[0])
251
304
 
252
305
 
306
+ @pytest.mark.asyncio
307
+ async def test_read_rejects_historical_table_attachment_array_at_public_boundary() -> None:
308
+ payload = _item_view_payload(uid="REC-EXAMPLE-001")
309
+ payload["modules"] = [
310
+ {
311
+ "type": "table",
312
+ "name": "Measurements",
313
+ "rows": ["row-1"],
314
+ "data": [
315
+ {
316
+ "name": "Evidence",
317
+ "type": "file",
318
+ "data": [
319
+ [
320
+ {
321
+ "hash": "d6a8f491a743ee3c10b42b4519d7545d7f1d79a7e98ed2ef9c03c0c1d75a1a91",
322
+ "filename": "evidence.pdf",
323
+ "size": 34,
324
+ "timestamp": 1_767_946_200_000,
325
+ }
326
+ ]
327
+ ],
328
+ }
329
+ ],
330
+ }
331
+ ]
332
+
333
+ async def handler(_: Request) -> Response:
334
+ return Response(200, json=payload)
335
+
336
+ notebook = NotebookRef(notebook_id=23, title="Shared Example", owner_userid=202, scope="shared")
337
+ source = RecordLocator(notebook=notebook, record=RecordRef(record_id=9001, record_uid="REC-EXAMPLE-001"))
338
+ async with AsyncClient(base_url="https://matelab.example/api", transport=MockTransport(handler)) as http_client:
339
+ client = AsyncMatelab("https://ignored.example", session=_session(), http_client=http_client)
340
+ with pytest.raises(MatelabProtocolError, match="does not match the Integration Contract"):
341
+ _ = await client.records.read(source)
342
+
343
+
253
344
  @pytest.mark.asyncio
254
345
  async def test_read_current_record_rejects_mismatched_uid() -> None:
255
346
  async def handler(_: Request) -> Response:
@@ -352,7 +352,7 @@ wheels = [
352
352
 
353
353
  [[package]]
354
354
  name = "matelab-python-sdk"
355
- version = "0.1.0a19"
355
+ version = "0.1.0a20"
356
356
  source = { editable = "." }
357
357
  dependencies = [
358
358
  { name = "httpx2" },