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.
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/CHANGELOG.md +11 -0
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/PKG-INFO +45 -33
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/README.md +44 -32
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/docs/roadmap.md +12 -2
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/pyproject.toml +1 -2
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/scripts/check_installed_package.py +2 -4
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/scripts/check_release.py +61 -22
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/scripts/generate_models.py +88 -3
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/src/matelab/__init__.py +21 -68
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/src/matelab/_generated/models.py +830 -757
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/src/matelab/_transport.py +89 -70
- matelab_python_sdk-0.1.0a4/src/matelab/_wire_validation.py +214 -0
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/src/matelab/client.py +32 -44
- matelab_python_sdk-0.1.0a4/src/matelab/domains/__init__.py +1 -0
- {matelab_python_sdk-0.1.0a3/src/matelab → matelab_python_sdk-0.1.0a4/src/matelab/domains}/cloud_drive.py +62 -116
- {matelab_python_sdk-0.1.0a3/src/matelab → matelab_python_sdk-0.1.0a4/src/matelab/domains}/groups.py +6 -2
- matelab_python_sdk-0.1.0a3/src/matelab/literature/__init__.py → matelab_python_sdk-0.1.0a4/src/matelab/domains/literature.py +95 -126
- {matelab_python_sdk-0.1.0a3/src/matelab → matelab_python_sdk-0.1.0a4/src/matelab/domains}/notebooks.py +84 -68
- matelab_python_sdk-0.1.0a3/src/matelab/records/__init__.py → matelab_python_sdk-0.1.0a4/src/matelab/domains/records.py +302 -371
- {matelab_python_sdk-0.1.0a3/src/matelab → matelab_python_sdk-0.1.0a4/src/matelab/domains}/templates.py +76 -100
- {matelab_python_sdk-0.1.0a3/src/matelab → matelab_python_sdk-0.1.0a4/src/matelab/domains}/uploads.py +5 -14
- {matelab_python_sdk-0.1.0a3/src/matelab → matelab_python_sdk-0.1.0a4/src/matelab/domains}/users.py +9 -23
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/src/matelab/streaming.py +23 -5
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_client.py +51 -1
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_cloud_drive.py +6 -9
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_comments_and_downloads.py +3 -3
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_extended_records.py +9 -13
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_generation.py +106 -0
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_groups.py +22 -0
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_notebook_lifecycle.py +31 -31
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_operation_coverage.py +4 -1
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_record_lifecycle.py +23 -12
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_record_mutations.py +107 -60
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_release.py +89 -12
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_streaming.py +74 -4
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_template_lifecycle.py +6 -6
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_templates.py +12 -12
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_transport.py +112 -66
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_uploads_and_literature.py +45 -21
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_users.py +10 -11
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/typing/consumer.py +7 -10
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/uv.lock +1 -1
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/.env.example +0 -0
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/.github/workflows/release.yml +0 -0
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/.gitignore +0 -0
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/AGENTS.md +0 -0
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/LICENSE +0 -0
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/NOTICE +0 -0
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/contracts/matelab-integration-v1.lock.json +0 -0
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/contracts/matelab-integration-v1.openapi.yaml +0 -0
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/docs/operation-coverage.yaml +0 -0
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/src/matelab/_generated/__init__.py +0 -0
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/src/matelab/errors.py +0 -0
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/src/matelab/py.typed +0 -0
- /matelab_python_sdk-0.1.0a3/src/matelab/models.py → /matelab_python_sdk-0.1.0a4/src/matelab/session.py +0 -0
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/conftest.py +0 -0
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/provider/test_provider_smoke.py +0 -0
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_consumer_smoke.py +0 -0
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_notebooks.py +0 -0
- {matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/tests/test_records.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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,
|
|
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=
|
|
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
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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,
|
|
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=
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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,
|
|
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=
|
|
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
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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,
|
|
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=
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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 = [
|
{matelab_python_sdk-0.1.0a3 → matelab_python_sdk-0.1.0a4}/scripts/check_installed_package.py
RENAMED
|
@@ -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) !=
|
|
14
|
-
raise RuntimeError(
|
|
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
|
-
|
|
13
|
-
|
|
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 =
|
|
16
|
-
|
|
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
|
-
|
|
56
|
+
path = PurePosixPath(name)
|
|
57
|
+
parts = path.parts
|
|
49
58
|
filename = parts[-1] if parts else ""
|
|
50
59
|
if (
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|