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.
Files changed (63) hide show
  1. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/CHANGELOG.md +9 -0
  2. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/PKG-INFO +20 -3
  3. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/README.md +19 -2
  4. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/pyproject.toml +1 -1
  5. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/__init__.py +2 -0
  6. matelab_python_sdk-0.1.0a11/src/matelab/_attachment_content.py +71 -0
  7. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/records.py +88 -86
  8. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/errors.py +22 -0
  9. matelab_python_sdk-0.1.0a11/tests/test_async_attachment_upload.py +378 -0
  10. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_errors.py +23 -0
  11. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/typing/consumer.py +11 -2
  12. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/uv.lock +1 -1
  13. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/.env.example +0 -0
  14. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/.github/workflows/release.yml +0 -0
  15. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/.gitignore +0 -0
  16. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/AGENTS.md +0 -0
  17. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/LICENSE +0 -0
  18. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/NOTICE +0 -0
  19. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/contracts/matelab-integration-v1.lock.json +0 -0
  20. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/contracts/matelab-integration-v1.openapi.yaml +0 -0
  21. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/docs/operation-coverage.yaml +0 -0
  22. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/scripts/check_installed_package.py +0 -0
  23. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/scripts/check_release.py +0 -0
  24. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/scripts/generate_models.py +0 -0
  25. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/_generated/__init__.py +0 -0
  26. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/_generated/models.py +0 -0
  27. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/_transport.py +0 -0
  28. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/_wire_validation.py +0 -0
  29. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/client.py +0 -0
  30. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/__init__.py +0 -0
  31. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/cloud_drive.py +0 -0
  32. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/groups.py +0 -0
  33. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/literature.py +0 -0
  34. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/notebooks.py +0 -0
  35. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/templates.py +0 -0
  36. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/uploads.py +0 -0
  37. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/domains/users.py +0 -0
  38. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/py.typed +0 -0
  39. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/session.py +0 -0
  40. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/src/matelab/streaming.py +0 -0
  41. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/conftest.py +0 -0
  42. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/provider/test_provider_smoke.py +0 -0
  43. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_client.py +0 -0
  44. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_cloud_drive.py +0 -0
  45. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_comments_and_downloads.py +0 -0
  46. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_consumer_smoke.py +0 -0
  47. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_extended_records.py +0 -0
  48. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_generation.py +0 -0
  49. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_groups.py +0 -0
  50. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_notebook_lifecycle.py +0 -0
  51. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_notebooks.py +0 -0
  52. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_operation_coverage.py +0 -0
  53. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_record_lifecycle.py +0 -0
  54. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_record_mutations.py +0 -0
  55. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_records.py +0 -0
  56. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_release.py +0 -0
  57. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_streaming.py +0 -0
  58. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_template_lifecycle.py +0 -0
  59. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_templates.py +0 -0
  60. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_transport.py +0 -0
  61. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_uploads_and_literature.py +0 -0
  62. {matelab_python_sdk-0.1.0a10 → matelab_python_sdk-0.1.0a11}/tests/test_users.py +0 -0
  63. {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.0a10
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.0a10`. `[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;
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.0a10-py3-none-any.whl
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.0a10`. `[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;
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.0a10-py3-none-any.whl
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "matelab-python-sdk"
7
- version = "0.1.0a10"
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"
@@ -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=response.size,
1097
- sha256=response.hash.root,
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=response.size,
1137
- sha256=response.hash.root,
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
- filename=filename, content=content, size=size, sha256=sha256, content_type=content_type
1527
- )
1528
- request = validate_request(
1529
- wire_models.UploadRecordCommentAttachmentRequest,
1530
- {
1531
- "uid": selected_binding.uid,
1532
- "eln": source.notebook.notebook_id,
1533
- "i": source.record.record_database_id,
1534
- "upload": b"validation-placeholder",
1535
- },
1536
- "Record-comment attachment upload does not satisfy the Integration Contract.",
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=size,
1563
- sha256=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
- filename=filename, content=content, size=size, sha256=sha256, content_type=content_type
2505
- )
2506
- request = validate_request(
2507
- wire_models.UploadAttachmentRequest,
2508
- {
2509
- "eln": notebook_title,
2510
- "user": notebook.owner_userid if notebook.scope == "shared" else None,
2511
- "uid": upload_event_uid,
2512
- "name": binding.uid,
2513
- "hash": sha256,
2514
- "last": 1,
2515
- "file": b"validation-placeholder",
2516
- },
2517
- "Record attachment upload does not satisfy the Integration Contract.",
2518
- )
2519
- response = await self._transport.request(
2520
- _UPLOAD_RECORD_ATTACHMENT,
2521
- payload=request.model_dump(mode="json", exclude={"file"}, exclude_none=True),
2522
- files={"file": (filename, content, content_type)},
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
- return response
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=b"example",
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]}}
@@ -352,7 +352,7 @@ wheels = [
352
352
 
353
353
  [[package]]
354
354
  name = "matelab-python-sdk"
355
- version = "0.1.0a10"
355
+ version = "0.1.0a11"
356
356
  source = { editable = "." }
357
357
  dependencies = [
358
358
  { name = "httpx2" },