matelab-python-sdk 0.1.0a9__tar.gz → 0.1.0a11__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.0a9 → matelab_python_sdk-0.1.0a11}/CHANGELOG.md +16 -0
- matelab_python_sdk-0.1.0a9/README.md → matelab_python_sdk-0.1.0a11/PKG-INFO +50 -4
- matelab_python_sdk-0.1.0a9/PKG-INFO → matelab_python_sdk-0.1.0a11/README.md +22 -31
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/pyproject.toml +2 -1
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/scripts/check_release.py +6 -1
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/__init__.py +2 -0
- matelab_python_sdk-0.1.0a11/src/matelab/_attachment_content.py +71 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/_transport.py +54 -8
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/records.py +97 -99
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/errors.py +22 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/session.py +3 -5
- matelab_python_sdk-0.1.0a11/tests/test_async_attachment_upload.py +378 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_client.py +7 -2
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_errors.py +23 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_release.py +7 -1
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_streaming.py +7 -3
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_transport.py +99 -1
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/typing/consumer.py +11 -2
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/uv.lock +3 -1
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/.env.example +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/.github/workflows/release.yml +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/.gitignore +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/AGENTS.md +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/LICENSE +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/NOTICE +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/contracts/matelab-integration-v1.lock.json +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/contracts/matelab-integration-v1.openapi.yaml +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/docs/operation-coverage.yaml +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/scripts/check_installed_package.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/scripts/generate_models.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/_generated/__init__.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/_generated/models.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/_wire_validation.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/client.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/__init__.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/cloud_drive.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/groups.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/literature.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/notebooks.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/templates.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/uploads.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/users.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/py.typed +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/streaming.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/conftest.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/provider/test_provider_smoke.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_cloud_drive.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_comments_and_downloads.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_consumer_smoke.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_extended_records.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_generation.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_groups.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_notebook_lifecycle.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_notebooks.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_operation_coverage.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_record_lifecycle.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_record_mutations.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_records.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_template_lifecycle.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_templates.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_uploads_and_literature.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_users.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/typing/pyproject.toml +0 -0
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0a11 - 2026-08-05
|
|
4
|
+
|
|
5
|
+
- Adds `AsyncIterable[bytes]` support to the three complete record-attachment upload interfaces without adding new
|
|
6
|
+
methods. Async content is spooled, size/checksum validated, rewound, and cleaned up without blocking the event loop;
|
|
7
|
+
`sha256` may now be omitted and computed by the SDK.
|
|
8
|
+
- Adds structured `MatelabAttachmentValidationError` reasons and expected/actual values so integrations can distinguish
|
|
9
|
+
size and checksum failures without parsing exception text. Validation failures occur before Provider staging, while
|
|
10
|
+
source exceptions propagate unchanged.
|
|
11
|
+
|
|
12
|
+
## 0.1.0a10 - 2026-08-05
|
|
13
|
+
|
|
14
|
+
- Keeps synchronous multipart file length probes and chunk reads off the event-loop thread while preserving streamed
|
|
15
|
+
uploads, correct content length, the bytes fast path, and existing non-replay retry restrictions.
|
|
16
|
+
- Replaces implicit-override suppressions with explicit `@override` declarations, simplifies inferred model attributes,
|
|
17
|
+
and declares the Python 3.11 `typing-extensions` runtime dependency directly.
|
|
18
|
+
|
|
3
19
|
## 0.1.0a9 - 2026-08-04
|
|
4
20
|
|
|
5
21
|
- Breaking: removes `MatelabProviderErrorKind` and `MatelabProviderError.kind`; Provider wire codes now map directly
|
|
@@ -1,8 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: matelab-python-sdk
|
|
3
|
+
Version: 0.1.0a11
|
|
4
|
+
Summary: Reusable async Python client for the Matelab Integration Contract
|
|
5
|
+
Author-email: 朱天念 <zhutiannian@gmail.com>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
License-File: NOTICE
|
|
9
|
+
Keywords: asyncio,electronic-lab-notebook,eln,matelab,sdk
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: httpx2<3,>=2.9.1
|
|
24
|
+
Requires-Dist: jsonschema<5,>=4.26
|
|
25
|
+
Requires-Dist: pydantic<3,>=2.13.4
|
|
26
|
+
Requires-Dist: typing-extensions<5,>=4.14.1
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
1
29
|
# matelab-python-sdk
|
|
2
30
|
|
|
3
31
|
Reusable async Python client for the Matelab Integration Contract.
|
|
4
32
|
|
|
5
|
-
The current alpha is `0.1.
|
|
33
|
+
The current alpha is `0.1.0a11`. `[project].version` in `pyproject.toml` is the sole SDK version source;
|
|
6
34
|
`uv.lock` only mirrors that source.
|
|
7
35
|
|
|
8
36
|
The SDK is pinned to the immutable `matelab-spec v0.3.0` Contract Release. The sole release pin is
|
|
@@ -31,7 +59,7 @@ To test the same artifact a downstream Consumer will install, build and install
|
|
|
31
59
|
|
|
32
60
|
```bash
|
|
33
61
|
uv build --no-build-isolation --out-dir dist/release
|
|
34
|
-
python -m pip install dist/release/matelab_python_sdk-0.1.
|
|
62
|
+
python -m pip install dist/release/matelab_python_sdk-0.1.0a11-py3-none-any.whl
|
|
35
63
|
```
|
|
36
64
|
|
|
37
65
|
Do not infer Provider compatibility from the SDK version alone. A release is also bound to the Contract
|
|
@@ -66,6 +94,7 @@ Provider details:
|
|
|
66
94
|
|---|---|---:|---|
|
|
67
95
|
| `MatelabAuthenticationError` | `AUTHENTICATION` | No | The Session or credentials cannot authenticate; obtain valid authentication before making a new call. |
|
|
68
96
|
| `MatelabUsageError` | `VALIDATION` | No | The call cannot be represented safely; correct its arguments. |
|
|
97
|
+
| `MatelabAttachmentValidationError` | `VALIDATION` | No | Attachment content differs from its declared size or SHA-256; inspect `reason`, `expected`, and `actual`. |
|
|
69
98
|
| `MatelabProviderError` | `BUSINESS`, `VALIDATION`, or `UPSTREAM` | No | The Provider rejected the request; route directly by `category`. |
|
|
70
99
|
| `MatelabTransportError` | `UPSTREAM` | No | The HTTP exchange failed; `status_code` is present for HTTP failures, and a mutation outcome may be unknown. |
|
|
71
100
|
| `MatelabProtocolError` | `UPSTREAM` | No | The response violates the pinned Contract; treat it as Provider drift or an SDK defect. |
|
|
@@ -206,6 +235,22 @@ result = await client.records.import_dataset(
|
|
|
206
235
|
)
|
|
207
236
|
```
|
|
208
237
|
|
|
238
|
+
`content` accepts `bytes`, a synchronous `IO[bytes]`, or an `AsyncIterable[bytes]`. `size` is always required and must
|
|
239
|
+
be exact. `sha256` is optional; when omitted, the SDK computes it while consuming the content. Async content is fully
|
|
240
|
+
consumed into a `SpooledTemporaryFile`, size-checked, checksum-checked when a checksum was supplied, rewound, and only
|
|
241
|
+
then sent to the Provider. An over-size stream stops at the first chunk that exceeds the declared size. Temporary-file
|
|
242
|
+
write, seek, read, and close operations run outside the event-loop thread, including after the spool rolls to disk.
|
|
243
|
+
|
|
244
|
+
Local content mismatches raise `MatelabAttachmentValidationError`, a `MatelabUsageError` subtype. Its `reason` is
|
|
245
|
+
`"size_mismatch"` or `"checksum_mismatch"`, while `expected` and `actual` carry the corresponding integer size or
|
|
246
|
+
lowercase SHA-256 value. These failures happen before any Provider request. An exception raised by the async source is
|
|
247
|
+
propagated unchanged after the SDK closes its temporary file. The SDK does not impose an upload-size policy; callers
|
|
248
|
+
remain responsible for limits such as an HTTP endpoint's maximum accepted body size.
|
|
249
|
+
|
|
250
|
+
The same content interface is available on `records.upload_attachment()` and
|
|
251
|
+
`records.upload_comment_attachment()`. `uploads.stage()` remains a separate resumable-fragment interface and does not
|
|
252
|
+
accept async content because its per-fragment offset and completion semantics are different.
|
|
253
|
+
|
|
209
254
|
The same staged handle may instead be consumed by one safe update finalizer. For example, add a new file field to an
|
|
210
255
|
existing form module:
|
|
211
256
|
|
|
@@ -601,8 +646,9 @@ object inheritance and preserves constraints the model generator cannot express
|
|
|
601
646
|
writing the checked-in models. `WireModel` applies that metadata with the standard
|
|
602
647
|
`jsonschema` Draft 2020-12 validator; the SDK does not maintain a second hand-written schema interpreter. The current lock resolves
|
|
603
648
|
`datamodel-code-generator 0.71.0` and
|
|
604
|
-
`hatchling 1.31.0`. Published metadata requires `httpx2>=2.9.1,<3`, `jsonschema>=4.26,<5`,
|
|
605
|
-
`pydantic>=2.13.4,<3
|
|
649
|
+
`hatchling 1.31.0`. Published metadata requires `httpx2>=2.9.1,<3`, `jsonschema>=4.26,<5`,
|
|
650
|
+
`pydantic>=2.13.4,<3`, and `typing-extensions>=4.14.1,<5`; the build backend requires
|
|
651
|
+
`hatchling>=1.27,<2`. These lower bounds are verified
|
|
606
652
|
against the complete test suite on the supported Python boundary versions rather than inferred from
|
|
607
653
|
`uv.lock`. The exact toolchain remains locked for development and release builds. Basedpyright and its
|
|
608
654
|
Node wheel retain the compatible exact pair `basedpyright==1.39.9` and
|
|
@@ -1,35 +1,8 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: matelab-python-sdk
|
|
3
|
-
Version: 0.1.0a9
|
|
4
|
-
Summary: Reusable async Python client for the Matelab Integration Contract
|
|
5
|
-
Author-email: 朱天念 <zhutiannian@gmail.com>
|
|
6
|
-
License-Expression: Apache-2.0
|
|
7
|
-
License-File: LICENSE
|
|
8
|
-
License-File: NOTICE
|
|
9
|
-
Keywords: asyncio,electronic-lab-notebook,eln,matelab,sdk
|
|
10
|
-
Classifier: Development Status :: 3 - Alpha
|
|
11
|
-
Classifier: Framework :: AsyncIO
|
|
12
|
-
Classifier: Operating System :: OS Independent
|
|
13
|
-
Classifier: Programming Language :: Python :: 3
|
|
14
|
-
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
-
Classifier: Topic :: Scientific/Engineering
|
|
20
|
-
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
-
Classifier: Typing :: Typed
|
|
22
|
-
Requires-Python: >=3.11
|
|
23
|
-
Requires-Dist: httpx2<3,>=2.9.1
|
|
24
|
-
Requires-Dist: jsonschema<5,>=4.26
|
|
25
|
-
Requires-Dist: pydantic<3,>=2.13.4
|
|
26
|
-
Description-Content-Type: text/markdown
|
|
27
|
-
|
|
28
1
|
# matelab-python-sdk
|
|
29
2
|
|
|
30
3
|
Reusable async Python client for the Matelab Integration Contract.
|
|
31
4
|
|
|
32
|
-
The current alpha is `0.1.
|
|
5
|
+
The current alpha is `0.1.0a11`. `[project].version` in `pyproject.toml` is the sole SDK version source;
|
|
33
6
|
`uv.lock` only mirrors that source.
|
|
34
7
|
|
|
35
8
|
The SDK is pinned to the immutable `matelab-spec v0.3.0` Contract Release. The sole release pin is
|
|
@@ -58,7 +31,7 @@ To test the same artifact a downstream Consumer will install, build and install
|
|
|
58
31
|
|
|
59
32
|
```bash
|
|
60
33
|
uv build --no-build-isolation --out-dir dist/release
|
|
61
|
-
python -m pip install dist/release/matelab_python_sdk-0.1.
|
|
34
|
+
python -m pip install dist/release/matelab_python_sdk-0.1.0a11-py3-none-any.whl
|
|
62
35
|
```
|
|
63
36
|
|
|
64
37
|
Do not infer Provider compatibility from the SDK version alone. A release is also bound to the Contract
|
|
@@ -93,6 +66,7 @@ Provider details:
|
|
|
93
66
|
|---|---|---:|---|
|
|
94
67
|
| `MatelabAuthenticationError` | `AUTHENTICATION` | No | The Session or credentials cannot authenticate; obtain valid authentication before making a new call. |
|
|
95
68
|
| `MatelabUsageError` | `VALIDATION` | No | The call cannot be represented safely; correct its arguments. |
|
|
69
|
+
| `MatelabAttachmentValidationError` | `VALIDATION` | No | Attachment content differs from its declared size or SHA-256; inspect `reason`, `expected`, and `actual`. |
|
|
96
70
|
| `MatelabProviderError` | `BUSINESS`, `VALIDATION`, or `UPSTREAM` | No | The Provider rejected the request; route directly by `category`. |
|
|
97
71
|
| `MatelabTransportError` | `UPSTREAM` | No | The HTTP exchange failed; `status_code` is present for HTTP failures, and a mutation outcome may be unknown. |
|
|
98
72
|
| `MatelabProtocolError` | `UPSTREAM` | No | The response violates the pinned Contract; treat it as Provider drift or an SDK defect. |
|
|
@@ -233,6 +207,22 @@ result = await client.records.import_dataset(
|
|
|
233
207
|
)
|
|
234
208
|
```
|
|
235
209
|
|
|
210
|
+
`content` accepts `bytes`, a synchronous `IO[bytes]`, or an `AsyncIterable[bytes]`. `size` is always required and must
|
|
211
|
+
be exact. `sha256` is optional; when omitted, the SDK computes it while consuming the content. Async content is fully
|
|
212
|
+
consumed into a `SpooledTemporaryFile`, size-checked, checksum-checked when a checksum was supplied, rewound, and only
|
|
213
|
+
then sent to the Provider. An over-size stream stops at the first chunk that exceeds the declared size. Temporary-file
|
|
214
|
+
write, seek, read, and close operations run outside the event-loop thread, including after the spool rolls to disk.
|
|
215
|
+
|
|
216
|
+
Local content mismatches raise `MatelabAttachmentValidationError`, a `MatelabUsageError` subtype. Its `reason` is
|
|
217
|
+
`"size_mismatch"` or `"checksum_mismatch"`, while `expected` and `actual` carry the corresponding integer size or
|
|
218
|
+
lowercase SHA-256 value. These failures happen before any Provider request. An exception raised by the async source is
|
|
219
|
+
propagated unchanged after the SDK closes its temporary file. The SDK does not impose an upload-size policy; callers
|
|
220
|
+
remain responsible for limits such as an HTTP endpoint's maximum accepted body size.
|
|
221
|
+
|
|
222
|
+
The same content interface is available on `records.upload_attachment()` and
|
|
223
|
+
`records.upload_comment_attachment()`. `uploads.stage()` remains a separate resumable-fragment interface and does not
|
|
224
|
+
accept async content because its per-fragment offset and completion semantics are different.
|
|
225
|
+
|
|
236
226
|
The same staged handle may instead be consumed by one safe update finalizer. For example, add a new file field to an
|
|
237
227
|
existing form module:
|
|
238
228
|
|
|
@@ -628,8 +618,9 @@ object inheritance and preserves constraints the model generator cannot express
|
|
|
628
618
|
writing the checked-in models. `WireModel` applies that metadata with the standard
|
|
629
619
|
`jsonschema` Draft 2020-12 validator; the SDK does not maintain a second hand-written schema interpreter. The current lock resolves
|
|
630
620
|
`datamodel-code-generator 0.71.0` and
|
|
631
|
-
`hatchling 1.31.0`. Published metadata requires `httpx2>=2.9.1,<3`, `jsonschema>=4.26,<5`,
|
|
632
|
-
`pydantic>=2.13.4,<3
|
|
621
|
+
`hatchling 1.31.0`. Published metadata requires `httpx2>=2.9.1,<3`, `jsonschema>=4.26,<5`,
|
|
622
|
+
`pydantic>=2.13.4,<3`, and `typing-extensions>=4.14.1,<5`; the build backend requires
|
|
623
|
+
`hatchling>=1.27,<2`. These lower bounds are verified
|
|
633
624
|
against the complete test suite on the supported Python boundary versions rather than inferred from
|
|
634
625
|
`uv.lock`. The exact toolchain remains locked for development and release builds. Basedpyright and its
|
|
635
626
|
Node wheel retain the compatible exact pair `basedpyright==1.39.9` and
|
|
@@ -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.0a11"
|
|
8
8
|
description = "Reusable async Python client for the Matelab Integration Contract"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -30,6 +30,7 @@ dependencies = [
|
|
|
30
30
|
"httpx2>=2.9.1,<3",
|
|
31
31
|
"jsonschema>=4.26,<5",
|
|
32
32
|
"pydantic>=2.13.4,<3",
|
|
33
|
+
"typing-extensions>=4.14.1,<5",
|
|
33
34
|
]
|
|
34
35
|
|
|
35
36
|
[dependency-groups]
|
|
@@ -16,7 +16,12 @@ _PROJECT = cast(dict[str, object], tomllib.loads((_ROOT / "pyproject.toml").read
|
|
|
16
16
|
_PROJECT_NAME = cast(str, _PROJECT["name"])
|
|
17
17
|
_VERSION = cast(str, _PROJECT["version"])
|
|
18
18
|
_DIST_INFO = f"{_PROJECT_NAME.replace('-', '_')}-{_VERSION}.dist-info"
|
|
19
|
-
_EXPECTED_REQUIRES_DIST = {
|
|
19
|
+
_EXPECTED_REQUIRES_DIST = {
|
|
20
|
+
"httpx2<3,>=2.9.1",
|
|
21
|
+
"jsonschema<5,>=4.26",
|
|
22
|
+
"pydantic<3,>=2.13.4",
|
|
23
|
+
"typing-extensions<5,>=4.14.1",
|
|
24
|
+
}
|
|
20
25
|
_EXPECTED_LICENSE_FILES = set(cast(list[str], _PROJECT["license-files"]))
|
|
21
26
|
_AUTHOR = cast(list[dict[str, str]], _PROJECT["authors"])[0]
|
|
22
27
|
_EXPECTED_AUTHOR_EMAIL = f"{_AUTHOR['name']} <{_AUTHOR['email']}>"
|
|
@@ -113,6 +113,7 @@ from matelab.domains.templates import (
|
|
|
113
113
|
from matelab.domains.uploads import StagedFile, StagedFileFragment, StagedUploadSession, UploadBindingRef
|
|
114
114
|
from matelab.domains.users import UserRef, UserSummary
|
|
115
115
|
from matelab.errors import (
|
|
116
|
+
MatelabAttachmentValidationError,
|
|
116
117
|
MatelabAuthenticationError,
|
|
117
118
|
MatelabError,
|
|
118
119
|
MatelabErrorCategory,
|
|
@@ -163,6 +164,7 @@ __all__ = [
|
|
|
163
164
|
"LiteraturePdf",
|
|
164
165
|
"LiteratureSource",
|
|
165
166
|
"LiteratureStoredMetadata",
|
|
167
|
+
"MatelabAttachmentValidationError",
|
|
166
168
|
"MatelabAuthenticationError",
|
|
167
169
|
"MatelabError",
|
|
168
170
|
"MatelabErrorCategory",
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import hashlib
|
|
3
|
+
from collections.abc import AsyncGenerator, AsyncIterable, AsyncIterator
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from tempfile import SpooledTemporaryFile
|
|
6
|
+
from typing import IO
|
|
7
|
+
|
|
8
|
+
from matelab.errors import MatelabAttachmentValidationError
|
|
9
|
+
|
|
10
|
+
_READ_SIZE = 64 * 1024
|
|
11
|
+
_SPOOL_MEMORY_LIMIT = 1024 * 1024
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def _chunks(content: IO[bytes] | AsyncIterable[bytes]) -> AsyncIterator[bytes]:
|
|
15
|
+
if isinstance(content, AsyncIterable):
|
|
16
|
+
async for chunk in content:
|
|
17
|
+
yield chunk
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
while chunk := await asyncio.to_thread(content.read, _READ_SIZE):
|
|
21
|
+
yield chunk
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _validate_content(*, expected_size: int, actual_size: int, expected_sha256: str | None, actual_sha256: str) -> None:
|
|
25
|
+
if actual_size != expected_size:
|
|
26
|
+
raise MatelabAttachmentValidationError("size_mismatch", expected=expected_size, actual=actual_size)
|
|
27
|
+
if expected_sha256 is not None and actual_sha256 != expected_sha256:
|
|
28
|
+
raise MatelabAttachmentValidationError("checksum_mismatch", expected=expected_sha256, actual=actual_sha256)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@asynccontextmanager
|
|
32
|
+
async def validated_attachment_content(
|
|
33
|
+
content: IO[bytes] | bytes | AsyncIterable[bytes], *, size: int, sha256: str | None
|
|
34
|
+
) -> AsyncGenerator[tuple[IO[bytes] | bytes, int, str]]:
|
|
35
|
+
if isinstance(content, bytes):
|
|
36
|
+
actual_sha256 = hashlib.sha256(content).hexdigest()
|
|
37
|
+
_validate_content(
|
|
38
|
+
expected_size=size,
|
|
39
|
+
actual_size=len(content),
|
|
40
|
+
expected_sha256=sha256,
|
|
41
|
+
actual_sha256=actual_sha256,
|
|
42
|
+
)
|
|
43
|
+
yield content, len(content), actual_sha256
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
if not isinstance(content, AsyncIterable) and sha256 is not None:
|
|
47
|
+
yield content, size, sha256
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
cached = await asyncio.to_thread(SpooledTemporaryFile, max_size=_SPOOL_MEMORY_LIMIT, mode="w+b")
|
|
51
|
+
digest = hashlib.sha256()
|
|
52
|
+
actual_size = 0
|
|
53
|
+
try:
|
|
54
|
+
async for chunk in _chunks(content):
|
|
55
|
+
actual_size += len(chunk)
|
|
56
|
+
if actual_size > size:
|
|
57
|
+
raise MatelabAttachmentValidationError("size_mismatch", expected=size, actual=actual_size)
|
|
58
|
+
digest.update(chunk)
|
|
59
|
+
_ = await asyncio.to_thread(cached.write, chunk)
|
|
60
|
+
|
|
61
|
+
actual_sha256 = digest.hexdigest()
|
|
62
|
+
_validate_content(
|
|
63
|
+
expected_size=size,
|
|
64
|
+
actual_size=actual_size,
|
|
65
|
+
expected_sha256=sha256,
|
|
66
|
+
actual_sha256=actual_sha256,
|
|
67
|
+
)
|
|
68
|
+
_ = await asyncio.to_thread(cached.seek, 0)
|
|
69
|
+
yield cached, actual_size, actual_sha256
|
|
70
|
+
finally:
|
|
71
|
+
await asyncio.to_thread(cached.close)
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import re
|
|
3
3
|
import time
|
|
4
|
-
from collections.abc import Mapping
|
|
4
|
+
from collections.abc import AsyncIterator, Mapping
|
|
5
5
|
from contextlib import suppress
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from enum import StrEnum
|
|
8
8
|
from typing import IO, Generic, TypeAlias, TypeVar, cast
|
|
9
9
|
|
|
10
|
-
from httpx2 import AsyncClient, RequestError, Response
|
|
10
|
+
from httpx2 import AsyncByteStream, AsyncClient, Request, RequestError, Response, SyncByteStream
|
|
11
11
|
from pydantic import BaseModel, JsonValue, ValidationError
|
|
12
|
+
from typing_extensions import override
|
|
12
13
|
|
|
13
14
|
from matelab._generated import models as wire_models
|
|
14
15
|
from matelab._wire_validation import validate_request
|
|
@@ -37,6 +38,21 @@ def _payload_mapping(payload: _Payload) -> Mapping[str, JsonValue]:
|
|
|
37
38
|
return payload
|
|
38
39
|
|
|
39
40
|
|
|
41
|
+
class _ThreadedMultipartStream(AsyncByteStream):
|
|
42
|
+
def __init__(self, stream: SyncByteStream) -> None:
|
|
43
|
+
self._stream = stream
|
|
44
|
+
|
|
45
|
+
@override
|
|
46
|
+
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
47
|
+
iterator = iter(self._stream)
|
|
48
|
+
while (chunk := await asyncio.to_thread(next, iterator, None)) is not None:
|
|
49
|
+
yield chunk
|
|
50
|
+
|
|
51
|
+
@override
|
|
52
|
+
async def aclose(self) -> None:
|
|
53
|
+
await asyncio.to_thread(self._stream.close)
|
|
54
|
+
|
|
55
|
+
|
|
40
56
|
def _provider_error(code: int, *, provider_message: str) -> MatelabAuthenticationError | MatelabProviderError:
|
|
41
57
|
if code == 1:
|
|
42
58
|
return MatelabAuthenticationError("Matelab authentication failed.")
|
|
@@ -138,6 +154,37 @@ class SessionTransport:
|
|
|
138
154
|
)
|
|
139
155
|
return self.session
|
|
140
156
|
|
|
157
|
+
async def _send_multipart(
|
|
158
|
+
self,
|
|
159
|
+
operation: Operation[WireResponse],
|
|
160
|
+
*,
|
|
161
|
+
headers: dict[str, str],
|
|
162
|
+
payload: Mapping[str, JsonValue] | None,
|
|
163
|
+
files: _Files | None,
|
|
164
|
+
) -> Response:
|
|
165
|
+
multipart = {
|
|
166
|
+
**{key: (None, str(value)) for key, value in (payload or {}).items()},
|
|
167
|
+
**(files or {}),
|
|
168
|
+
}
|
|
169
|
+
if files is None or all(isinstance(content, bytes) for _, content, _ in files.values()):
|
|
170
|
+
return await self._http.request(operation.method, operation.path, headers=headers, files=multipart)
|
|
171
|
+
|
|
172
|
+
base_request = self._http.build_request(operation.method, operation.path, headers=headers)
|
|
173
|
+
multipart_headers = base_request.headers.copy()
|
|
174
|
+
_ = multipart_headers.pop("Content-Length", None)
|
|
175
|
+
_ = multipart_headers.pop("Transfer-Encoding", None)
|
|
176
|
+
request = await asyncio.to_thread(
|
|
177
|
+
Request,
|
|
178
|
+
base_request.method,
|
|
179
|
+
base_request.url,
|
|
180
|
+
headers=multipart_headers,
|
|
181
|
+
files=multipart,
|
|
182
|
+
extensions=base_request.extensions,
|
|
183
|
+
)
|
|
184
|
+
if isinstance(request.stream, SyncByteStream):
|
|
185
|
+
request.stream = _ThreadedMultipartStream(request.stream)
|
|
186
|
+
return await self._http.send(request)
|
|
187
|
+
|
|
141
188
|
async def request(
|
|
142
189
|
self, operation: Operation[WireResponse], *, payload: _Payload | None = None, files: _Files | None = None
|
|
143
190
|
) -> WireResponse:
|
|
@@ -169,12 +216,11 @@ class SessionTransport:
|
|
|
169
216
|
operation.method, operation.path, headers=headers, data=encoded_payload
|
|
170
217
|
)
|
|
171
218
|
elif operation.encoding is Encoding.MULTIPART:
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
operation.method, operation.path, headers=headers, files=multipart
|
|
219
|
+
response = await self._send_multipart(
|
|
220
|
+
operation,
|
|
221
|
+
headers=headers,
|
|
222
|
+
payload=encoded_payload,
|
|
223
|
+
files=files,
|
|
178
224
|
)
|
|
179
225
|
else:
|
|
180
226
|
response = await self._http.request(operation.method, operation.path, headers=headers)
|