matelab-python-sdk 0.1.0a10__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.0a10 → matelab_python_sdk-0.1.0a11}/CHANGELOG.md +9 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/PKG-INFO +20 -3
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/README.md +19 -2
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/pyproject.toml +1 -1
- {matelab_python_sdk-0.1.0a10 → 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.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/records.py +88 -86
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/errors.py +22 -0
- matelab_python_sdk-0.1.0a11/tests/test_async_attachment_upload.py +378 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_errors.py +23 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/typing/consumer.py +11 -2
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/uv.lock +1 -1
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/.env.example +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/.github/workflows/release.yml +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/.gitignore +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/AGENTS.md +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/LICENSE +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/NOTICE +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/contracts/matelab-integration-v1.lock.json +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/contracts/matelab-integration-v1.openapi.yaml +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/docs/operation-coverage.yaml +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/scripts/check_installed_package.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/scripts/check_release.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/scripts/generate_models.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/_generated/__init__.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/_generated/models.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/_transport.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/_wire_validation.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/client.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/__init__.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/cloud_drive.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/groups.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/literature.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/notebooks.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/templates.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/uploads.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/users.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/py.typed +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/session.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/streaming.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/conftest.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/provider/test_provider_smoke.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_client.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_cloud_drive.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_comments_and_downloads.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_consumer_smoke.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_extended_records.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_generation.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_groups.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_notebook_lifecycle.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_notebooks.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_operation_coverage.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_record_lifecycle.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_record_mutations.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_records.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_release.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_streaming.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_template_lifecycle.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_templates.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_transport.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_uploads_and_literature.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_users.py +0 -0
- {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/typing/pyproject.toml +0 -0
|
@@ -1,5 +1,14 @@
|
|
|
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
|
+
|
|
3
12
|
## 0.1.0a10 - 2026-08-05
|
|
4
13
|
|
|
5
14
|
- Keeps synchronous multipart file length probes and chunk reads off the event-loop thread while preserving streamed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: matelab-python-sdk
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.0a11
|
|
4
4
|
Summary: Reusable async Python client for the Matelab Integration Contract
|
|
5
5
|
Author-email: 朱天念 <zhutiannian@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -30,7 +30,7 @@ Description-Content-Type: text/markdown
|
|
|
30
30
|
|
|
31
31
|
Reusable async Python client for the Matelab Integration Contract.
|
|
32
32
|
|
|
33
|
-
The current alpha is `0.1.
|
|
33
|
+
The current alpha is `0.1.0a11`. `[project].version` in `pyproject.toml` is the sole SDK version source;
|
|
34
34
|
`uv.lock` only mirrors that source.
|
|
35
35
|
|
|
36
36
|
The SDK is pinned to the immutable `matelab-spec v0.3.0` Contract Release. The sole release pin is
|
|
@@ -59,7 +59,7 @@ To test the same artifact a downstream Consumer will install, build and install
|
|
|
59
59
|
|
|
60
60
|
```bash
|
|
61
61
|
uv build --no-build-isolation --out-dir dist/release
|
|
62
|
-
python -m pip install dist/release/matelab_python_sdk-0.1.
|
|
62
|
+
python -m pip install dist/release/matelab_python_sdk-0.1.0a11-py3-none-any.whl
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
Do not infer Provider compatibility from the SDK version alone. A release is also bound to the Contract
|
|
@@ -94,6 +94,7 @@ Provider details:
|
|
|
94
94
|
|---|---|---:|---|
|
|
95
95
|
| `MatelabAuthenticationError` | `AUTHENTICATION` | No | The Session or credentials cannot authenticate; obtain valid authentication before making a new call. |
|
|
96
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`. |
|
|
97
98
|
| `MatelabProviderError` | `BUSINESS`, `VALIDATION`, or `UPSTREAM` | No | The Provider rejected the request; route directly by `category`. |
|
|
98
99
|
| `MatelabTransportError` | `UPSTREAM` | No | The HTTP exchange failed; `status_code` is present for HTTP failures, and a mutation outcome may be unknown. |
|
|
99
100
|
| `MatelabProtocolError` | `UPSTREAM` | No | The response violates the pinned Contract; treat it as Provider drift or an SDK defect. |
|
|
@@ -234,6 +235,22 @@ result = await client.records.import_dataset(
|
|
|
234
235
|
)
|
|
235
236
|
```
|
|
236
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
|
+
|
|
237
254
|
The same staged handle may instead be consumed by one safe update finalizer. For example, add a new file field to an
|
|
238
255
|
existing form module:
|
|
239
256
|
|
|
@@ -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.0a11`. `[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.3.0` Contract Release. The sole release pin is
|
|
@@ -31,7 +31,7 @@ To test the same artifact a downstream Consumer will install, build and install
|
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
33
|
uv build --no-build-isolation --out-dir dist/release
|
|
34
|
-
python -m pip install dist/release/matelab_python_sdk-0.1.
|
|
34
|
+
python -m pip install dist/release/matelab_python_sdk-0.1.0a11-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
|
|
@@ -66,6 +66,7 @@ Provider details:
|
|
|
66
66
|
|---|---|---:|---|
|
|
67
67
|
| `MatelabAuthenticationError` | `AUTHENTICATION` | No | The Session or credentials cannot authenticate; obtain valid authentication before making a new call. |
|
|
68
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`. |
|
|
69
70
|
| `MatelabProviderError` | `BUSINESS`, `VALIDATION`, or `UPSTREAM` | No | The Provider rejected the request; route directly by `category`. |
|
|
70
71
|
| `MatelabTransportError` | `UPSTREAM` | No | The HTTP exchange failed; `status_code` is present for HTTP failures, and a mutation outcome may be unknown. |
|
|
71
72
|
| `MatelabProtocolError` | `UPSTREAM` | No | The response violates the pinned Contract; treat it as Provider drift or an SDK defect. |
|
|
@@ -206,6 +207,22 @@ result = await client.records.import_dataset(
|
|
|
206
207
|
)
|
|
207
208
|
```
|
|
208
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
|
+
|
|
209
226
|
The same staged handle may instead be consumed by one safe update finalizer. For example, add a new file field to an
|
|
210
227
|
existing form module:
|
|
211
228
|
|
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
import hashlib
|
|
2
2
|
import json
|
|
3
3
|
import re
|
|
4
|
-
from collections.abc import Mapping, Sequence
|
|
4
|
+
from collections.abc import AsyncIterable, Mapping, Sequence
|
|
5
5
|
from dataclasses import dataclass, field
|
|
6
6
|
from html.parser import HTMLParser
|
|
7
7
|
from types import MappingProxyType
|
|
@@ -11,6 +11,7 @@ from urllib.parse import parse_qs, unquote
|
|
|
11
11
|
from pydantic import JsonValue
|
|
12
12
|
from typing_extensions import override
|
|
13
13
|
|
|
14
|
+
from matelab._attachment_content import validated_attachment_content
|
|
14
15
|
from matelab._generated import models as wire_models
|
|
15
16
|
from matelab._transport import Encoding, Operation, SessionTransport, StreamOperation
|
|
16
17
|
from matelab._wire_validation import encode_json_field, validate_request
|
|
@@ -1071,14 +1072,14 @@ class Records:
|
|
|
1071
1072
|
source: RecordLocator,
|
|
1072
1073
|
*,
|
|
1073
1074
|
filename: str,
|
|
1074
|
-
content: IO[bytes] | bytes,
|
|
1075
|
+
content: IO[bytes] | bytes | AsyncIterable[bytes],
|
|
1075
1076
|
size: int,
|
|
1076
|
-
sha256: str,
|
|
1077
|
+
sha256: str | None = None,
|
|
1077
1078
|
binding: UploadBindingRef | None = None,
|
|
1078
1079
|
content_type: str = "application/octet-stream",
|
|
1079
1080
|
) -> StagedRecordAttachment:
|
|
1080
1081
|
selected_binding = binding or UploadBindingRef.new()
|
|
1081
|
-
response = await self._upload_record_attachment(
|
|
1082
|
+
response, actual_size, actual_sha256 = await self._upload_record_attachment(
|
|
1082
1083
|
notebook=source.notebook,
|
|
1083
1084
|
upload_event_uid=source.record.record_uid,
|
|
1084
1085
|
binding=selected_binding,
|
|
@@ -1093,8 +1094,8 @@ class Records:
|
|
|
1093
1094
|
temporary_file_id=response.id,
|
|
1094
1095
|
binding=selected_binding,
|
|
1095
1096
|
filename=response.filename,
|
|
1096
|
-
size=
|
|
1097
|
-
sha256=
|
|
1097
|
+
size=actual_size,
|
|
1098
|
+
sha256=actual_sha256,
|
|
1098
1099
|
)
|
|
1099
1100
|
|
|
1100
1101
|
async def stage_attachment(
|
|
@@ -1102,9 +1103,9 @@ class Records:
|
|
|
1102
1103
|
*,
|
|
1103
1104
|
notebook: NotebookRef,
|
|
1104
1105
|
filename: str,
|
|
1105
|
-
content: IO[bytes] | bytes,
|
|
1106
|
+
content: IO[bytes] | bytes | AsyncIterable[bytes],
|
|
1106
1107
|
size: int,
|
|
1107
|
-
sha256: str,
|
|
1108
|
+
sha256: str | None = None,
|
|
1108
1109
|
binding: UploadBindingRef | None = None,
|
|
1109
1110
|
content_type: str = "application/octet-stream",
|
|
1110
1111
|
) -> StagedNotebookAttachment:
|
|
@@ -1117,7 +1118,7 @@ class Records:
|
|
|
1117
1118
|
|
|
1118
1119
|
uploader = self._authenticated_uploader()
|
|
1119
1120
|
selected_binding = binding or UploadBindingRef.new()
|
|
1120
|
-
response = await self._upload_record_attachment(
|
|
1121
|
+
response, actual_size, actual_sha256 = await self._upload_record_attachment(
|
|
1121
1122
|
notebook=notebook,
|
|
1122
1123
|
upload_event_uid=UploadBindingRef.new().uid,
|
|
1123
1124
|
binding=selected_binding,
|
|
@@ -1133,8 +1134,8 @@ class Records:
|
|
|
1133
1134
|
temporary_file_id=response.id,
|
|
1134
1135
|
binding=selected_binding,
|
|
1135
1136
|
filename=response.filename,
|
|
1136
|
-
size=
|
|
1137
|
-
sha256=
|
|
1137
|
+
size=actual_size,
|
|
1138
|
+
sha256=actual_sha256,
|
|
1138
1139
|
)
|
|
1139
1140
|
|
|
1140
1141
|
async def update(
|
|
@@ -1515,52 +1516,57 @@ class Records:
|
|
|
1515
1516
|
source: RecordLocator,
|
|
1516
1517
|
*,
|
|
1517
1518
|
filename: str,
|
|
1518
|
-
content: IO[bytes] | bytes,
|
|
1519
|
+
content: IO[bytes] | bytes | AsyncIterable[bytes],
|
|
1519
1520
|
size: int,
|
|
1520
|
-
sha256: str,
|
|
1521
|
+
sha256: str | None = None,
|
|
1521
1522
|
binding: UploadBindingRef | None = None,
|
|
1522
1523
|
content_type: str = "application/octet-stream",
|
|
1523
1524
|
) -> StagedRecordCommentAttachment:
|
|
1524
1525
|
selected_binding = binding or UploadBindingRef.new()
|
|
1525
|
-
self._validate_upload_metadata(
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
)
|
|
1538
|
-
response = await self._transport.request(
|
|
1539
|
-
_UPLOAD_COMMENT_ATTACHMENT,
|
|
1540
|
-
payload=request.model_dump(mode="json", exclude={"upload"}),
|
|
1541
|
-
files={"upload": (filename, content, content_type)},
|
|
1542
|
-
)
|
|
1543
|
-
returned_notebook_id, temporary_id, returned_sha256, returned_size, returned_filename, canonical_reference = (
|
|
1544
|
-
self._parse_comment_upload_query(response.query)
|
|
1545
|
-
)
|
|
1546
|
-
if (
|
|
1547
|
-
returned_notebook_id != source.notebook.notebook_id
|
|
1548
|
-
or response.filename != filename
|
|
1549
|
-
or returned_filename != filename
|
|
1550
|
-
or returned_sha256 != sha256
|
|
1551
|
-
or returned_size != size
|
|
1552
|
-
):
|
|
1553
|
-
raise MatelabProtocolError(
|
|
1554
|
-
"Matelab staged the comment attachment but returned different upload metadata; "
|
|
1555
|
-
+ "the temporary upload cannot be aborted through this Contract."
|
|
1526
|
+
self._validate_upload_metadata(filename=filename, size=size, sha256=sha256, content_type=content_type)
|
|
1527
|
+
async with validated_attachment_content(content, size=size, sha256=sha256) as prepared:
|
|
1528
|
+
prepared_content, actual_size, actual_sha256 = prepared
|
|
1529
|
+
request = validate_request(
|
|
1530
|
+
wire_models.UploadRecordCommentAttachmentRequest,
|
|
1531
|
+
{
|
|
1532
|
+
"uid": selected_binding.uid,
|
|
1533
|
+
"eln": source.notebook.notebook_id,
|
|
1534
|
+
"i": source.record.record_database_id,
|
|
1535
|
+
"upload": b"validation-placeholder",
|
|
1536
|
+
},
|
|
1537
|
+
"Record-comment attachment upload does not satisfy the Integration Contract.",
|
|
1556
1538
|
)
|
|
1539
|
+
response = await self._transport.request(
|
|
1540
|
+
_UPLOAD_COMMENT_ATTACHMENT,
|
|
1541
|
+
payload=request.model_dump(mode="json", exclude={"upload"}),
|
|
1542
|
+
files={"upload": (filename, prepared_content, content_type)},
|
|
1543
|
+
)
|
|
1544
|
+
(
|
|
1545
|
+
returned_notebook_id,
|
|
1546
|
+
temporary_id,
|
|
1547
|
+
returned_sha256,
|
|
1548
|
+
returned_size,
|
|
1549
|
+
returned_filename,
|
|
1550
|
+
canonical_reference,
|
|
1551
|
+
) = self._parse_comment_upload_query(response.query)
|
|
1552
|
+
if (
|
|
1553
|
+
returned_notebook_id != source.notebook.notebook_id
|
|
1554
|
+
or response.filename != filename
|
|
1555
|
+
or returned_filename != filename
|
|
1556
|
+
or returned_sha256 != actual_sha256
|
|
1557
|
+
or returned_size != actual_size
|
|
1558
|
+
):
|
|
1559
|
+
raise MatelabProtocolError(
|
|
1560
|
+
"Matelab staged the comment attachment but returned different upload metadata; "
|
|
1561
|
+
+ "the temporary upload cannot be aborted through this Contract."
|
|
1562
|
+
)
|
|
1557
1563
|
return StagedRecordCommentAttachment(
|
|
1558
1564
|
source=source,
|
|
1559
1565
|
binding=selected_binding,
|
|
1560
1566
|
temporary_file_id=temporary_id,
|
|
1561
1567
|
filename=filename,
|
|
1562
|
-
size=
|
|
1563
|
-
sha256=
|
|
1568
|
+
size=actual_size,
|
|
1569
|
+
sha256=actual_sha256,
|
|
1564
1570
|
_temporary_url=self._transport.absolute_url(response.path, response.query),
|
|
1565
1571
|
_canonical_reference=canonical_reference,
|
|
1566
1572
|
)
|
|
@@ -2494,63 +2500,59 @@ class Records:
|
|
|
2494
2500
|
upload_event_uid: str,
|
|
2495
2501
|
binding: UploadBindingRef,
|
|
2496
2502
|
filename: str,
|
|
2497
|
-
content: IO[bytes] | bytes,
|
|
2503
|
+
content: IO[bytes] | bytes | AsyncIterable[bytes],
|
|
2498
2504
|
size: int,
|
|
2499
|
-
sha256: str,
|
|
2505
|
+
sha256: str | None,
|
|
2500
2506
|
content_type: str,
|
|
2501
|
-
) -> wire_models.UploadAttachmentResponse:
|
|
2507
|
+
) -> tuple[wire_models.UploadAttachmentResponse, int, str]:
|
|
2502
2508
|
notebook_title = self._notebook_title(notebook)
|
|
2503
|
-
self._validate_upload_metadata(
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
)
|
|
2524
|
-
if (
|
|
2525
|
-
response.hash.root != sha256
|
|
2526
|
-
or response.size != size
|
|
2527
|
-
or response.filename != filename
|
|
2528
|
-
or response.name != binding.uid
|
|
2529
|
-
):
|
|
2530
|
-
raise MatelabProtocolError(
|
|
2531
|
-
"Matelab staged the record attachment but returned different upload metadata; "
|
|
2532
|
-
+ "the temporary upload may remain."
|
|
2509
|
+
self._validate_upload_metadata(filename=filename, size=size, sha256=sha256, content_type=content_type)
|
|
2510
|
+
async with validated_attachment_content(content, size=size, sha256=sha256) as prepared:
|
|
2511
|
+
prepared_content, actual_size, actual_sha256 = prepared
|
|
2512
|
+
request = validate_request(
|
|
2513
|
+
wire_models.UploadAttachmentRequest,
|
|
2514
|
+
{
|
|
2515
|
+
"eln": notebook_title,
|
|
2516
|
+
"user": notebook.owner_userid if notebook.scope == "shared" else None,
|
|
2517
|
+
"uid": upload_event_uid,
|
|
2518
|
+
"name": binding.uid,
|
|
2519
|
+
"hash": actual_sha256,
|
|
2520
|
+
"last": 1,
|
|
2521
|
+
"file": b"validation-placeholder",
|
|
2522
|
+
},
|
|
2523
|
+
"Record attachment upload does not satisfy the Integration Contract.",
|
|
2524
|
+
)
|
|
2525
|
+
response = await self._transport.request(
|
|
2526
|
+
_UPLOAD_RECORD_ATTACHMENT,
|
|
2527
|
+
payload=request.model_dump(mode="json", exclude={"file"}, exclude_none=True),
|
|
2528
|
+
files={"file": (filename, prepared_content, content_type)},
|
|
2533
2529
|
)
|
|
2534
|
-
|
|
2530
|
+
if (
|
|
2531
|
+
response.hash.root != actual_sha256
|
|
2532
|
+
or response.size != actual_size
|
|
2533
|
+
or response.filename != filename
|
|
2534
|
+
or response.name != binding.uid
|
|
2535
|
+
):
|
|
2536
|
+
raise MatelabProtocolError(
|
|
2537
|
+
"Matelab staged the record attachment but returned different upload metadata; "
|
|
2538
|
+
+ "the temporary upload may remain."
|
|
2539
|
+
)
|
|
2540
|
+
return response, actual_size, actual_sha256
|
|
2535
2541
|
|
|
2536
2542
|
@staticmethod
|
|
2537
|
-
def _validate_upload_metadata(
|
|
2538
|
-
*, filename: str, content: IO[bytes] | bytes, size: int, sha256: str, content_type: str
|
|
2539
|
-
) -> None:
|
|
2543
|
+
def _validate_upload_metadata(*, filename: str, size: int, sha256: str | None, content_type: str) -> None:
|
|
2540
2544
|
if (
|
|
2541
2545
|
not filename
|
|
2542
2546
|
or len(filename) > 1000
|
|
2543
2547
|
or "\r" in filename
|
|
2544
2548
|
or "\n" in filename
|
|
2545
2549
|
or size < 0
|
|
2546
|
-
or re.fullmatch(r"[0-9a-f]{64}", sha256) is None
|
|
2550
|
+
or (sha256 is not None and re.fullmatch(r"[0-9a-f]{64}", sha256) is None)
|
|
2547
2551
|
or not content_type
|
|
2548
2552
|
or "\r" in content_type
|
|
2549
2553
|
or "\n" in content_type
|
|
2550
2554
|
):
|
|
2551
2555
|
raise MatelabUsageError("Attachment metadata does not satisfy the Integration Contract.")
|
|
2552
|
-
if isinstance(content, bytes) and (len(content) != size or hashlib.sha256(content).hexdigest() != sha256):
|
|
2553
|
-
raise MatelabUsageError("Attachment bytes do not match the declared size and SHA-256.")
|
|
2554
2556
|
|
|
2555
2557
|
@staticmethod
|
|
2556
2558
|
def _parse_comment_upload_query(query: str) -> tuple[int, int, str, int, str, str]:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from enum import StrEnum
|
|
2
|
+
from typing import Literal
|
|
2
3
|
|
|
3
4
|
|
|
4
5
|
class MatelabErrorCategory(StrEnum):
|
|
@@ -37,6 +38,27 @@ class MatelabUsageError(MatelabError):
|
|
|
37
38
|
category = MatelabErrorCategory.VALIDATION
|
|
38
39
|
|
|
39
40
|
|
|
41
|
+
class MatelabAttachmentValidationError(MatelabUsageError):
|
|
42
|
+
"""Attachment bytes differ from caller-declared upload metadata."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
reason: Literal["size_mismatch", "checksum_mismatch"],
|
|
47
|
+
*,
|
|
48
|
+
expected: int | str,
|
|
49
|
+
actual: int | str,
|
|
50
|
+
) -> None:
|
|
51
|
+
message = (
|
|
52
|
+
"Attachment content size does not match the declared size."
|
|
53
|
+
if reason == "size_mismatch"
|
|
54
|
+
else "Attachment content SHA-256 does not match the declared checksum."
|
|
55
|
+
)
|
|
56
|
+
super().__init__(message)
|
|
57
|
+
self.reason = reason
|
|
58
|
+
self.expected = expected
|
|
59
|
+
self.actual = actual
|
|
60
|
+
|
|
61
|
+
|
|
40
62
|
class MatelabTransportError(MatelabError):
|
|
41
63
|
"""The HTTP exchange with the Provider failed."""
|
|
42
64
|
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import hashlib
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from io import BytesIO
|
|
5
|
+
from tempfile import SpooledTemporaryFile
|
|
6
|
+
from threading import Event, Lock
|
|
7
|
+
from typing import IO, cast
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from httpx2 import AsyncClient, MockTransport, Request, Response
|
|
11
|
+
|
|
12
|
+
from matelab import (
|
|
13
|
+
AsyncMatelab,
|
|
14
|
+
MatelabAttachmentValidationError,
|
|
15
|
+
NotebookRef,
|
|
16
|
+
RecordLocator,
|
|
17
|
+
RecordRef,
|
|
18
|
+
Session,
|
|
19
|
+
UploadBindingRef,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_CONTENT = b"alpha"
|
|
23
|
+
_SHA256 = hashlib.sha256(_CONTENT).hexdigest()
|
|
24
|
+
_NOTEBOOK = NotebookRef(notebook_id=12, title="Example Notebook", owner_userid=42, scope="owned")
|
|
25
|
+
_SOURCE = RecordLocator(notebook=_NOTEBOOK, record=RecordRef(record_database_id=9001, record_uid="REC-001"))
|
|
26
|
+
_SPOOL_WRITE = cast(Callable[[SpooledTemporaryFile[bytes], bytes], int], SpooledTemporaryFile.write)
|
|
27
|
+
_SPOOL_SEEK = cast(Callable[[SpooledTemporaryFile[bytes], int, int], int], SpooledTemporaryFile.seek)
|
|
28
|
+
_SPOOL_READ = cast(Callable[[SpooledTemporaryFile[bytes], int], bytes], SpooledTemporaryFile.read)
|
|
29
|
+
_SPOOL_CLOSE = cast(Callable[[SpooledTemporaryFile[bytes]], None], SpooledTemporaryFile.close)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _upload_response(
|
|
33
|
+
envelope: Callable[..., dict[str, object]], *, binding: UploadBindingRef, sha256: str = _SHA256
|
|
34
|
+
) -> Response:
|
|
35
|
+
return Response(
|
|
36
|
+
200,
|
|
37
|
+
json=envelope(
|
|
38
|
+
id=7301,
|
|
39
|
+
path="url_eln/temp",
|
|
40
|
+
query=f"h={sha256}&id=7301&s={len(_CONTENT)}&f=alpha.txt",
|
|
41
|
+
name=binding.uid,
|
|
42
|
+
filename="alpha.txt",
|
|
43
|
+
size=len(_CONTENT),
|
|
44
|
+
hash=sha256,
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.mark.asyncio
|
|
50
|
+
async def test_stage_attachment_accepts_async_content(
|
|
51
|
+
provider_session: Session, provider_envelope: Callable[..., dict[str, object]]
|
|
52
|
+
) -> None:
|
|
53
|
+
binding = UploadBindingRef(uid="async-upload-with-checksum")
|
|
54
|
+
|
|
55
|
+
async def content():
|
|
56
|
+
yield b"al"
|
|
57
|
+
await asyncio.sleep(0)
|
|
58
|
+
yield b"pha"
|
|
59
|
+
|
|
60
|
+
async def handler(request: Request) -> Response:
|
|
61
|
+
assert request.url.path == "/api/eln_api/upload"
|
|
62
|
+
assert b'name="file"; filename="alpha.txt"' in request.content
|
|
63
|
+
assert b"\r\n\r\nalpha\r\n" in request.content
|
|
64
|
+
return _upload_response(provider_envelope, binding=binding)
|
|
65
|
+
|
|
66
|
+
async with AsyncClient(base_url="https://matelab.example/api", transport=MockTransport(handler)) as http_client:
|
|
67
|
+
client = AsyncMatelab("https://ignored.example", session=provider_session, http_client=http_client)
|
|
68
|
+
staged = await client.records.stage_attachment(
|
|
69
|
+
notebook=_NOTEBOOK,
|
|
70
|
+
filename="alpha.txt",
|
|
71
|
+
content=content(),
|
|
72
|
+
size=len(_CONTENT),
|
|
73
|
+
sha256=_SHA256,
|
|
74
|
+
binding=binding,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
assert staged.size == len(_CONTENT)
|
|
78
|
+
assert staged.sha256 == _SHA256
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.asyncio
|
|
82
|
+
async def test_stage_attachment_computes_missing_async_checksum(
|
|
83
|
+
provider_session: Session, provider_envelope: Callable[..., dict[str, object]]
|
|
84
|
+
) -> None:
|
|
85
|
+
binding = UploadBindingRef(uid="async-upload-computed-checksum")
|
|
86
|
+
|
|
87
|
+
async def content():
|
|
88
|
+
yield _CONTENT
|
|
89
|
+
|
|
90
|
+
async def handler(request: Request) -> Response:
|
|
91
|
+
assert _SHA256.encode() in request.content
|
|
92
|
+
return _upload_response(provider_envelope, binding=binding)
|
|
93
|
+
|
|
94
|
+
async with AsyncClient(base_url="https://matelab.example/api", transport=MockTransport(handler)) as http_client:
|
|
95
|
+
client = AsyncMatelab("https://ignored.example", session=provider_session, http_client=http_client)
|
|
96
|
+
staged = await client.records.stage_attachment(
|
|
97
|
+
notebook=_NOTEBOOK,
|
|
98
|
+
filename="alpha.txt",
|
|
99
|
+
content=content(),
|
|
100
|
+
size=len(_CONTENT),
|
|
101
|
+
binding=binding,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
assert staged.size == len(_CONTENT)
|
|
105
|
+
assert staged.sha256 == _SHA256
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@pytest.mark.asyncio
|
|
109
|
+
async def test_record_attachment_upload_uses_shared_async_content_handling(
|
|
110
|
+
provider_session: Session, provider_envelope: Callable[..., dict[str, object]]
|
|
111
|
+
) -> None:
|
|
112
|
+
binding = UploadBindingRef(uid="async-record-upload")
|
|
113
|
+
|
|
114
|
+
async def content():
|
|
115
|
+
yield _CONTENT
|
|
116
|
+
|
|
117
|
+
async def handler(request: Request) -> Response:
|
|
118
|
+
assert request.url.path == "/api/eln_api/upload"
|
|
119
|
+
return _upload_response(provider_envelope, binding=binding)
|
|
120
|
+
|
|
121
|
+
async with AsyncClient(base_url="https://matelab.example/api", transport=MockTransport(handler)) as http_client:
|
|
122
|
+
client = AsyncMatelab("https://ignored.example", session=provider_session, http_client=http_client)
|
|
123
|
+
staged = await client.records.upload_attachment(
|
|
124
|
+
_SOURCE,
|
|
125
|
+
filename="alpha.txt",
|
|
126
|
+
content=content(),
|
|
127
|
+
size=len(_CONTENT),
|
|
128
|
+
binding=binding,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
assert staged.size == len(_CONTENT)
|
|
132
|
+
assert staged.sha256 == _SHA256
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@pytest.mark.asyncio
|
|
136
|
+
async def test_comment_attachment_upload_uses_shared_async_content_handling(
|
|
137
|
+
provider_session: Session, provider_envelope: Callable[..., dict[str, object]]
|
|
138
|
+
) -> None:
|
|
139
|
+
binding = UploadBindingRef(uid="async-comment-upload")
|
|
140
|
+
|
|
141
|
+
async def content():
|
|
142
|
+
yield _CONTENT
|
|
143
|
+
|
|
144
|
+
async def handler(request: Request) -> Response:
|
|
145
|
+
assert request.url.path == "/api/eln_items/more_upload"
|
|
146
|
+
assert b'name="upload"; filename="alpha.txt"' in request.content
|
|
147
|
+
return Response(
|
|
148
|
+
200,
|
|
149
|
+
json=provider_envelope(
|
|
150
|
+
filename="alpha.txt",
|
|
151
|
+
path="eln_url/eln_temp",
|
|
152
|
+
query=f"eln=12&id=7302&h={_SHA256}&s={len(_CONTENT)}&f=alpha.txt",
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
async with AsyncClient(base_url="https://matelab.example/api", transport=MockTransport(handler)) as http_client:
|
|
157
|
+
client = AsyncMatelab("https://ignored.example", session=provider_session, http_client=http_client)
|
|
158
|
+
staged = await client.records.upload_comment_attachment(
|
|
159
|
+
_SOURCE,
|
|
160
|
+
filename="alpha.txt",
|
|
161
|
+
content=content(),
|
|
162
|
+
size=len(_CONTENT),
|
|
163
|
+
binding=binding,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
assert staged.size == len(_CONTENT)
|
|
167
|
+
assert staged.sha256 == _SHA256
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@pytest.mark.parametrize(
|
|
171
|
+
("chunks", "declared_size", "actual_size"),
|
|
172
|
+
[
|
|
173
|
+
pytest.param((b"alpha",), 4, 5, id="too-large"),
|
|
174
|
+
pytest.param((b"al", b"ph"), 5, 4, id="too-small"),
|
|
175
|
+
],
|
|
176
|
+
)
|
|
177
|
+
@pytest.mark.asyncio
|
|
178
|
+
async def test_async_attachment_size_mismatch_is_structured_and_sends_no_http(
|
|
179
|
+
provider_session: Session,
|
|
180
|
+
chunks: tuple[bytes, ...],
|
|
181
|
+
declared_size: int,
|
|
182
|
+
actual_size: int,
|
|
183
|
+
) -> None:
|
|
184
|
+
calls = 0
|
|
185
|
+
|
|
186
|
+
async def content():
|
|
187
|
+
for chunk in chunks:
|
|
188
|
+
yield chunk
|
|
189
|
+
|
|
190
|
+
async def handler(_: Request) -> Response:
|
|
191
|
+
nonlocal calls
|
|
192
|
+
calls += 1
|
|
193
|
+
raise AssertionError("validation failures must not send HTTP")
|
|
194
|
+
|
|
195
|
+
async with AsyncClient(base_url="https://matelab.example/api", transport=MockTransport(handler)) as http_client:
|
|
196
|
+
client = AsyncMatelab("https://ignored.example", session=provider_session, http_client=http_client)
|
|
197
|
+
with pytest.raises(MatelabAttachmentValidationError) as caught:
|
|
198
|
+
_ = await client.records.stage_attachment(
|
|
199
|
+
notebook=_NOTEBOOK,
|
|
200
|
+
filename="alpha.txt",
|
|
201
|
+
content=content(),
|
|
202
|
+
size=declared_size,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
assert caught.value.reason == "size_mismatch"
|
|
206
|
+
assert caught.value.expected == declared_size
|
|
207
|
+
assert caught.value.actual == actual_size
|
|
208
|
+
assert calls == 0
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@pytest.mark.asyncio
|
|
212
|
+
async def test_async_attachment_checksum_mismatch_is_structured_and_sends_no_http(
|
|
213
|
+
provider_session: Session,
|
|
214
|
+
) -> None:
|
|
215
|
+
calls = 0
|
|
216
|
+
expected_sha256 = "0" * 64
|
|
217
|
+
|
|
218
|
+
async def content():
|
|
219
|
+
yield _CONTENT
|
|
220
|
+
|
|
221
|
+
async def handler(_: Request) -> Response:
|
|
222
|
+
nonlocal calls
|
|
223
|
+
calls += 1
|
|
224
|
+
raise AssertionError("validation failures must not send HTTP")
|
|
225
|
+
|
|
226
|
+
async with AsyncClient(base_url="https://matelab.example/api", transport=MockTransport(handler)) as http_client:
|
|
227
|
+
client = AsyncMatelab("https://ignored.example", session=provider_session, http_client=http_client)
|
|
228
|
+
with pytest.raises(MatelabAttachmentValidationError) as caught:
|
|
229
|
+
_ = await client.records.stage_attachment(
|
|
230
|
+
notebook=_NOTEBOOK,
|
|
231
|
+
filename="alpha.txt",
|
|
232
|
+
content=content(),
|
|
233
|
+
size=len(_CONTENT),
|
|
234
|
+
sha256=expected_sha256,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
assert caught.value.reason == "checksum_mismatch"
|
|
238
|
+
assert caught.value.expected == expected_sha256
|
|
239
|
+
assert caught.value.actual == _SHA256
|
|
240
|
+
assert calls == 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@pytest.mark.asyncio
|
|
244
|
+
async def test_spooled_async_attachment_operations_do_not_block_the_event_loop(
|
|
245
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
246
|
+
provider_session: Session,
|
|
247
|
+
provider_envelope: Callable[..., dict[str, object]],
|
|
248
|
+
) -> None:
|
|
249
|
+
binding = UploadBindingRef(uid="async-upload-threaded-spool")
|
|
250
|
+
operations = ("write", "seek", "read", "close")
|
|
251
|
+
started = {operation: Event() for operation in operations}
|
|
252
|
+
progressed = {operation: Event() for operation in operations}
|
|
253
|
+
observed: set[str] = set()
|
|
254
|
+
observed_lock = Lock()
|
|
255
|
+
|
|
256
|
+
def wait_for_event_loop(operation: str) -> None:
|
|
257
|
+
with observed_lock:
|
|
258
|
+
first_call = operation not in observed
|
|
259
|
+
observed.add(operation)
|
|
260
|
+
if first_call:
|
|
261
|
+
started[operation].set()
|
|
262
|
+
if not progressed[operation].wait(timeout=5):
|
|
263
|
+
raise AssertionError(f"event loop did not progress during spooled-file {operation}")
|
|
264
|
+
|
|
265
|
+
def blocking_write(file: SpooledTemporaryFile[bytes], data: bytes) -> int:
|
|
266
|
+
file.rollover()
|
|
267
|
+
wait_for_event_loop("write")
|
|
268
|
+
return _SPOOL_WRITE(file, data)
|
|
269
|
+
|
|
270
|
+
def blocking_seek(file: SpooledTemporaryFile[bytes], offset: int, whence: int = 0) -> int:
|
|
271
|
+
wait_for_event_loop("seek")
|
|
272
|
+
return _SPOOL_SEEK(file, offset, whence)
|
|
273
|
+
|
|
274
|
+
def blocking_read(file: SpooledTemporaryFile[bytes], size: int = -1) -> bytes:
|
|
275
|
+
wait_for_event_loop("read")
|
|
276
|
+
return _SPOOL_READ(file, size)
|
|
277
|
+
|
|
278
|
+
def blocking_close(file: SpooledTemporaryFile[bytes]) -> None:
|
|
279
|
+
wait_for_event_loop("close")
|
|
280
|
+
_SPOOL_CLOSE(file)
|
|
281
|
+
|
|
282
|
+
monkeypatch.setattr(SpooledTemporaryFile, "write", blocking_write)
|
|
283
|
+
monkeypatch.setattr(SpooledTemporaryFile, "seek", blocking_seek)
|
|
284
|
+
monkeypatch.setattr(SpooledTemporaryFile, "read", blocking_read)
|
|
285
|
+
monkeypatch.setattr(SpooledTemporaryFile, "close", blocking_close)
|
|
286
|
+
|
|
287
|
+
async def observe_operations() -> None:
|
|
288
|
+
for operation in operations:
|
|
289
|
+
while not started[operation].is_set():
|
|
290
|
+
await asyncio.sleep(0)
|
|
291
|
+
progressed[operation].set()
|
|
292
|
+
|
|
293
|
+
async def content():
|
|
294
|
+
yield _CONTENT
|
|
295
|
+
|
|
296
|
+
async def handler(_: Request) -> Response:
|
|
297
|
+
return _upload_response(provider_envelope, binding=binding)
|
|
298
|
+
|
|
299
|
+
observer = asyncio.create_task(observe_operations())
|
|
300
|
+
async with AsyncClient(base_url="https://matelab.example/api", transport=MockTransport(handler)) as http_client:
|
|
301
|
+
client = AsyncMatelab("https://ignored.example", session=provider_session, http_client=http_client)
|
|
302
|
+
_ = await client.records.stage_attachment(
|
|
303
|
+
notebook=_NOTEBOOK,
|
|
304
|
+
filename="alpha.txt",
|
|
305
|
+
content=content(),
|
|
306
|
+
size=len(_CONTENT),
|
|
307
|
+
binding=binding,
|
|
308
|
+
)
|
|
309
|
+
await observer
|
|
310
|
+
|
|
311
|
+
assert observed == set(operations)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@pytest.mark.asyncio
|
|
315
|
+
async def test_async_source_exception_propagates_and_closes_spooled_file(
|
|
316
|
+
monkeypatch: pytest.MonkeyPatch, provider_session: Session
|
|
317
|
+
) -> None:
|
|
318
|
+
calls = 0
|
|
319
|
+
closed = Event()
|
|
320
|
+
source_error = RuntimeError("caller disconnected")
|
|
321
|
+
|
|
322
|
+
def observed_close(file: SpooledTemporaryFile[bytes]) -> None:
|
|
323
|
+
closed.set()
|
|
324
|
+
_SPOOL_CLOSE(file)
|
|
325
|
+
|
|
326
|
+
monkeypatch.setattr(SpooledTemporaryFile, "close", observed_close)
|
|
327
|
+
|
|
328
|
+
async def content():
|
|
329
|
+
yield b"al"
|
|
330
|
+
raise source_error
|
|
331
|
+
|
|
332
|
+
async def handler(_: Request) -> Response:
|
|
333
|
+
nonlocal calls
|
|
334
|
+
calls += 1
|
|
335
|
+
raise AssertionError("a failed source must not send HTTP")
|
|
336
|
+
|
|
337
|
+
async with AsyncClient(base_url="https://matelab.example/api", transport=MockTransport(handler)) as http_client:
|
|
338
|
+
client = AsyncMatelab("https://ignored.example", session=provider_session, http_client=http_client)
|
|
339
|
+
with pytest.raises(RuntimeError) as caught:
|
|
340
|
+
_ = await client.records.stage_attachment(
|
|
341
|
+
notebook=_NOTEBOOK,
|
|
342
|
+
filename="alpha.txt",
|
|
343
|
+
content=content(),
|
|
344
|
+
size=len(_CONTENT),
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
assert caught.value is source_error
|
|
348
|
+
assert closed.is_set()
|
|
349
|
+
assert calls == 0
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@pytest.mark.parametrize(
|
|
353
|
+
"content", [pytest.param(_CONTENT, id="bytes"), pytest.param(BytesIO(_CONTENT), id="binary-io")]
|
|
354
|
+
)
|
|
355
|
+
@pytest.mark.asyncio
|
|
356
|
+
async def test_stage_attachment_preserves_existing_content_inputs(
|
|
357
|
+
content: IO[bytes] | bytes,
|
|
358
|
+
provider_session: Session,
|
|
359
|
+
provider_envelope: Callable[..., dict[str, object]],
|
|
360
|
+
) -> None:
|
|
361
|
+
binding = UploadBindingRef(uid="compatible-upload-input")
|
|
362
|
+
|
|
363
|
+
async def handler(request: Request) -> Response:
|
|
364
|
+
assert b"\r\n\r\nalpha\r\n" in request.content
|
|
365
|
+
return _upload_response(provider_envelope, binding=binding)
|
|
366
|
+
|
|
367
|
+
async with AsyncClient(base_url="https://matelab.example/api", transport=MockTransport(handler)) as http_client:
|
|
368
|
+
client = AsyncMatelab("https://ignored.example", session=provider_session, http_client=http_client)
|
|
369
|
+
staged = await client.records.stage_attachment(
|
|
370
|
+
notebook=_NOTEBOOK,
|
|
371
|
+
filename="alpha.txt",
|
|
372
|
+
content=content,
|
|
373
|
+
size=len(_CONTENT),
|
|
374
|
+
sha256=_SHA256,
|
|
375
|
+
binding=binding,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
assert staged.sha256 == _SHA256
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
1
3
|
import pytest
|
|
2
4
|
|
|
3
5
|
import matelab
|
|
4
6
|
from matelab import (
|
|
7
|
+
MatelabAttachmentValidationError,
|
|
5
8
|
MatelabAuthenticationError,
|
|
6
9
|
MatelabError,
|
|
7
10
|
MatelabErrorCategory,
|
|
@@ -13,6 +16,7 @@ from matelab import (
|
|
|
13
16
|
|
|
14
17
|
|
|
15
18
|
def test_provider_error_kind_is_not_part_of_public_interface() -> None:
|
|
19
|
+
assert "MatelabAttachmentValidationError" in matelab.__all__
|
|
16
20
|
assert "MatelabProviderErrorKind" not in matelab.__all__
|
|
17
21
|
assert not hasattr(matelab, "MatelabProviderErrorKind")
|
|
18
22
|
|
|
@@ -65,3 +69,22 @@ def test_transport_error_preserves_http_status_without_changing_integration_sema
|
|
|
65
69
|
assert error.status_code == 503
|
|
66
70
|
assert error.category is MatelabErrorCategory.UPSTREAM
|
|
67
71
|
assert error.retryable is False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.parametrize(
|
|
75
|
+
("reason", "expected", "actual"),
|
|
76
|
+
[
|
|
77
|
+
pytest.param("size_mismatch", 5, 4, id="size"),
|
|
78
|
+
pytest.param("checksum_mismatch", "a" * 64, "b" * 64, id="checksum"),
|
|
79
|
+
],
|
|
80
|
+
)
|
|
81
|
+
def test_attachment_validation_error_exposes_stable_reason_and_values(
|
|
82
|
+
reason: Literal["size_mismatch", "checksum_mismatch"], expected: int | str, actual: int | str
|
|
83
|
+
) -> None:
|
|
84
|
+
error = MatelabAttachmentValidationError(reason, expected=expected, actual=actual)
|
|
85
|
+
|
|
86
|
+
assert error.reason == reason
|
|
87
|
+
assert error.expected == expected
|
|
88
|
+
assert error.actual == actual
|
|
89
|
+
assert error.category is MatelabErrorCategory.VALIDATION
|
|
90
|
+
assert error.retryable is False
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
from collections.abc import AsyncIterator
|
|
2
|
+
|
|
1
3
|
from matelab import (
|
|
2
4
|
AsyncMatelab,
|
|
5
|
+
MatelabAttachmentValidationError,
|
|
3
6
|
MatelabError,
|
|
4
7
|
MatelabErrorCategory,
|
|
5
8
|
MatelabProviderError,
|
|
@@ -34,6 +37,10 @@ def construct_provider_error(category: MatelabErrorCategory) -> MatelabProviderE
|
|
|
34
37
|
return MatelabProviderError("failure", category=category)
|
|
35
38
|
|
|
36
39
|
|
|
40
|
+
def consume_attachment_error(error: MatelabAttachmentValidationError) -> tuple[str, int | str, int | str]:
|
|
41
|
+
return error.reason, error.expected, error.actual
|
|
42
|
+
|
|
43
|
+
|
|
37
44
|
async def consume_public_types(client: AsyncMatelab) -> tuple[Session | None, NotebookCollection]:
|
|
38
45
|
notebooks: NotebookCollection = await client.notebooks.list()
|
|
39
46
|
session: Session | None = client.session
|
|
@@ -47,12 +54,14 @@ async def consume_public_types(client: AsyncMatelab) -> tuple[Session | None, No
|
|
|
47
54
|
async def consume_staged_attachment(
|
|
48
55
|
client: AsyncMatelab, notebook: NotebookRef, source: RecordLocator, attachment: StagedNotebookAttachment
|
|
49
56
|
) -> None:
|
|
57
|
+
async def content() -> AsyncIterator[bytes]:
|
|
58
|
+
yield b"example"
|
|
59
|
+
|
|
50
60
|
staged: StagedNotebookAttachment = await client.records.stage_attachment(
|
|
51
61
|
notebook=notebook,
|
|
52
62
|
filename="example.txt",
|
|
53
|
-
content=
|
|
63
|
+
content=content(),
|
|
54
64
|
size=7,
|
|
55
|
-
sha256="50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacbce6576c",
|
|
56
65
|
)
|
|
57
66
|
import_item: RecordImportItem = RecordImportItem(
|
|
58
67
|
record_uid="REC-IMPORT-001", title="Imported", data={"Attachments": {"File": [staged]}}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/scripts/check_installed_package.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/_generated/__init__.py
RENAMED
|
File without changes
|
{matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/_generated/models.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/cloud_drive.py
RENAMED
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/literature.py
RENAMED
|
File without changes
|
{matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/notebooks.py
RENAMED
|
File without changes
|
{matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/templates.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/provider/test_provider_smoke.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_comments_and_downloads.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_notebook_lifecycle.py
RENAMED
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_operation_coverage.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_template_lifecycle.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_uploads_and_literature.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|