matelab-python-sdk 0.1.0a13__tar.gz → 0.1.0a15__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.0a13 → matelab_python_sdk-0.1.0a15}/CHANGELOG.md +16 -0
  2. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/PKG-INFO +61 -5
  3. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/README.md +60 -4
  4. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/pyproject.toml +1 -1
  5. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/src/matelab/__init__.py +49 -3
  6. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/src/matelab/domains/notebooks.py +37 -14
  7. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/src/matelab/domains/records.py +31 -14
  8. matelab_python_sdk-0.1.0a15/src/matelab/domains/template_documents.py +527 -0
  9. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/src/matelab/domains/templates.py +26 -12
  10. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/test_extended_records.py +17 -9
  11. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/test_notebook_lifecycle.py +16 -0
  12. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/test_notebooks.py +9 -5
  13. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/test_records.py +0 -12
  14. matelab_python_sdk-0.1.0a15/tests/test_template_documents.py +393 -0
  15. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/typing/consumer.py +27 -0
  16. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/uv.lock +1 -1
  17. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/.env.example +0 -0
  18. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/.github/workflows/release.yml +0 -0
  19. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/.gitignore +0 -0
  20. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/AGENTS.md +0 -0
  21. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/LICENSE +0 -0
  22. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/NOTICE +0 -0
  23. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/contracts/matelab-integration-v1.lock.json +0 -0
  24. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/contracts/matelab-integration-v1.openapi.yaml +0 -0
  25. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/docs/operation-coverage.yaml +0 -0
  26. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/scripts/check_installed_package.py +0 -0
  27. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/scripts/check_release.py +0 -0
  28. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/scripts/generate_models.py +0 -0
  29. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/src/matelab/_generated/__init__.py +0 -0
  30. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/src/matelab/_generated/models.py +0 -0
  31. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/src/matelab/_transport.py +0 -0
  32. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/src/matelab/_wire_validation.py +0 -0
  33. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/src/matelab/client.py +0 -0
  34. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/src/matelab/domains/__init__.py +0 -0
  35. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/src/matelab/domains/_attachment_content.py +0 -0
  36. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/src/matelab/domains/cloud_drive.py +0 -0
  37. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/src/matelab/domains/groups.py +0 -0
  38. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/src/matelab/domains/literature.py +0 -0
  39. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/src/matelab/domains/uploads.py +0 -0
  40. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/src/matelab/domains/users.py +0 -0
  41. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/src/matelab/errors.py +0 -0
  42. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/src/matelab/py.typed +0 -0
  43. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/src/matelab/session.py +0 -0
  44. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/src/matelab/streaming.py +0 -0
  45. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/conftest.py +0 -0
  46. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/provider/test_provider_smoke.py +0 -0
  47. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/test_async_attachment_upload.py +0 -0
  48. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/test_client.py +0 -0
  49. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/test_cloud_drive.py +0 -0
  50. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/test_comments_and_downloads.py +0 -0
  51. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/test_consumer_smoke.py +0 -0
  52. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/test_errors.py +0 -0
  53. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/test_generation.py +0 -0
  54. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/test_groups.py +0 -0
  55. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/test_operation_coverage.py +0 -0
  56. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/test_record_lifecycle.py +0 -0
  57. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/test_record_mutations.py +0 -0
  58. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/test_release.py +0 -0
  59. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/test_streaming.py +0 -0
  60. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/test_template_lifecycle.py +0 -0
  61. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/test_templates.py +0 -0
  62. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/test_transport.py +0 -0
  63. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/test_uploads_and_literature.py +0 -0
  64. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/test_users.py +0 -0
  65. {matelab_python_sdk-0.1.0a13 → matelab_python_sdk-0.1.0a15}/tests/typing/pyproject.toml +0 -0
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0a15 - 2026-08-10
4
+
5
+ - Breaking: removes `PublicNotebookRef` and uses one `NotebookRef` with `scope="owned"`, `"shared"`, or `"public"`.
6
+ Public `notebook_id` values identify `eln_public` catalog rows rather than source ELNs; public record results retain
7
+ their separate source notebook identity.
8
+ - Centralizes notebook-scope validation: public record listing accepts only a public ref with a non-empty title,
9
+ private operations reject public refs, and owned-only notebook mutations reject shared/public refs. `NotebookRef.title`
10
+ is a non-empty `str` preserved without whitespace normalization; an unexpected null from the wider owned/shared wire
11
+ contract is reported as a protocol violation, matching Provider write validation and audited runtime data.
12
+
13
+ ## 0.1.0a14 - 2026-08-10
14
+
15
+ - Adds immutable semantic template modules, editable `TemplateDocument` values, ordered name-based patch intents,
16
+ stable content fingerprints, and `Templates.read_document()`, `save_document()`, and `patch_content()` while
17
+ retaining the low-level `TemplateModule`, `read()`, and `save_content()` interface.
18
+
3
19
  ## 0.1.0a13 - 2026-08-08
4
20
 
5
21
  - Breaking: `Identity.username` now removes surrounding whitespace and rejects values that become empty.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matelab-python-sdk
3
- Version: 0.1.0a13
3
+ Version: 0.1.0a15
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
@@ -30,7 +30,7 @@ Description-Content-Type: text/markdown
30
30
 
31
31
  Reusable async Python client for the Matelab Integration Contract.
32
32
 
33
- The current alpha is `0.1.0a13`. `[project].version` in `pyproject.toml` is the sole SDK version source;
33
+ The current alpha is `0.1.0a15`. `[project].version` in `pyproject.toml` is the sole SDK version source;
34
34
  `uv.lock` only mirrors that source.
35
35
 
36
36
  The SDK is pinned to the immutable `matelab-spec v0.4.0` Contract Release. The sole release pin is
@@ -59,7 +59,7 @@ To test the same artifact a downstream Consumer will install, build and install
59
59
 
60
60
  ```bash
61
61
  uv build --no-build-isolation --out-dir dist/release
62
- python -m pip install dist/release/matelab_python_sdk-0.1.0a13-py3-none-any.whl
62
+ python -m pip install dist/release/matelab_python_sdk-0.1.0a15-py3-none-any.whl
63
63
  ```
64
64
 
65
65
  Do not infer Provider compatibility from the SDK version alone. A release is also bound to the Contract
@@ -450,6 +450,52 @@ market = await client.templates.search_market("calibration", page=1, page_size=2
450
450
  modules = await client.templates.read(templates.owned[0].ref)
451
451
  ```
452
452
 
453
+ Semantic template documents hide canonical `uid`, `width`, `data`, `rows`, `folders`, and option encoding:
454
+
455
+ ```python
456
+ from matelab import (
457
+ TemplateDocument,
458
+ TemplateFieldAdd,
459
+ TemplateFormField,
460
+ TemplateFormModule,
461
+ TemplatePatch,
462
+ TemplateRichTextModule,
463
+ )
464
+
465
+ document = TemplateDocument.create(
466
+ (
467
+ TemplateFormModule(
468
+ name="Basic",
469
+ fields=(TemplateFormField(name="Temperature", field_type="number", required=True, unit="K"),),
470
+ ),
471
+ TemplateRichTextModule(name="Notes", content="<p>Instructions</p>"),
472
+ )
473
+ )
474
+ template = await client.templates.create(title="Measurement")
475
+ await client.templates.save_document(template, document)
476
+
477
+ observed = await client.templates.read_document(template)
478
+ await client.templates.patch_content(
479
+ template,
480
+ TemplatePatch(
481
+ changes=(
482
+ TemplateFieldAdd(
483
+ module="Basic",
484
+ field=TemplateFormField(name="Operator", field_type="text", required=True),
485
+ ),
486
+ )
487
+ ),
488
+ expected_fingerprint=observed.fingerprint,
489
+ )
490
+ updated = await client.templates.read_document(template)
491
+ ```
492
+
493
+ `TemplateDocument` is immutable at its interface, preserves unknown canonical properties, keeps existing UIDs stable,
494
+ and converts losslessly back to `TemplateModule`. Patches are ordered name-based intents and reject missing, duplicate,
495
+ or incompatible targets before saving. The fingerprint preflight detects a stale prior read, but is client-side only:
496
+ the current Provider has no atomic template patch or CAS (PCG-007). `patch_content()` returns `None`; explicitly
497
+ re-read before using a new fingerprint because the mutation response contains no saved representation.
498
+
453
499
  The market result reports the Provider `total_count`, the requested and effective page sizes, and a `has_more`
454
500
  value derived from the total; it does not claim a stable order or continuation token. Canonical
455
501
  modules are mapped to public `TemplateModule` values and retain additive module attributes. Template reads remain
@@ -612,8 +658,18 @@ deletion is named `permanently_delete_files` and is non-recoverable. Cloud downl
612
658
  resolve bytes from the final file identity and reuse `DownloadStream`, thumbnail/preview choices and the PVD-002-safe
613
659
  range subset. Cloud mutations are not automatically retried.
614
660
 
615
- Owned/shared `NotebookRef`, public `PublicNotebookRef`, `RecordRef`, and `RecordVersionRef` keep
616
- Provider identifiers distinct. Historical reads first re-read the authorized current record and confirm
661
+ Owned, shared, and public catalog notebooks use one `NotebookRef`, distinguished by its
662
+ `scope` (`"owned"`, `"shared"`, or `"public"`). For owned/shared refs, `notebook_id` is the source `eln.id`;
663
+ for public refs it is the distinct `eln_public.id` catalog identity and must not be treated as a source ELN ID.
664
+ Public record entries expose that source identity separately as `PublicRecordRef.source_notebook_id`.
665
+ `NotebookRef.title` is a non-empty `str` preserved exactly as returned, without whitespace normalization. The pinned
666
+ owned/shared wire field remains nullable because the database column allows null, but Provider writes require a
667
+ non-empty title and a runtime database audit found no null or empty titles; the SDK therefore treats a null
668
+ notebook-list title as a protocol violation instead of exposing it publicly. Public
669
+ record listing additionally requires a non-empty title selector. Private operations reject public scope, and
670
+ public record listing accepts only public scope, while
671
+ owned-only notebook mutations continue to reject shared/public refs. `RecordRef` and `RecordVersionRef` keep
672
+ their Provider identifiers distinct. Historical reads first re-read the authorized current record and confirm
617
673
  that the requested version is still present in its `modify_log`; both reads write Provider audit entries.
618
674
 
619
675
  Errors are separated into semantic Provider errors, authentication errors, HTTP/transport errors,
@@ -2,7 +2,7 @@
2
2
 
3
3
  Reusable async Python client for the Matelab Integration Contract.
4
4
 
5
- The current alpha is `0.1.0a13`. `[project].version` in `pyproject.toml` is the sole SDK version source;
5
+ The current alpha is `0.1.0a15`. `[project].version` in `pyproject.toml` is the sole SDK version source;
6
6
  `uv.lock` only mirrors that source.
7
7
 
8
8
  The SDK is pinned to the immutable `matelab-spec v0.4.0` Contract Release. The sole release pin is
@@ -31,7 +31,7 @@ To test the same artifact a downstream Consumer will install, build and install
31
31
 
32
32
  ```bash
33
33
  uv build --no-build-isolation --out-dir dist/release
34
- python -m pip install dist/release/matelab_python_sdk-0.1.0a13-py3-none-any.whl
34
+ python -m pip install dist/release/matelab_python_sdk-0.1.0a15-py3-none-any.whl
35
35
  ```
36
36
 
37
37
  Do not infer Provider compatibility from the SDK version alone. A release is also bound to the Contract
@@ -422,6 +422,52 @@ market = await client.templates.search_market("calibration", page=1, page_size=2
422
422
  modules = await client.templates.read(templates.owned[0].ref)
423
423
  ```
424
424
 
425
+ Semantic template documents hide canonical `uid`, `width`, `data`, `rows`, `folders`, and option encoding:
426
+
427
+ ```python
428
+ from matelab import (
429
+ TemplateDocument,
430
+ TemplateFieldAdd,
431
+ TemplateFormField,
432
+ TemplateFormModule,
433
+ TemplatePatch,
434
+ TemplateRichTextModule,
435
+ )
436
+
437
+ document = TemplateDocument.create(
438
+ (
439
+ TemplateFormModule(
440
+ name="Basic",
441
+ fields=(TemplateFormField(name="Temperature", field_type="number", required=True, unit="K"),),
442
+ ),
443
+ TemplateRichTextModule(name="Notes", content="<p>Instructions</p>"),
444
+ )
445
+ )
446
+ template = await client.templates.create(title="Measurement")
447
+ await client.templates.save_document(template, document)
448
+
449
+ observed = await client.templates.read_document(template)
450
+ await client.templates.patch_content(
451
+ template,
452
+ TemplatePatch(
453
+ changes=(
454
+ TemplateFieldAdd(
455
+ module="Basic",
456
+ field=TemplateFormField(name="Operator", field_type="text", required=True),
457
+ ),
458
+ )
459
+ ),
460
+ expected_fingerprint=observed.fingerprint,
461
+ )
462
+ updated = await client.templates.read_document(template)
463
+ ```
464
+
465
+ `TemplateDocument` is immutable at its interface, preserves unknown canonical properties, keeps existing UIDs stable,
466
+ and converts losslessly back to `TemplateModule`. Patches are ordered name-based intents and reject missing, duplicate,
467
+ or incompatible targets before saving. The fingerprint preflight detects a stale prior read, but is client-side only:
468
+ the current Provider has no atomic template patch or CAS (PCG-007). `patch_content()` returns `None`; explicitly
469
+ re-read before using a new fingerprint because the mutation response contains no saved representation.
470
+
425
471
  The market result reports the Provider `total_count`, the requested and effective page sizes, and a `has_more`
426
472
  value derived from the total; it does not claim a stable order or continuation token. Canonical
427
473
  modules are mapped to public `TemplateModule` values and retain additive module attributes. Template reads remain
@@ -584,8 +630,18 @@ deletion is named `permanently_delete_files` and is non-recoverable. Cloud downl
584
630
  resolve bytes from the final file identity and reuse `DownloadStream`, thumbnail/preview choices and the PVD-002-safe
585
631
  range subset. Cloud mutations are not automatically retried.
586
632
 
587
- Owned/shared `NotebookRef`, public `PublicNotebookRef`, `RecordRef`, and `RecordVersionRef` keep
588
- Provider identifiers distinct. Historical reads first re-read the authorized current record and confirm
633
+ Owned, shared, and public catalog notebooks use one `NotebookRef`, distinguished by its
634
+ `scope` (`"owned"`, `"shared"`, or `"public"`). For owned/shared refs, `notebook_id` is the source `eln.id`;
635
+ for public refs it is the distinct `eln_public.id` catalog identity and must not be treated as a source ELN ID.
636
+ Public record entries expose that source identity separately as `PublicRecordRef.source_notebook_id`.
637
+ `NotebookRef.title` is a non-empty `str` preserved exactly as returned, without whitespace normalization. The pinned
638
+ owned/shared wire field remains nullable because the database column allows null, but Provider writes require a
639
+ non-empty title and a runtime database audit found no null or empty titles; the SDK therefore treats a null
640
+ notebook-list title as a protocol violation instead of exposing it publicly. Public
641
+ record listing additionally requires a non-empty title selector. Private operations reject public scope, and
642
+ public record listing accepts only public scope, while
643
+ owned-only notebook mutations continue to reject shared/public refs. `RecordRef` and `RecordVersionRef` keep
644
+ their Provider identifiers distinct. Historical reads first re-read the authorized current record and confirm
589
645
  that the requested version is still present in its `modify_log`; both reads write Provider audit entries.
590
646
 
591
647
  Errors are separated into semantic Provider errors, authentication errors, HTTP/transport errors,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "matelab-python-sdk"
7
- version = "0.1.0a13"
7
+ version = "0.1.0a15"
8
8
  description = "Reusable async Python client for the Matelab Integration Contract"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -42,7 +42,6 @@ from matelab.domains.notebooks import (
42
42
  NotebookShareRef,
43
43
  NotebookShares,
44
44
  NotebookSummary,
45
- PublicNotebookRef,
46
45
  PublicNotebookSummary,
47
46
  )
48
47
  from matelab.domains.records import (
@@ -99,13 +98,38 @@ from matelab.domains.records import (
99
98
  StagedRecordAttachment,
100
99
  StagedRecordCommentAttachment,
101
100
  )
101
+ from matelab.domains.template_documents import (
102
+ TemplateChange,
103
+ TemplateCodeLanguage,
104
+ TemplateCodeModule,
105
+ TemplateCodeStyle,
106
+ TemplateDocument,
107
+ TemplateFieldAdd,
108
+ TemplateFieldRemove,
109
+ TemplateFieldRename,
110
+ TemplateFieldUpdate,
111
+ TemplateFilesModule,
112
+ TemplateFormField,
113
+ TemplateFormFieldType,
114
+ TemplateFormModule,
115
+ TemplateModule,
116
+ TemplateModuleAdd,
117
+ TemplateModuleContentUpdate,
118
+ TemplateModuleDefinition,
119
+ TemplateModuleRemove,
120
+ TemplateModuleRename,
121
+ TemplatePatch,
122
+ TemplateRichTextModule,
123
+ TemplateTableColumn,
124
+ TemplateTableColumnType,
125
+ TemplateTableModule,
126
+ )
102
127
  from matelab.domains.templates import (
103
128
  TemplateCollection,
104
129
  TemplateLibraryEntryRef,
105
130
  TemplateLibraryRelationKind,
106
131
  TemplateMarketEntry,
107
132
  TemplateMarketPage,
108
- TemplateModule,
109
133
  TemplateRef,
110
134
  TemplateSource,
111
135
  TemplateSummary,
@@ -180,7 +204,6 @@ __all__ = [
180
204
  "PendingLiteratureShare",
181
205
  "PendingLiteratureShareComment",
182
206
  "PersonalLiteratureItemRef",
183
- "PublicNotebookRef",
184
207
  "PublicNotebookSummary",
185
208
  "PublicRecordRef",
186
209
  "PublicRecordSummary",
@@ -235,15 +258,38 @@ __all__ = [
235
258
  "StagedRecordAttachment",
236
259
  "StagedRecordCommentAttachment",
237
260
  "StagedUploadSession",
261
+ "TemplateChange",
262
+ "TemplateCodeLanguage",
263
+ "TemplateCodeModule",
264
+ "TemplateCodeStyle",
238
265
  "TemplateCollection",
266
+ "TemplateDocument",
267
+ "TemplateFieldAdd",
268
+ "TemplateFieldRemove",
269
+ "TemplateFieldRename",
270
+ "TemplateFieldUpdate",
271
+ "TemplateFilesModule",
272
+ "TemplateFormField",
273
+ "TemplateFormFieldType",
274
+ "TemplateFormModule",
239
275
  "TemplateLibraryEntryRef",
240
276
  "TemplateLibraryRelationKind",
241
277
  "TemplateMarketEntry",
242
278
  "TemplateMarketPage",
243
279
  "TemplateModule",
280
+ "TemplateModuleAdd",
281
+ "TemplateModuleContentUpdate",
282
+ "TemplateModuleDefinition",
283
+ "TemplateModuleRemove",
284
+ "TemplateModuleRename",
285
+ "TemplatePatch",
244
286
  "TemplateRef",
287
+ "TemplateRichTextModule",
245
288
  "TemplateSource",
246
289
  "TemplateSummary",
290
+ "TemplateTableColumn",
291
+ "TemplateTableColumnType",
292
+ "TemplateTableModule",
247
293
  "Token",
248
294
  "UploadBindingRef",
249
295
  "UserRef",
@@ -10,7 +10,7 @@ from matelab._transport import Encoding, Operation, SessionTransport
10
10
  from matelab._wire_validation import encode_json_field, validate_request
11
11
  from matelab.domains.groups import GroupRef
12
12
  from matelab.domains.users import UserRef
13
- from matelab.errors import MatelabUsageError
13
+ from matelab.errors import MatelabProtocolError, MatelabUsageError
14
14
 
15
15
  _LIST_NOTEBOOKS = Operation(
16
16
  method="GET",
@@ -60,21 +60,39 @@ _DELETE_SHARE = Operation(
60
60
 
61
61
  @dataclass(frozen=True, slots=True)
62
62
  class NotebookRef:
63
- """Identity and exact Provider selector for an owned or shared notebook."""
63
+ """Notebook identity and exact Provider selector across all visibility scopes.
64
+
65
+ ``notebook_id`` is an ``eln.id`` for owned/shared scopes, but an
66
+ ``eln_public.id`` catalog identity for public scope. It is not the source
67
+ ELN identity returned by public record entries. ``title`` is intentionally
68
+ non-empty and preserved verbatim: Provider writes require it and runtime
69
+ data contains no null or empty titles, even though the pinned owned/shared
70
+ wire field remains nullable.
71
+ """
64
72
 
65
73
  notebook_id: int
66
- title: str | None
74
+ title: str
67
75
  owner_userid: int
68
- scope: Literal["owned", "shared"]
76
+ scope: Literal["owned", "shared", "public"]
69
77
 
78
+ def __post_init__(self) -> None:
79
+ if not self.title:
80
+ raise ValueError("Notebook title cannot be empty.")
70
81
 
71
- @dataclass(frozen=True, slots=True)
72
- class PublicNotebookRef:
73
- """Identity and title selector for a public-catalog notebook."""
74
82
 
75
- public_notebook_id: int
76
- title: str
77
- owner_userid: int
83
+ def require_private_notebook(notebook: NotebookRef) -> None:
84
+ if notebook.scope == "public":
85
+ raise MatelabUsageError("This operation requires an owned or shared NotebookRef, not public scope.")
86
+
87
+
88
+ def _require_owned_notebook(notebook: NotebookRef) -> None:
89
+ if notebook.scope != "owned":
90
+ raise MatelabUsageError("This operation requires an owned NotebookRef.")
91
+
92
+
93
+ def require_public_notebook(notebook: NotebookRef) -> None:
94
+ if notebook.scope != "public":
95
+ raise MatelabUsageError("Public record listing requires a public NotebookRef.")
78
96
 
79
97
 
80
98
  @dataclass(frozen=True, slots=True)
@@ -99,7 +117,7 @@ class NotebookSummary:
99
117
 
100
118
  @dataclass(frozen=True, slots=True)
101
119
  class PublicNotebookSummary:
102
- ref: PublicNotebookRef
120
+ ref: NotebookRef
103
121
  summary: str
104
122
  record_count: int
105
123
  created_at: str
@@ -204,6 +222,8 @@ def _parse_display_fields(raw: str | None) -> tuple[tuple[NotebookListField, ...
204
222
 
205
223
 
206
224
  def _owned_or_shared(item: wire_models.ElnNotebook, *, scope: Literal["owned", "shared"]) -> NotebookSummary:
225
+ if item.showtext is None:
226
+ raise MatelabProtocolError("Matelab returned a null notebook title, violating the SDK runtime invariant.")
207
227
  display_fields, configuration_valid = _parse_display_fields(item.requests)
208
228
  return NotebookSummary(
209
229
  ref=NotebookRef(notebook_id=item.id, title=item.showtext, owner_userid=item.userid, scope=scope),
@@ -232,7 +252,7 @@ class Notebooks:
232
252
  shared=tuple(_owned_or_shared(item, scope="shared") for item in response.share),
233
253
  public=tuple(
234
254
  PublicNotebookSummary(
235
- ref=PublicNotebookRef(public_notebook_id=item.id, title=item.showtext, owner_userid=item.userid),
255
+ ref=NotebookRef(notebook_id=item.id, title=item.showtext, owner_userid=item.userid, scope="public"),
236
256
  summary=item.comm,
237
257
  record_count=item.num,
238
258
  created_at=item.datetime_create,
@@ -270,8 +290,7 @@ class Notebooks:
270
290
  notebook_types: tuple[JsonValue, ...] = (),
271
291
  display_fields: tuple[NotebookListField, ...] | None = None,
272
292
  ) -> None:
273
- if notebook.scope != "owned":
274
- raise MatelabUsageError("Notebook metadata can only be updated through an owned NotebookRef.")
293
+ _require_owned_notebook(notebook)
275
294
  request = _save_request(
276
295
  title=title,
277
296
  summary=summary,
@@ -282,6 +301,7 @@ class Notebooks:
282
301
  _ = await self._transport.request(_SAVE_NOTEBOOK, payload=request.model_dump(mode="json", exclude_unset=True))
283
302
 
284
303
  async def shares(self, notebook: NotebookRef) -> NotebookShares:
304
+ require_private_notebook(notebook)
285
305
  request = validate_request(
286
306
  wire_models.ListNotebookSharesParametersQuery,
287
307
  {"id": notebook.notebook_id},
@@ -309,6 +329,7 @@ class Notebooks:
309
329
  )
310
330
 
311
331
  async def share(self, notebook: NotebookRef, recipients: Sequence[UserRef]) -> None:
332
+ _require_owned_notebook(notebook)
312
333
  request = validate_request(
313
334
  wire_models.AddNotebookShareRequest,
314
335
  {
@@ -331,6 +352,7 @@ class Notebooks:
331
352
  read_audit_log: bool = False,
332
353
  manage_shares: bool = False,
333
354
  ) -> None:
355
+ _require_owned_notebook(ref.notebook)
334
356
  request = validate_request(
335
357
  wire_models.UpdateNotebookSharePermissionsRequest,
336
358
  {
@@ -348,6 +370,7 @@ class Notebooks:
348
370
  _ = await self._transport.request(_UPDATE_SHARE, payload=request)
349
371
 
350
372
  async def unshare(self, ref: NotebookShareRef) -> None:
373
+ _require_owned_notebook(ref.notebook)
351
374
  request = validate_request(
352
375
  wire_models.DeleteNotebookShareRequest,
353
376
  {"id": ref.share_row_id},
@@ -15,7 +15,7 @@ from matelab._generated import models as wire_models
15
15
  from matelab._transport import Encoding, Operation, SessionTransport, StreamOperation
16
16
  from matelab._wire_validation import encode_json_field, validate_request
17
17
  from matelab.domains._attachment_content import validated_attachment_content
18
- from matelab.domains.notebooks import NotebookRef, PublicNotebookRef
18
+ from matelab.domains.notebooks import NotebookRef, require_private_notebook, require_public_notebook
19
19
  from matelab.domains.uploads import UploadBindingRef
20
20
  from matelab.domains.users import UserRef
21
21
  from matelab.errors import MatelabProtocolError, MatelabUsageError
@@ -1135,14 +1135,13 @@ def _render_comment(
1135
1135
  return wire_html.strip(), canonical_html.strip(), next(iter(binding_uids), "")
1136
1136
 
1137
1137
 
1138
- def _notebook_title(notebook: NotebookRef) -> str:
1139
- if notebook.title is None:
1140
- raise MatelabUsageError("Notebook selector cannot use a null Provider title.")
1138
+ def _private_notebook_title(notebook: NotebookRef) -> str:
1139
+ require_private_notebook(notebook)
1141
1140
  return notebook.title
1142
1141
 
1143
1142
 
1144
1143
  def _notebook_selector(notebook: NotebookRef) -> str | dict[str, JsonValue]:
1145
- title = _notebook_title(notebook)
1144
+ title = _private_notebook_title(notebook)
1146
1145
  if notebook.scope == "shared":
1147
1146
  return {"title": title, "user": notebook.owner_userid}
1148
1147
  return title
@@ -1213,12 +1212,11 @@ class Records:
1213
1212
  keywords: Sequence[str] | None = None,
1214
1213
  content: str | None = None,
1215
1214
  ) -> RecordCollection:
1216
- if notebook.title is None:
1217
- raise MatelabUsageError("Cannot list records for a notebook whose Provider title is null.")
1215
+ title = _private_notebook_title(notebook)
1218
1216
  request = validate_request(
1219
1217
  wire_models.ListRecordsRequest,
1220
1218
  {
1221
- "eln": notebook.title,
1219
+ "eln": title,
1222
1220
  "user": notebook.owner_userid if notebook.scope == "shared" else None,
1223
1221
  "date_start": date_start if date_start is not None else "",
1224
1222
  "date_end": date_end if date_end is not None else "",
@@ -1245,7 +1243,7 @@ class Records:
1245
1243
  record_uid: str | None = None,
1246
1244
  path: Sequence[str] | None = None,
1247
1245
  ) -> None:
1248
- notebook_title = _notebook_title(notebook)
1246
+ notebook_title = _private_notebook_title(notebook)
1249
1247
  request = validate_request(
1250
1248
  wire_models.CreateRecordRequest,
1251
1249
  {
@@ -1268,7 +1266,7 @@ class Records:
1268
1266
  items: Sequence[RecordImportItem],
1269
1267
  template_owner: UserRef | None = None,
1270
1268
  ) -> RecordImportResult:
1271
- notebook_title = _notebook_title(notebook)
1269
+ notebook_title = _private_notebook_title(notebook)
1272
1270
  staged_attachments: set[_StagedNotebookAttachmentIdentity] = set()
1273
1271
  payload: dict[str, JsonValue] = {
1274
1272
  "eln": notebook_title,
@@ -1316,6 +1314,8 @@ class Records:
1316
1314
  record_uid: str | None = None,
1317
1315
  ) -> RecordCopyResult:
1318
1316
  target = target_notebook or source.notebook
1317
+ require_private_notebook(source.notebook)
1318
+ require_private_notebook(target)
1319
1319
  request = validate_request(
1320
1320
  wire_models.CopyRecordRequest,
1321
1321
  {
@@ -1331,7 +1331,7 @@ class Records:
1331
1331
  return RecordCopyResult(record_database_id=response.id)
1332
1332
 
1333
1333
  async def delete(self, *, notebook: NotebookRef, records: Sequence[RecordRef]) -> None:
1334
- notebook_title = _notebook_title(notebook)
1334
+ notebook_title = _private_notebook_title(notebook)
1335
1335
  request = validate_request(
1336
1336
  wire_models.DeleteRecordsRequest,
1337
1337
  {
@@ -1344,6 +1344,7 @@ class Records:
1344
1344
  _ = await self._transport.request(_DELETE_RECORDS, payload=request)
1345
1345
 
1346
1346
  async def restore(self, *, notebook: NotebookRef, records: Sequence[DeletedRecordRef]) -> None:
1347
+ require_private_notebook(notebook)
1347
1348
  request = validate_request(
1348
1349
  wire_models.RestoreRecordsRequest,
1349
1350
  {
@@ -1365,6 +1366,7 @@ class Records:
1365
1366
  binding: UploadBindingRef | None = None,
1366
1367
  content_type: str = "application/octet-stream",
1367
1368
  ) -> StagedRecordAttachment:
1369
+ require_private_notebook(source.notebook)
1368
1370
  selected_binding = binding or UploadBindingRef.new()
1369
1371
  response, actual_size, actual_sha256 = await self._upload_record_attachment(
1370
1372
  notebook=source.notebook,
@@ -1403,6 +1405,7 @@ class Records:
1403
1405
  one safe form-file-field update.
1404
1406
  """
1405
1407
 
1408
+ require_private_notebook(notebook)
1406
1409
  uploader = self._authenticated_uploader()
1407
1410
  selected_binding = binding or UploadBindingRef.new()
1408
1411
  response, actual_size, actual_sha256 = await self._upload_record_attachment(
@@ -1440,6 +1443,7 @@ class Records:
1440
1443
  expected_content_sha256: str | None = None,
1441
1444
  password: str | None = None,
1442
1445
  ) -> Literal["provider_reported_persisted", "pending_browser_save", "provider_acknowledged_unclassified"]:
1446
+ require_private_notebook(source.notebook)
1443
1447
  if expected_content_sha256 is not None and re.fullmatch(r"[0-9a-f]{64}", expected_content_sha256) is None:
1444
1448
  raise MatelabUsageError("Expected record content fingerprint must be a lowercase SHA-256.")
1445
1449
  selected_value_updates = dict(value_updates or {})
@@ -1497,7 +1501,7 @@ class Records:
1497
1501
  request = validate_request(
1498
1502
  wire_models.UpdateRecordRequest,
1499
1503
  {
1500
- "eln": _notebook_title(source.notebook),
1504
+ "eln": _private_notebook_title(source.notebook),
1501
1505
  "uid": source.record.record_uid,
1502
1506
  "user": source.notebook.owner_userid if source.notebook.scope == "shared" else None,
1503
1507
  "password": password,
@@ -1588,7 +1592,8 @@ class Records:
1588
1592
  )
1589
1593
  return RecordSearchResult(matches=tuple(matches), forwarded=response.code == 10)
1590
1594
 
1591
- async def list_public(self, notebook: PublicNotebookRef) -> tuple[PublicRecordSummary, ...]:
1595
+ async def list_public(self, notebook: NotebookRef) -> tuple[PublicRecordSummary, ...]:
1596
+ require_public_notebook(notebook)
1592
1597
  request = validate_request(
1593
1598
  wire_models.PublicRecordListRequest,
1594
1599
  {"eln": notebook.title},
@@ -1626,6 +1631,7 @@ class Records:
1626
1631
  content: str = "",
1627
1632
  keywords: Sequence[str] = (),
1628
1633
  ) -> RecordPage:
1634
+ require_private_notebook(notebook)
1629
1635
  encoded_keywords = encode_json_field(list(keywords), ensure_ascii=False) if keywords else ""
1630
1636
  request = validate_request(
1631
1637
  wire_models.ListNotebookRecordPageParametersQuery,
@@ -1677,6 +1683,7 @@ class Records:
1677
1683
  )
1678
1684
 
1679
1685
  async def recycle_bin(self, notebook: NotebookRef, *, page: int = 1, page_size: int = 24) -> DeletedRecordPage:
1686
+ require_private_notebook(notebook)
1680
1687
  if not 10 <= page_size <= 256:
1681
1688
  raise MatelabUsageError("Recycle-bin page size must be between 10 and 256.")
1682
1689
  request = validate_request(
@@ -1708,6 +1715,7 @@ class Records:
1708
1715
  )
1709
1716
 
1710
1717
  async def relations(self, *, notebook: NotebookRef, record: RecordRef) -> RecordRelations:
1718
+ require_private_notebook(notebook)
1711
1719
  request = validate_request(
1712
1720
  wire_models.ListRecordRelationsParametersQuery,
1713
1721
  {"eln": notebook.notebook_id, "item_id": record.record_database_id},
@@ -1736,6 +1744,8 @@ class Records:
1736
1744
  )
1737
1745
 
1738
1746
  async def add_relation(self, *, source: RecordLocator, target: RecordLocator) -> None:
1747
+ require_private_notebook(source.notebook)
1748
+ require_private_notebook(target.notebook)
1739
1749
  source_record = await self.read(notebook=source.notebook, record=source.record)
1740
1750
  source_relations = await self.relations(notebook=source.notebook, record=source.record)
1741
1751
  if not source_relations.editable:
@@ -1761,6 +1771,7 @@ class Records:
1761
1771
 
1762
1772
  async def delete_relation(self, relation: RecordRelation) -> None:
1763
1773
  source = relation.ref.source
1774
+ require_private_notebook(source.notebook)
1764
1775
  before = await self.relations(notebook=source.notebook, record=source.record)
1765
1776
  if not before.editable:
1766
1777
  raise MatelabUsageError("The selected source record is not currently editable.")
@@ -1788,6 +1799,7 @@ class Records:
1788
1799
  _ = await self._transport.request(_DELETE_RELATION, payload=request)
1789
1800
 
1790
1801
  async def comments(self, *, notebook: NotebookRef, record: RecordRef) -> tuple[RecordComment, ...]:
1802
+ require_private_notebook(notebook)
1791
1803
  request = validate_request(
1792
1804
  wire_models.ListRecordCommentsParametersQuery,
1793
1805
  {"eln": notebook.notebook_id, "item_id": record.record_database_id},
@@ -1808,6 +1820,7 @@ class Records:
1808
1820
  binding: UploadBindingRef | None = None,
1809
1821
  content_type: str = "application/octet-stream",
1810
1822
  ) -> StagedRecordCommentAttachment:
1823
+ require_private_notebook(source.notebook)
1811
1824
  selected_binding = binding or UploadBindingRef.new()
1812
1825
  _validate_upload_metadata(filename=filename, size=size, sha256=sha256, content_type=content_type)
1813
1826
  async with validated_attachment_content(content, size=size, sha256=sha256) as prepared:
@@ -1864,6 +1877,7 @@ class Records:
1864
1877
  html: str,
1865
1878
  attachments: Mapping[str, StagedRecordCommentAttachment] | None = None,
1866
1879
  ) -> None:
1880
+ require_private_notebook(source.notebook)
1867
1881
  wire_html, _, binding_uid = _render_comment(source, html=html, attachments=attachments)
1868
1882
  await self._save_comment_request(source=source, comment_id=0, html=wire_html, binding_uid=binding_uid)
1869
1883
 
@@ -1904,6 +1918,7 @@ class Records:
1904
1918
  async def download_attachment(
1905
1919
  self, attachment: RecordAttachmentRef, *, byte_range: ByteRange | None = None
1906
1920
  ) -> DownloadStream:
1921
+ require_private_notebook(attachment.source.notebook)
1907
1922
  request = validate_request(
1908
1923
  wire_models.DownloadRecordAttachmentParametersQuery,
1909
1924
  {
@@ -1933,6 +1948,7 @@ class Records:
1933
1948
  but the current Provider only authorizes the supplied notebook and does
1934
1949
  not independently verify that the attachment belongs to it.
1935
1950
  """
1951
+ require_private_notebook(attachment.source.notebook)
1936
1952
  request = validate_request(
1937
1953
  wire_models.DownloadRecordCommentAttachmentParametersQuery,
1938
1954
  {
@@ -1953,6 +1969,7 @@ class Records:
1953
1969
  async def read(
1954
1970
  self, *, notebook: NotebookRef, record: RecordRef, version: RecordVersionRef | None = None
1955
1971
  ) -> Record:
1972
+ require_private_notebook(notebook)
1956
1973
  if version is not None and (
1957
1974
  version.notebook_id != notebook.notebook_id
1958
1975
  or version.record_database_id != record.record_database_id
@@ -2622,7 +2639,7 @@ class Records:
2622
2639
  sha256: str | None,
2623
2640
  content_type: str,
2624
2641
  ) -> tuple[wire_models.UploadAttachmentResponse, int, str]:
2625
- notebook_title = _notebook_title(notebook)
2642
+ notebook_title = _private_notebook_title(notebook)
2626
2643
  _validate_upload_metadata(filename=filename, size=size, sha256=sha256, content_type=content_type)
2627
2644
  async with validated_attachment_content(content, size=size, sha256=sha256) as prepared:
2628
2645
  prepared_content, actual_size, actual_sha256 = prepared