matelab-python-sdk 0.1.0a3__tar.gz → 0.1.0a4__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 (61) hide show
  1. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/CHANGELOG.md +11 -0
  2. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/PKG-INFO +45 -33
  3. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/README.md +44 -32
  4. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/docs/roadmap.md +12 -2
  5. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/pyproject.toml +1 -2
  6. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/scripts/check_installed_package.py +2 -4
  7. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/scripts/check_release.py +61 -22
  8. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/scripts/generate_models.py +88 -3
  9. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/src/matelab/__init__.py +21 -68
  10. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/src/matelab/_generated/models.py +830 -757
  11. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/src/matelab/_transport.py +89 -70
  12. matelab_python_sdk-0.1.0a4/src/matelab/_wire_validation.py +214 -0
  13. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/src/matelab/client.py +32 -44
  14. matelab_python_sdk-0.1.0a4/src/matelab/domains/__init__.py +1 -0
  15. {matelab_python_sdk-0.1.0a3/src/matelab → matelab_python_sdk-0.1.0a4/src/matelab/domains}/cloud_drive.py +62 -116
  16. {matelab_python_sdk-0.1.0a3/src/matelab → matelab_python_sdk-0.1.0a4/src/matelab/domains}/groups.py +6 -2
  17. matelab_python_sdk-0.1.0a3/src/matelab/literature/__init__.py → matelab_python_sdk-0.1.0a4/src/matelab/domains/literature.py +95 -126
  18. {matelab_python_sdk-0.1.0a3/src/matelab → matelab_python_sdk-0.1.0a4/src/matelab/domains}/notebooks.py +84 -68
  19. matelab_python_sdk-0.1.0a3/src/matelab/records/__init__.py → matelab_python_sdk-0.1.0a4/src/matelab/domains/records.py +302 -371
  20. {matelab_python_sdk-0.1.0a3/src/matelab → matelab_python_sdk-0.1.0a4/src/matelab/domains}/templates.py +76 -100
  21. {matelab_python_sdk-0.1.0a3/src/matelab → matelab_python_sdk-0.1.0a4/src/matelab/domains}/uploads.py +5 -14
  22. {matelab_python_sdk-0.1.0a3/src/matelab → matelab_python_sdk-0.1.0a4/src/matelab/domains}/users.py +9 -23
  23. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/src/matelab/streaming.py +23 -5
  24. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_client.py +51 -1
  25. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_cloud_drive.py +6 -9
  26. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_comments_and_downloads.py +3 -3
  27. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_extended_records.py +9 -13
  28. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_generation.py +106 -0
  29. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_groups.py +22 -0
  30. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_notebook_lifecycle.py +31 -31
  31. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_operation_coverage.py +4 -1
  32. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_record_lifecycle.py +23 -12
  33. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_record_mutations.py +107 -60
  34. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_release.py +89 -12
  35. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_streaming.py +74 -4
  36. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_template_lifecycle.py +6 -6
  37. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_templates.py +12 -12
  38. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_transport.py +112 -66
  39. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_uploads_and_literature.py +45 -21
  40. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_users.py +10 -11
  41. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/typing/consumer.py +7 -10
  42. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/uv.lock +1 -1
  43. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/.env.example +0 -0
  44. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/.github/workflows/release.yml +0 -0
  45. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/.gitignore +0 -0
  46. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/AGENTS.md +0 -0
  47. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/LICENSE +0 -0
  48. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/NOTICE +0 -0
  49. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/contracts/matelab-integration-v1.lock.json +0 -0
  50. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/contracts/matelab-integration-v1.openapi.yaml +0 -0
  51. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/docs/operation-coverage.yaml +0 -0
  52. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/src/matelab/_generated/__init__.py +0 -0
  53. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/src/matelab/errors.py +0 -0
  54. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/src/matelab/py.typed +0 -0
  55. /matelab_python_sdk-0.1.0a3/src/matelab/models.py → /matelab_python_sdk-0.1.0a4/src/matelab/session.py +0 -0
  56. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/conftest.py +0 -0
  57. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/provider/test_provider_smoke.py +0 -0
  58. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_consumer_smoke.py +0 -0
  59. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_notebooks.py +0 -0
  60. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_records.py +0 -0
  61. {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/typing/pyproject.toml +0 -0
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0a4 - 2026-07-28
4
+
5
+ - Further reduces the alpha public surface from 150 to 126 symbols by accepting direct keyword arguments and existing
6
+ domain values instead of request-shaped wrapper dataclasses.
7
+ - Centralizes safe request-model validation, strengthens generated Contract constraints, and preserves structured field
8
+ diagnostics without exposing credentials, tokens, or sensitive payload values.
9
+ - Tightens Session replacement, refresh races, multipart retry safety, streaming cleanup, and exact HTTP success
10
+ handling while retaining stable transport, authentication, Provider, protocol, usage, and verification errors.
11
+ - Collects all business capabilities under `matelab.domains`, keeps client/session/error/transport primitives at the
12
+ package root, and hardens generation and release-artifact audits.
13
+
3
14
  ## 0.1.0a3 - 2026-07-28
4
15
 
5
16
  - Replaces HTTPX with HTTPX2 2.9.1 and raises the Pydantic compatibility floor to 2.13.4. Injected clients must now
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matelab-python-sdk
3
- Version: 0.1.0a3
3
+ Version: 0.1.0a4
4
4
  Summary: Reusable async Python client for the Matelab Integration Contract
5
5
  Author-email: 朱天念 <zhutiannian@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -28,7 +28,7 @@ Description-Content-Type: text/markdown
28
28
 
29
29
  Reusable async Python client for the Matelab Integration Contract.
30
30
 
31
- The current alpha is `0.1.0a3`. `[project].version` in `pyproject.toml` is the sole SDK version source;
31
+ The current alpha is `0.1.0a4`. `[project].version` in `pyproject.toml` is the sole SDK version source;
32
32
  `uv.lock` only mirrors that source.
33
33
 
34
34
  The SDK is pinned to the immutable `matelab-spec v0.1.1` Contract Release. The sole release pin is
@@ -57,7 +57,7 @@ To test the same artifact a downstream Consumer will install, build and install
57
57
 
58
58
  ```bash
59
59
  uv build --no-build-isolation --out-dir dist/release
60
- python -m pip install dist/release/matelab_python_sdk-0.1.0a3-py3-none-any.whl
60
+ python -m pip install dist/release/matelab_python_sdk-0.1.0a4-py3-none-any.whl
61
61
  ```
62
62
 
63
63
  Do not infer Provider compatibility from the SDK version alone. A release is also bound to the Contract
@@ -82,6 +82,24 @@ async with AsyncMatelab() as client:
82
82
  `AsyncMatelab()` uses `https://matelab.iphy.ac.cn/api` by default. Pass another Provider API root
83
83
  explicitly when needed, for example `AsyncMatelab("https://custom.example/api")`.
84
84
 
85
+ ### Error handling
86
+
87
+ Catch `MatelabError` for one application-level fallback, or a specific subclass when recovery differs:
88
+
89
+ | Error | Meaning and normal response |
90
+ |---|---|
91
+ | `MatelabUsageError` | The call cannot be represented safely; correct its arguments. |
92
+ | `MatelabAuthenticationError` | The Session is missing, expired, invalid, or rejected; refresh or authenticate again as appropriate. |
93
+ | `MatelabProviderError` | The Provider rejected a valid request with a business error; inspect `code` and do not assume a mutation was applied. |
94
+ | `MatelabTransportError` | The HTTP exchange failed; `status_code` is present for HTTP failures, and a mutation outcome may be unknown. |
95
+ | `MatelabProtocolError` | The Provider response does not match the pinned Contract; treat it as Provider drift or an SDK defect. |
96
+ | `MatelabVerificationError` | A mutation was acknowledged but its readback failed; the mutation may already have persisted. |
97
+
98
+ `code`, `status_code`, and the sanitized `payload` are diagnostics. The `payload` shape may contain private wire
99
+ field names and is not a stable Consumer interface; application control flow should use the exception class and
100
+ documented scalar attributes. Never automatically retry a mutation solely because it raised a transport or
101
+ verification error.
102
+
85
103
  ### Session ownership
86
104
 
87
105
  Each `AsyncMatelab` instance owns at most one current, process-local `Session`. The SDK injects its bearer
@@ -176,7 +194,7 @@ notebook selector used for upload:
176
194
  ```python
177
195
  import hashlib
178
196
 
179
- from matelab import RecordImportItem, RecordImportTemplate, TemplateRef
197
+ from matelab import RecordImportItem, TemplateRef
180
198
 
181
199
  content = b"measurement data"
182
200
  staged = await client.records.stage_attachment(
@@ -188,7 +206,8 @@ staged = await client.records.stage_attachment(
188
206
  )
189
207
  result = await client.records.import_dataset(
190
208
  notebook=notebook,
191
- template=RecordImportTemplate(template=TemplateRef(template_id=8), title="Example Template"),
209
+ template=TemplateRef(template_id=8),
210
+ template_title="Example Template",
192
211
  items=(
193
212
  RecordImportItem(
194
213
  record_uid="REC-IMPORT-001",
@@ -203,19 +222,17 @@ The same staged handle may instead be consumed by one safe update that adds a ne
203
222
  module:
204
223
 
205
224
  ```python
206
- from matelab import RecordFormAttachmentFieldAddition, RecordPatch
225
+ from matelab import RecordFormAttachmentFieldAddition
207
226
 
208
227
  # Alternative to the import above; do not run both with the same staged handle.
209
228
  result = await client.records.update(
210
229
  source,
211
- RecordPatch(
212
- attachment_changes=(
213
- RecordFormAttachmentFieldAddition(
214
- module="Attachments",
215
- name="Measurement",
216
- attachment=staged,
217
- ),
218
- )
230
+ attachment_changes=(
231
+ RecordFormAttachmentFieldAddition(
232
+ module="Attachments",
233
+ name="Measurement",
234
+ attachment=staged,
235
+ ),
219
236
  ),
220
237
  )
221
238
  ```
@@ -295,11 +312,9 @@ Provider returns members for an unstable first group and user search is unpaged,
295
312
  Notebook create/update and direct sharing keep write acknowledgement separate from what a readback can prove:
296
313
 
297
314
  ```python
298
- from matelab import NotebookMetadata, NotebookSharePermissionGrant
299
-
300
- await client.notebooks.create(NotebookMetadata(title="Example Notebook"))
315
+ await client.notebooks.create(title="Example Notebook")
301
316
  shares = await client.notebooks.share(notebook, [target.ref])
302
- updated = await client.notebooks.update_share(shares[0].ref, NotebookSharePermissionGrant(write=True, create=True))
317
+ updated = await client.notebooks.update_share(shares[0].ref, write=True, create=True)
303
318
  ```
304
319
 
305
320
  Create returns `None` because the Provider returns no identity. Owned updates return the notebook row observed by
@@ -314,7 +329,7 @@ relation identities:
314
329
  ```python
315
330
  templates = await client.templates.list()
316
331
  market = await client.templates.search_market("calibration", page=1, page_size=20)
317
- content = await client.templates.read(templates.owned[0].ref)
332
+ modules = await client.templates.read(templates.owned[0].ref)
318
333
  ```
319
334
 
320
335
  The market result reports the Provider `total_count`, the requested and effective page sizes, and a `has_more`
@@ -334,12 +349,10 @@ limitations are documented operation semantics rather than constant fields on ev
334
349
  Extended record reads stay behind the same `records` interface:
335
350
 
336
351
  ```python
337
- from matelab import RecordFieldExtraction, RecordLocator
352
+ from matelab import RecordLocator
338
353
 
339
354
  exported = await client.records.export([RecordLocator(notebook=notebook, record=record)])
340
- matches = await client.records.search(
341
- notebooks=[notebook], extractions=[RecordFieldExtraction(alias="notes", path=("Notes",))]
342
- )
355
+ matches = await client.records.search(notebooks=[notebook], extractions={"notes": ("Notes",)})
343
356
  page = await client.records.page(notebook)
344
357
  deleted = await client.records.recycle_bin(notebook)
345
358
  relations = await client.records.relations(notebook=notebook, record=record)
@@ -354,12 +367,13 @@ remain unspecified, and no continuation token is invented.
354
367
  Record creation keeps blank creation and structured import as separate capabilities:
355
368
 
356
369
  ```python
357
- from matelab import RecordImportItem, RecordImportTemplate, TemplateRef
370
+ from matelab import RecordImportItem, TemplateRef
358
371
 
359
372
  blank = await client.records.create_blank(notebook=notebook, title="Blank Record", record_uid="caller-generated-uid")
360
373
  imported = await client.records.import_dataset(
361
374
  notebook=notebook,
362
- template=RecordImportTemplate(template=TemplateRef(template_id=8), title="Example Template"),
375
+ template=TemplateRef(template_id=8),
376
+ template_title="Example Template",
363
377
  items=[RecordImportItem(record_uid="import-uid", title="Imported", data={"Notes": "value"})],
364
378
  )
365
379
  ```
@@ -397,7 +411,7 @@ Attachment bytes are streamed and must be consumed or closed explicitly:
397
411
  from matelab import ByteRange
398
412
 
399
413
  comments = await client.records.comments(notebook=notebook, record=record)
400
- attachment = comments.comments[0].attachments[0]
414
+ attachment = comments[0].attachments[0]
401
415
  async with await client.records.download_comment_attachment(attachment, byte_range=ByteRange.from_start(0)) as download:
402
416
  async for chunk in download:
403
417
  consume(chunk)
@@ -436,7 +450,7 @@ Staging mutations are never automatically retried.
436
450
  Literature identities distinguish the personal library, shared libraries and pending incoming copies:
437
451
 
438
452
  ```python
439
- from matelab import DoiMetadataSource, LiteratureMetadata
453
+ from matelab import LiteratureMetadata
440
454
 
441
455
  libraries = await client.literature.libraries()
442
456
  page = await client.literature.list(libraries.personal.ref)
@@ -444,7 +458,7 @@ detail = await client.literature.read(page.items[0].ref)
444
458
  schema = await client.literature.creation_schema()
445
459
 
446
460
  if schema.metadata_extraction_available:
447
- candidates = await client.literature.extract_metadata(DoiMetadataSource("10.0000/example"))
461
+ candidates = await client.literature.extract_metadata(doi="10.0000/example")
448
462
 
449
463
  await client.literature.create(LiteratureMetadata(title="Example import", doi="10.0000/example"), staged_pdf=staged)
450
464
  ```
@@ -467,10 +481,8 @@ stable `ByteRange` subset.
467
481
  The personal cloud-drive surface keeps root, folder, final file and temporary staging identities separate:
468
482
 
469
483
  ```python
470
- from matelab import CloudFolderMetadata
471
-
472
484
  listing = await client.cloud_drive.list()
473
- folder_result = await client.cloud_drive.create_folder(CloudFolderMetadata(name="Example data"))
485
+ folder_result = await client.cloud_drive.create_folder(name="Example data")
474
486
  matches = await client.cloud_drive.bind_staged_file(staged, target=folder_result.folder)
475
487
 
476
488
  if len(matches) == 1:
@@ -479,13 +491,13 @@ if len(matches) == 1:
479
491
  )
480
492
  ```
481
493
 
482
- `CloudDriveListing` contains a typed file page, complete folder tree, quota usage and personal-root permissions rather
494
+ `CloudDriveListing` contains a typed file page, complete folder snapshot, quota usage and personal-root permissions rather
483
495
  than flattening them into one ambiguous collection. Folder browse results retain their location; filename searches
484
496
  are explicitly root-wide and return `location=None` because the Provider omits each match's folder ID. Ordering has
485
497
  no stable ID tie-breaker (PCG-003, PVD-013).
486
498
 
487
499
  Folder create returns the Provider ID plus an optional observed folder, and all folder mutations read back the
488
- complete tree. Staged finalize accepts a completed `StagedFile`, then paginates the target folder and returns all
500
+ complete folder snapshot. Staged finalize accepts a completed `StagedFile`, then paginates the target folder and returns all
489
501
  exact filename/hash/size matches; zero, one, or multiple results preserve missing and ambiguous observations without
490
502
  another result model. This is useful evidence, not an uploader-ownership guarantee: the Provider binds by
491
503
  temporary row ID without checking its owner (PVD-031), and finalize atomicity/idempotency remain absent (PCG-012).
@@ -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.0a3`. `[project].version` in `pyproject.toml` is the sole SDK version source;
5
+ The current alpha is `0.1.0a4`. `[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.1.1` 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.0a3-py3-none-any.whl
34
+ python -m pip install dist/release/matelab_python_sdk-0.1.0a4-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
@@ -56,6 +56,24 @@ async with AsyncMatelab() as client:
56
56
  `AsyncMatelab()` uses `https://matelab.iphy.ac.cn/api` by default. Pass another Provider API root
57
57
  explicitly when needed, for example `AsyncMatelab("https://custom.example/api")`.
58
58
 
59
+ ### Error handling
60
+
61
+ Catch `MatelabError` for one application-level fallback, or a specific subclass when recovery differs:
62
+
63
+ | Error | Meaning and normal response |
64
+ |---|---|
65
+ | `MatelabUsageError` | The call cannot be represented safely; correct its arguments. |
66
+ | `MatelabAuthenticationError` | The Session is missing, expired, invalid, or rejected; refresh or authenticate again as appropriate. |
67
+ | `MatelabProviderError` | The Provider rejected a valid request with a business error; inspect `code` and do not assume a mutation was applied. |
68
+ | `MatelabTransportError` | The HTTP exchange failed; `status_code` is present for HTTP failures, and a mutation outcome may be unknown. |
69
+ | `MatelabProtocolError` | The Provider response does not match the pinned Contract; treat it as Provider drift or an SDK defect. |
70
+ | `MatelabVerificationError` | A mutation was acknowledged but its readback failed; the mutation may already have persisted. |
71
+
72
+ `code`, `status_code`, and the sanitized `payload` are diagnostics. The `payload` shape may contain private wire
73
+ field names and is not a stable Consumer interface; application control flow should use the exception class and
74
+ documented scalar attributes. Never automatically retry a mutation solely because it raised a transport or
75
+ verification error.
76
+
59
77
  ### Session ownership
60
78
 
61
79
  Each `AsyncMatelab` instance owns at most one current, process-local `Session`. The SDK injects its bearer
@@ -150,7 +168,7 @@ notebook selector used for upload:
150
168
  ```python
151
169
  import hashlib
152
170
 
153
- from matelab import RecordImportItem, RecordImportTemplate, TemplateRef
171
+ from matelab import RecordImportItem, TemplateRef
154
172
 
155
173
  content = b"measurement data"
156
174
  staged = await client.records.stage_attachment(
@@ -162,7 +180,8 @@ staged = await client.records.stage_attachment(
162
180
  )
163
181
  result = await client.records.import_dataset(
164
182
  notebook=notebook,
165
- template=RecordImportTemplate(template=TemplateRef(template_id=8), title="Example Template"),
183
+ template=TemplateRef(template_id=8),
184
+ template_title="Example Template",
166
185
  items=(
167
186
  RecordImportItem(
168
187
  record_uid="REC-IMPORT-001",
@@ -177,19 +196,17 @@ The same staged handle may instead be consumed by one safe update that adds a ne
177
196
  module:
178
197
 
179
198
  ```python
180
- from matelab import RecordFormAttachmentFieldAddition, RecordPatch
199
+ from matelab import RecordFormAttachmentFieldAddition
181
200
 
182
201
  # Alternative to the import above; do not run both with the same staged handle.
183
202
  result = await client.records.update(
184
203
  source,
185
- RecordPatch(
186
- attachment_changes=(
187
- RecordFormAttachmentFieldAddition(
188
- module="Attachments",
189
- name="Measurement",
190
- attachment=staged,
191
- ),
192
- )
204
+ attachment_changes=(
205
+ RecordFormAttachmentFieldAddition(
206
+ module="Attachments",
207
+ name="Measurement",
208
+ attachment=staged,
209
+ ),
193
210
  ),
194
211
  )
195
212
  ```
@@ -269,11 +286,9 @@ Provider returns members for an unstable first group and user search is unpaged,
269
286
  Notebook create/update and direct sharing keep write acknowledgement separate from what a readback can prove:
270
287
 
271
288
  ```python
272
- from matelab import NotebookMetadata, NotebookSharePermissionGrant
273
-
274
- await client.notebooks.create(NotebookMetadata(title="Example Notebook"))
289
+ await client.notebooks.create(title="Example Notebook")
275
290
  shares = await client.notebooks.share(notebook, [target.ref])
276
- updated = await client.notebooks.update_share(shares[0].ref, NotebookSharePermissionGrant(write=True, create=True))
291
+ updated = await client.notebooks.update_share(shares[0].ref, write=True, create=True)
277
292
  ```
278
293
 
279
294
  Create returns `None` because the Provider returns no identity. Owned updates return the notebook row observed by
@@ -288,7 +303,7 @@ relation identities:
288
303
  ```python
289
304
  templates = await client.templates.list()
290
305
  market = await client.templates.search_market("calibration", page=1, page_size=20)
291
- content = await client.templates.read(templates.owned[0].ref)
306
+ modules = await client.templates.read(templates.owned[0].ref)
292
307
  ```
293
308
 
294
309
  The market result reports the Provider `total_count`, the requested and effective page sizes, and a `has_more`
@@ -308,12 +323,10 @@ limitations are documented operation semantics rather than constant fields on ev
308
323
  Extended record reads stay behind the same `records` interface:
309
324
 
310
325
  ```python
311
- from matelab import RecordFieldExtraction, RecordLocator
326
+ from matelab import RecordLocator
312
327
 
313
328
  exported = await client.records.export([RecordLocator(notebook=notebook, record=record)])
314
- matches = await client.records.search(
315
- notebooks=[notebook], extractions=[RecordFieldExtraction(alias="notes", path=("Notes",))]
316
- )
329
+ matches = await client.records.search(notebooks=[notebook], extractions={"notes": ("Notes",)})
317
330
  page = await client.records.page(notebook)
318
331
  deleted = await client.records.recycle_bin(notebook)
319
332
  relations = await client.records.relations(notebook=notebook, record=record)
@@ -328,12 +341,13 @@ remain unspecified, and no continuation token is invented.
328
341
  Record creation keeps blank creation and structured import as separate capabilities:
329
342
 
330
343
  ```python
331
- from matelab import RecordImportItem, RecordImportTemplate, TemplateRef
344
+ from matelab import RecordImportItem, TemplateRef
332
345
 
333
346
  blank = await client.records.create_blank(notebook=notebook, title="Blank Record", record_uid="caller-generated-uid")
334
347
  imported = await client.records.import_dataset(
335
348
  notebook=notebook,
336
- template=RecordImportTemplate(template=TemplateRef(template_id=8), title="Example Template"),
349
+ template=TemplateRef(template_id=8),
350
+ template_title="Example Template",
337
351
  items=[RecordImportItem(record_uid="import-uid", title="Imported", data={"Notes": "value"})],
338
352
  )
339
353
  ```
@@ -371,7 +385,7 @@ Attachment bytes are streamed and must be consumed or closed explicitly:
371
385
  from matelab import ByteRange
372
386
 
373
387
  comments = await client.records.comments(notebook=notebook, record=record)
374
- attachment = comments.comments[0].attachments[0]
388
+ attachment = comments[0].attachments[0]
375
389
  async with await client.records.download_comment_attachment(attachment, byte_range=ByteRange.from_start(0)) as download:
376
390
  async for chunk in download:
377
391
  consume(chunk)
@@ -410,7 +424,7 @@ Staging mutations are never automatically retried.
410
424
  Literature identities distinguish the personal library, shared libraries and pending incoming copies:
411
425
 
412
426
  ```python
413
- from matelab import DoiMetadataSource, LiteratureMetadata
427
+ from matelab import LiteratureMetadata
414
428
 
415
429
  libraries = await client.literature.libraries()
416
430
  page = await client.literature.list(libraries.personal.ref)
@@ -418,7 +432,7 @@ detail = await client.literature.read(page.items[0].ref)
418
432
  schema = await client.literature.creation_schema()
419
433
 
420
434
  if schema.metadata_extraction_available:
421
- candidates = await client.literature.extract_metadata(DoiMetadataSource("10.0000/example"))
435
+ candidates = await client.literature.extract_metadata(doi="10.0000/example")
422
436
 
423
437
  await client.literature.create(LiteratureMetadata(title="Example import", doi="10.0000/example"), staged_pdf=staged)
424
438
  ```
@@ -441,10 +455,8 @@ stable `ByteRange` subset.
441
455
  The personal cloud-drive surface keeps root, folder, final file and temporary staging identities separate:
442
456
 
443
457
  ```python
444
- from matelab import CloudFolderMetadata
445
-
446
458
  listing = await client.cloud_drive.list()
447
- folder_result = await client.cloud_drive.create_folder(CloudFolderMetadata(name="Example data"))
459
+ folder_result = await client.cloud_drive.create_folder(name="Example data")
448
460
  matches = await client.cloud_drive.bind_staged_file(staged, target=folder_result.folder)
449
461
 
450
462
  if len(matches) == 1:
@@ -453,13 +465,13 @@ if len(matches) == 1:
453
465
  )
454
466
  ```
455
467
 
456
- `CloudDriveListing` contains a typed file page, complete folder tree, quota usage and personal-root permissions rather
468
+ `CloudDriveListing` contains a typed file page, complete folder snapshot, quota usage and personal-root permissions rather
457
469
  than flattening them into one ambiguous collection. Folder browse results retain their location; filename searches
458
470
  are explicitly root-wide and return `location=None` because the Provider omits each match's folder ID. Ordering has
459
471
  no stable ID tie-breaker (PCG-003, PVD-013).
460
472
 
461
473
  Folder create returns the Provider ID plus an optional observed folder, and all folder mutations read back the
462
- complete tree. Staged finalize accepts a completed `StagedFile`, then paginates the target folder and returns all
474
+ complete folder snapshot. Staged finalize accepts a completed `StagedFile`, then paginates the target folder and returns all
463
475
  exact filename/hash/size matches; zero, one, or multiple results preserve missing and ambiguous observations without
464
476
  another result model. This is useful evidence, not an uploader-ownership guarantee: the Provider binds by
465
477
  temporary row ID without checking its owner (PVD-031), and finalize atomicity/idempotency remain absent (PCG-012).
@@ -49,7 +49,7 @@ small domain-oriented public interface
49
49
  - Contract commit:`047746ad37d827a85f93d947942f1e5fab80d54c`
50
50
  - OpenAPI SHA-256:`1d437b071968d2df165c712092fba4a283832e0ac19bc791e0d5af82294d1cca`
51
51
  - Contract operation:71
52
- - SDK 当前版本:`0.1.0a3`
52
+ - SDK 当前版本:`0.1.0a4`
53
53
  - Python:3.11+
54
54
  - 生成器:`datamodel-code-generator 0.71.0`
55
55
 
@@ -73,7 +73,7 @@ goal 期间不得跟踪 `matelab-spec/main`,也不得自动升级 Contract Rel
73
73
  领域 interface;
74
74
  - 71 条 Contract operation 的 public-interface、wire adapter、测试与 coverage;
75
75
  - opt-in 的隔离 Provider smoke;
76
- - reproducible `0.1.0a3` build。
76
+ - reproducible `0.1.0a4` build。
77
77
 
78
78
  当前 operation coverage:71 implemented / 0 planned。每条 operation 的领域入口、稳定性和已知问题以
79
79
  [`operation-coverage.yaml`](operation-coverage.yaml) 为准。
@@ -908,3 +908,13 @@ reproducible build 基线。
908
908
  - records 与 literature 拆分为领域 package,wire model 继续只通过私有 namespace 使用;
909
909
  - Provider smoke 仅验证少量 SDK consumer flow,并与隔离测试服务的 `MATELAB_PROVIDER_*` 配置对齐;
910
910
  - Python 3.11 与 3.14 完整测试、生成检查、Ruff、Basedpyright、制品审计和安装后类型消费均作为发布门槛。
911
+
912
+ ## 28. `0.1.0a4` validation and package-boundary simplification
913
+
914
+ 本阶段保持 `matelab-spec v0.1.1` 和 71-operation 覆盖不变,完成全库精简后的发布收口:
915
+
916
+ - 公共符号从 150 个进一步收敛到 126 个,简单输入直接使用关键字参数或已有领域值;
917
+ - request wire validation 集中处理并只暴露脱敏的字段诊断,response schema mismatch 继续归类为 protocol error;
918
+ - 收紧生成模型无法直接表达的 Contract 约束、并发 Session 替换、multipart 重放边界和 streaming 清理;
919
+ - 所有业务能力统一进入 `matelab.domains`,根目录只保留 client、session、errors 和 transport 等核心模块;
920
+ - release audit 从 `pyproject.toml` 读取唯一版本与 metadata 权威,并拒绝不安全或重复的制品成员。
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "matelab-python-sdk"
7
- version = "0.1.0a3"
7
+ version = "0.1.0a4"
8
8
  description = "Reusable async Python client for the Matelab Integration Contract"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -52,7 +52,6 @@ lint.extend-select = ["I"]
52
52
  include = ["src", "tests", "scripts"]
53
53
  exclude = ["src/matelab/_generated"]
54
54
  pythonVersion = "3.11"
55
- failOnWarnings = false
56
55
 
57
56
  [tool.pytest.ini_options]
58
57
  markers = [
@@ -5,13 +5,11 @@ from typing import cast
5
5
 
6
6
  import matelab
7
7
 
8
- _EXPECTED_PUBLIC_SYMBOLS = 150
9
-
10
8
 
11
9
  def main() -> None:
12
10
  exports = matelab.__all__
13
- if len(exports) != _EXPECTED_PUBLIC_SYMBOLS or len(set(exports)) != _EXPECTED_PUBLIC_SYMBOLS:
14
- raise RuntimeError(f"Expected {_EXPECTED_PUBLIC_SYMBOLS} unique public symbols, found {len(exports)}.")
11
+ if not exports or len(exports) != len(set(exports)):
12
+ raise RuntimeError("The installed package must expose a non-empty set of unique public symbols.")
15
13
  for name in exports:
16
14
  value = cast(object, getattr(matelab, name))
17
15
  module = cast(object, getattr(value, "__module__", ""))
@@ -2,18 +2,24 @@
2
2
 
3
3
  import argparse
4
4
  import hashlib
5
+ import stat
5
6
  import tarfile
7
+ import tomllib
6
8
  from email import policy
7
9
  from email.parser import BytesParser
8
10
  from pathlib import Path, PurePosixPath
9
11
  from typing import cast
10
12
  from zipfile import ZipFile
11
13
 
12
- _VERSION = "0.1.0a3"
13
- _DIST_INFO = f"matelab_python_sdk-{_VERSION}.dist-info"
14
+ _ROOT = Path(__file__).resolve().parents[1]
15
+ _PROJECT = cast(dict[str, object], tomllib.loads((_ROOT / "pyproject.toml").read_text())["project"])
16
+ _PROJECT_NAME = cast(str, _PROJECT["name"])
17
+ _VERSION = cast(str, _PROJECT["version"])
18
+ _DIST_INFO = f"{_PROJECT_NAME.replace('-', '_')}-{_VERSION}.dist-info"
14
19
  _EXPECTED_REQUIRES_DIST = {"httpx2<3,>=2.9.1", "pydantic<3,>=2.13.4"}
15
- _EXPECTED_LICENSE_FILES = {"LICENSE", "NOTICE"}
16
- _EXPECTED_AUTHOR_EMAIL = "朱天念 <zhutiannian@gmail.com>"
20
+ _EXPECTED_LICENSE_FILES = set(cast(list[str], _PROJECT["license-files"]))
21
+ _AUTHOR = cast(list[dict[str, str]], _PROJECT["authors"])[0]
22
+ _EXPECTED_AUTHOR_EMAIL = f"{_AUTHOR['name']} <{_AUTHOR['email']}>"
17
23
  _ALLOWED_ENV_EXAMPLE = ".env.example"
18
24
  _FORBIDDEN_PARTS = {
19
25
  ".env",
@@ -43,12 +49,21 @@ def _sha256(path: Path) -> str:
43
49
 
44
50
 
45
51
  def _check_member_names(artifact: Path, names: list[str]) -> None:
52
+ if len(names) != len(set(names)):
53
+ raise ValueError(f"{artifact.name} contains duplicate archive member names")
46
54
  offenders: list[str] = []
47
55
  for name in names:
48
- parts = PurePosixPath(name).parts
56
+ path = PurePosixPath(name)
57
+ parts = path.parts
49
58
  filename = parts[-1] if parts else ""
50
59
  if (
51
- any(
60
+ not name
61
+ or "\\" in name
62
+ or path.is_absolute()
63
+ or any(part in {".", ".."} for part in parts)
64
+ or name not in {path.as_posix(), f"{path.as_posix()}/"}
65
+ or not parts
66
+ or any(
52
67
  part in _FORBIDDEN_PARTS or (part.startswith(".env.") and part != _ALLOWED_ENV_EXAMPLE)
53
68
  for part in parts
54
69
  )
@@ -65,10 +80,32 @@ def _check_payload(artifact: Path, name: str, payload: bytes) -> None:
65
80
  raise ValueError(f"{artifact.name} contains a private key marker in {name}")
66
81
 
67
82
 
83
+ def _check_metadata(artifact: Path, metadata_bytes: bytes) -> None:
84
+ metadata = BytesParser(policy=policy.default).parsebytes(metadata_bytes)
85
+ requires_dist = set(cast(list[str], metadata.get_all("Requires-Dist", [])))
86
+ if metadata["Name"] != _PROJECT_NAME or metadata["Version"] != _VERSION:
87
+ raise ValueError(f"{artifact.name} contains unexpected name/version metadata")
88
+ if metadata["Requires-Python"] != _PROJECT["requires-python"]:
89
+ raise ValueError(f"{artifact.name} contains unexpected Requires-Python metadata")
90
+ if requires_dist != _EXPECTED_REQUIRES_DIST:
91
+ raise ValueError(f"{artifact.name} contains unexpected Requires-Dist metadata: {sorted(requires_dist)}")
92
+ if metadata["License-Expression"] != _PROJECT["license"]:
93
+ raise ValueError(f"{artifact.name} contains unexpected License-Expression metadata")
94
+ if set(metadata.get_all("License-File", [])) != _EXPECTED_LICENSE_FILES:
95
+ raise ValueError(f"{artifact.name} contains unexpected License-File metadata")
96
+ if metadata["Author-email"] != _EXPECTED_AUTHOR_EMAIL:
97
+ raise ValueError(f"{artifact.name} contains unexpected Author-email metadata")
98
+
99
+
68
100
  def _audit_wheel(path: Path) -> None:
69
101
  with ZipFile(path) as wheel:
70
- names = wheel.namelist()
102
+ members = wheel.infolist()
103
+ names = [member.filename for member in members]
71
104
  _check_member_names(path, names)
105
+ for member in members:
106
+ member_type = stat.S_IFMT(member.external_attr >> 16)
107
+ if member_type not in {0, stat.S_IFREG, stat.S_IFDIR}:
108
+ raise ValueError(f"{path.name} contains unsupported archive member type: {member.filename}")
72
109
  required = {
73
110
  "matelab/__init__.py",
74
111
  "matelab/py.typed",
@@ -89,20 +126,7 @@ def _audit_wheel(path: Path) -> None:
89
126
  _check_payload(path, name, wheel.read(name))
90
127
  metadata_bytes = wheel.read(f"{_DIST_INFO}/METADATA")
91
128
 
92
- metadata = BytesParser(policy=policy.default).parsebytes(metadata_bytes)
93
- requires_dist = set(cast(list[str], metadata.get_all("Requires-Dist", [])))
94
- if metadata["Name"] != "matelab-python-sdk" or metadata["Version"] != _VERSION:
95
- raise ValueError(f"{path.name} contains unexpected name/version metadata")
96
- if metadata["Requires-Python"] != ">=3.11":
97
- raise ValueError(f"{path.name} contains unexpected Requires-Python metadata")
98
- if requires_dist != _EXPECTED_REQUIRES_DIST:
99
- raise ValueError(f"{path.name} contains unexpected Requires-Dist metadata: {sorted(requires_dist)}")
100
- if metadata["License-Expression"] != "Apache-2.0":
101
- raise ValueError(f"{path.name} contains unexpected License-Expression metadata")
102
- if set(metadata.get_all("License-File", [])) != _EXPECTED_LICENSE_FILES:
103
- raise ValueError(f"{path.name} contains unexpected License-File metadata")
104
- if metadata["Author-email"] != _EXPECTED_AUTHOR_EMAIL:
105
- raise ValueError(f"{path.name} contains unexpected Author-email metadata")
129
+ _check_metadata(path, metadata_bytes)
106
130
 
107
131
 
108
132
  def _audit_sdist(path: Path) -> None:
@@ -110,14 +134,21 @@ def _audit_sdist(path: Path) -> None:
110
134
  members = source.getmembers()
111
135
  names = [member.name for member in members]
112
136
  _check_member_names(path, names)
137
+ for member in members:
138
+ if not (member.isfile() or member.isdir()):
139
+ raise ValueError(f"{path.name} contains unsupported archive member type: {member.name}")
113
140
  roots = {PurePosixPath(name).parts[0] for name in names}
114
141
  if len(roots) != 1:
115
142
  raise ValueError(f"{path.name} must contain exactly one top-level directory")
116
143
  root = roots.pop()
144
+ expected_root = f"{_PROJECT_NAME.replace('-', '_')}-{_VERSION}"
145
+ if root != expected_root or path.name != f"{root}.tar.gz":
146
+ raise ValueError(f"{path.name} contains an unexpected source-distribution root")
117
147
  required = {
118
148
  f"{root}/CHANGELOG.md",
119
149
  f"{root}/LICENSE",
120
150
  f"{root}/NOTICE",
151
+ f"{root}/PKG-INFO",
121
152
  f"{root}/README.md",
122
153
  f"{root}/pyproject.toml",
123
154
  f"{root}/src/matelab/__init__.py",
@@ -126,12 +157,20 @@ def _audit_sdist(path: Path) -> None:
126
157
  missing = sorted(required - set(names))
127
158
  if missing:
128
159
  raise ValueError(f"{path.name} is missing required files: {missing}")
160
+ metadata_bytes: bytes | None = None
129
161
  for member in members:
130
162
  if member.isfile():
131
163
  extracted = source.extractfile(member)
132
164
  if extracted is None:
133
165
  raise ValueError(f"{path.name} could not read {member.name}")
134
- _check_payload(path, member.name, extracted.read())
166
+ payload = extracted.read()
167
+ _check_payload(path, member.name, payload)
168
+ if member.name == f"{root}/PKG-INFO":
169
+ metadata_bytes = payload
170
+ if metadata_bytes is None:
171
+ raise ValueError(f"{path.name} could not read PKG-INFO")
172
+
173
+ _check_metadata(path, metadata_bytes)
135
174
 
136
175
 
137
176
  def audit_artifact(path: Path) -> str: