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.
Files changed (63) hide show
  1. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/CHANGELOG.md +16 -0
  2. matelab_python_sdk-0.1.0a9/README.md → matelab_python_sdk-0.1.0a11/PKG-INFO +50 -4
  3. matelab_python_sdk-0.1.0a9/PKG-INFO → matelab_python_sdk-0.1.0a11/README.md +22 -31
  4. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/pyproject.toml +2 -1
  5. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/scripts/check_release.py +6 -1
  6. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/__init__.py +2 -0
  7. matelab_python_sdk-0.1.0a11/src/matelab/_attachment_content.py +71 -0
  8. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/_transport.py +54 -8
  9. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/records.py +97 -99
  10. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/errors.py +22 -0
  11. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/session.py +3 -5
  12. matelab_python_sdk-0.1.0a11/tests/test_async_attachment_upload.py +378 -0
  13. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_client.py +7 -2
  14. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_errors.py +23 -0
  15. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_release.py +7 -1
  16. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_streaming.py +7 -3
  17. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_transport.py +99 -1
  18. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/typing/consumer.py +11 -2
  19. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/uv.lock +3 -1
  20. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/.env.example +0 -0
  21. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/.github/workflows/release.yml +0 -0
  22. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/.gitignore +0 -0
  23. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/AGENTS.md +0 -0
  24. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/LICENSE +0 -0
  25. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/NOTICE +0 -0
  26. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/contracts/matelab-integration-v1.lock.json +0 -0
  27. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/contracts/matelab-integration-v1.openapi.yaml +0 -0
  28. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/docs/operation-coverage.yaml +0 -0
  29. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/scripts/check_installed_package.py +0 -0
  30. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/scripts/generate_models.py +0 -0
  31. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/_generated/__init__.py +0 -0
  32. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/_generated/models.py +0 -0
  33. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/_wire_validation.py +0 -0
  34. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/client.py +0 -0
  35. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/__init__.py +0 -0
  36. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/cloud_drive.py +0 -0
  37. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/groups.py +0 -0
  38. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/literature.py +0 -0
  39. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/notebooks.py +0 -0
  40. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/templates.py +0 -0
  41. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/uploads.py +0 -0
  42. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/users.py +0 -0
  43. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/py.typed +0 -0
  44. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/src/matelab/streaming.py +0 -0
  45. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/conftest.py +0 -0
  46. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/provider/test_provider_smoke.py +0 -0
  47. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_cloud_drive.py +0 -0
  48. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_comments_and_downloads.py +0 -0
  49. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_consumer_smoke.py +0 -0
  50. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_extended_records.py +0 -0
  51. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_generation.py +0 -0
  52. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_groups.py +0 -0
  53. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_notebook_lifecycle.py +0 -0
  54. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_notebooks.py +0 -0
  55. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_operation_coverage.py +0 -0
  56. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_record_lifecycle.py +0 -0
  57. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_record_mutations.py +0 -0
  58. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_records.py +0 -0
  59. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_template_lifecycle.py +0 -0
  60. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_templates.py +0 -0
  61. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_uploads_and_literature.py +0 -0
  62. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a11}/tests/test_users.py +0 -0
  63. {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.0a9`. `[project].version` in `pyproject.toml` is the sole SDK version source;
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.0a9-py3-none-any.whl
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`, and
605
- `pydantic>=2.13.4,<3`; the build backend requires `hatchling>=1.27,<2`. These lower bounds are verified
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.0a9`. `[project].version` in `pyproject.toml` is the sole SDK version source;
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.0a9-py3-none-any.whl
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`, and
632
- `pydantic>=2.13.4,<3`; the build backend requires `hatchling>=1.27,<2`. These lower bounds are verified
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.0a9"
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 = {"httpx2<3,>=2.9.1", "jsonschema<5,>=4.26", "pydantic<3,>=2.13.4"}
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
- multipart = {
173
- **{key: (None, str(value)) for key, value in (encoded_payload or {}).items()},
174
- **(files or {}),
175
- }
176
- response = await self._http.request(
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)