matelab-python-sdk 0.1.0a9__tar.gz → 0.1.0a10__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 (61) hide show
  1. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/CHANGELOG.md +7 -0
  2. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/PKG-INFO +7 -5
  3. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/README.md +5 -4
  4. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/pyproject.toml +2 -1
  5. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/scripts/check_release.py +6 -1
  6. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/_transport.py +54 -8
  7. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/records.py +9 -13
  8. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/session.py +3 -5
  9. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_client.py +7 -2
  10. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_release.py +7 -1
  11. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_streaming.py +7 -3
  12. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_transport.py +99 -1
  13. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/uv.lock +3 -1
  14. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/.env.example +0 -0
  15. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/.github/workflows/release.yml +0 -0
  16. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/.gitignore +0 -0
  17. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/AGENTS.md +0 -0
  18. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/LICENSE +0 -0
  19. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/NOTICE +0 -0
  20. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/contracts/matelab-integration-v1.lock.json +0 -0
  21. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/contracts/matelab-integration-v1.openapi.yaml +0 -0
  22. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/docs/operation-coverage.yaml +0 -0
  23. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/scripts/check_installed_package.py +0 -0
  24. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/scripts/generate_models.py +0 -0
  25. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/__init__.py +0 -0
  26. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/_generated/__init__.py +0 -0
  27. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/_generated/models.py +0 -0
  28. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/_wire_validation.py +0 -0
  29. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/client.py +0 -0
  30. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/__init__.py +0 -0
  31. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/cloud_drive.py +0 -0
  32. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/groups.py +0 -0
  33. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/literature.py +0 -0
  34. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/notebooks.py +0 -0
  35. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/templates.py +0 -0
  36. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/uploads.py +0 -0
  37. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/users.py +0 -0
  38. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/errors.py +0 -0
  39. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/py.typed +0 -0
  40. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/streaming.py +0 -0
  41. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/conftest.py +0 -0
  42. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/provider/test_provider_smoke.py +0 -0
  43. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_cloud_drive.py +0 -0
  44. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_comments_and_downloads.py +0 -0
  45. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_consumer_smoke.py +0 -0
  46. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_errors.py +0 -0
  47. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_extended_records.py +0 -0
  48. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_generation.py +0 -0
  49. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_groups.py +0 -0
  50. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_notebook_lifecycle.py +0 -0
  51. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_notebooks.py +0 -0
  52. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_operation_coverage.py +0 -0
  53. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_record_lifecycle.py +0 -0
  54. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_record_mutations.py +0 -0
  55. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_records.py +0 -0
  56. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_template_lifecycle.py +0 -0
  57. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_templates.py +0 -0
  58. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_uploads_and_literature.py +0 -0
  59. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_users.py +0 -0
  60. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/typing/consumer.py +0 -0
  61. {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/typing/pyproject.toml +0 -0
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0a10 - 2026-08-05
4
+
5
+ - Keeps synchronous multipart file length probes and chunk reads off the event-loop thread while preserving streamed
6
+ uploads, correct content length, the bytes fast path, and existing non-replay retry restrictions.
7
+ - Replaces implicit-override suppressions with explicit `@override` declarations, simplifies inferred model attributes,
8
+ and declares the Python 3.11 `typing-extensions` runtime dependency directly.
9
+
3
10
  ## 0.1.0a9 - 2026-08-04
4
11
 
5
12
  - Breaking: removes `MatelabProviderErrorKind` and `MatelabProviderError.kind`; Provider wire codes now map directly
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matelab-python-sdk
3
- Version: 0.1.0a9
3
+ Version: 0.1.0a10
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
@@ -23,13 +23,14 @@ Requires-Python: >=3.11
23
23
  Requires-Dist: httpx2<3,>=2.9.1
24
24
  Requires-Dist: jsonschema<5,>=4.26
25
25
  Requires-Dist: pydantic<3,>=2.13.4
26
+ Requires-Dist: typing-extensions<5,>=4.14.1
26
27
  Description-Content-Type: text/markdown
27
28
 
28
29
  # matelab-python-sdk
29
30
 
30
31
  Reusable async Python client for the Matelab Integration Contract.
31
32
 
32
- The current alpha is `0.1.0a9`. `[project].version` in `pyproject.toml` is the sole SDK version source;
33
+ The current alpha is `0.1.0a10`. `[project].version` in `pyproject.toml` is the sole SDK version source;
33
34
  `uv.lock` only mirrors that source.
34
35
 
35
36
  The SDK is pinned to the immutable `matelab-spec v0.3.0` Contract Release. The sole release pin is
@@ -58,7 +59,7 @@ To test the same artifact a downstream Consumer will install, build and install
58
59
 
59
60
  ```bash
60
61
  uv build --no-build-isolation --out-dir dist/release
61
- python -m pip install dist/release/matelab_python_sdk-0.1.0a9-py3-none-any.whl
62
+ python -m pip install dist/release/matelab_python_sdk-0.1.0a10-py3-none-any.whl
62
63
  ```
63
64
 
64
65
  Do not infer Provider compatibility from the SDK version alone. A release is also bound to the Contract
@@ -628,8 +629,9 @@ object inheritance and preserves constraints the model generator cannot express
628
629
  writing the checked-in models. `WireModel` applies that metadata with the standard
629
630
  `jsonschema` Draft 2020-12 validator; the SDK does not maintain a second hand-written schema interpreter. The current lock resolves
630
631
  `datamodel-code-generator 0.71.0` and
631
- `hatchling 1.31.0`. Published metadata requires `httpx2>=2.9.1,<3`, `jsonschema>=4.26,<5`, and
632
- `pydantic>=2.13.4,<3`; the build backend requires `hatchling>=1.27,<2`. These lower bounds are verified
632
+ `hatchling 1.31.0`. Published metadata requires `httpx2>=2.9.1,<3`, `jsonschema>=4.26,<5`,
633
+ `pydantic>=2.13.4,<3`, and `typing-extensions>=4.14.1,<5`; the build backend requires
634
+ `hatchling>=1.27,<2`. These lower bounds are verified
633
635
  against the complete test suite on the supported Python boundary versions rather than inferred from
634
636
  `uv.lock`. The exact toolchain remains locked for development and release builds. Basedpyright and its
635
637
  Node wheel retain the compatible exact pair `basedpyright==1.39.9` and
@@ -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.0a9`. `[project].version` in `pyproject.toml` is the sole SDK version source;
5
+ The current alpha is `0.1.0a10`. `[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.0a9-py3-none-any.whl
34
+ python -m pip install dist/release/matelab_python_sdk-0.1.0a10-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
@@ -601,8 +601,9 @@ object inheritance and preserves constraints the model generator cannot express
601
601
  writing the checked-in models. `WireModel` applies that metadata with the standard
602
602
  `jsonschema` Draft 2020-12 validator; the SDK does not maintain a second hand-written schema interpreter. The current lock resolves
603
603
  `datamodel-code-generator 0.71.0` and
604
- `hatchling 1.31.0`. Published metadata requires `httpx2>=2.9.1,<3`, `jsonschema>=4.26,<5`, and
605
- `pydantic>=2.13.4,<3`; the build backend requires `hatchling>=1.27,<2`. These lower bounds are verified
604
+ `hatchling 1.31.0`. Published metadata requires `httpx2>=2.9.1,<3`, `jsonschema>=4.26,<5`,
605
+ `pydantic>=2.13.4,<3`, and `typing-extensions>=4.14.1,<5`; the build backend requires
606
+ `hatchling>=1.27,<2`. These lower bounds are verified
606
607
  against the complete test suite on the supported Python boundary versions rather than inferred from
607
608
  `uv.lock`. The exact toolchain remains locked for development and release builds. Basedpyright and its
608
609
  Node wheel retain the compatible exact pair `basedpyright==1.39.9` and
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "matelab-python-sdk"
7
- version = "0.1.0a9"
7
+ version = "0.1.0a10"
8
8
  description = "Reusable async Python client for the Matelab Integration Contract"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -30,6 +30,7 @@ dependencies = [
30
30
  "httpx2>=2.9.1,<3",
31
31
  "jsonschema>=4.26,<5",
32
32
  "pydantic>=2.13.4,<3",
33
+ "typing-extensions>=4.14.1,<5",
33
34
  ]
34
35
 
35
36
  [dependency-groups]
@@ -16,7 +16,12 @@ _PROJECT = cast(dict[str, object], tomllib.loads((_ROOT / "pyproject.toml").read
16
16
  _PROJECT_NAME = cast(str, _PROJECT["name"])
17
17
  _VERSION = cast(str, _PROJECT["version"])
18
18
  _DIST_INFO = f"{_PROJECT_NAME.replace('-', '_')}-{_VERSION}.dist-info"
19
- _EXPECTED_REQUIRES_DIST = {"httpx2<3,>=2.9.1", "jsonschema<5,>=4.26", "pydantic<3,>=2.13.4"}
19
+ _EXPECTED_REQUIRES_DIST = {
20
+ "httpx2<3,>=2.9.1",
21
+ "jsonschema<5,>=4.26",
22
+ "pydantic<3,>=2.13.4",
23
+ "typing-extensions<5,>=4.14.1",
24
+ }
20
25
  _EXPECTED_LICENSE_FILES = set(cast(list[str], _PROJECT["license-files"]))
21
26
  _AUTHOR = cast(list[dict[str, str]], _PROJECT["authors"])[0]
22
27
  _EXPECTED_AUTHOR_EMAIL = f"{_AUTHOR['name']} <{_AUTHOR['email']}>"
@@ -1,14 +1,15 @@
1
1
  import asyncio
2
2
  import re
3
3
  import time
4
- from collections.abc import Mapping
4
+ from collections.abc import AsyncIterator, Mapping
5
5
  from contextlib import suppress
6
6
  from dataclasses import dataclass
7
7
  from enum import StrEnum
8
8
  from typing import IO, Generic, TypeAlias, TypeVar, cast
9
9
 
10
- from httpx2 import AsyncClient, RequestError, Response
10
+ from httpx2 import AsyncByteStream, AsyncClient, Request, RequestError, Response, SyncByteStream
11
11
  from pydantic import BaseModel, JsonValue, ValidationError
12
+ from typing_extensions import override
12
13
 
13
14
  from matelab._generated import models as wire_models
14
15
  from matelab._wire_validation import validate_request
@@ -37,6 +38,21 @@ def _payload_mapping(payload: _Payload) -> Mapping[str, JsonValue]:
37
38
  return payload
38
39
 
39
40
 
41
+ class _ThreadedMultipartStream(AsyncByteStream):
42
+ def __init__(self, stream: SyncByteStream) -> None:
43
+ self._stream = stream
44
+
45
+ @override
46
+ async def __aiter__(self) -> AsyncIterator[bytes]:
47
+ iterator = iter(self._stream)
48
+ while (chunk := await asyncio.to_thread(next, iterator, None)) is not None:
49
+ yield chunk
50
+
51
+ @override
52
+ async def aclose(self) -> None:
53
+ await asyncio.to_thread(self._stream.close)
54
+
55
+
40
56
  def _provider_error(code: int, *, provider_message: str) -> MatelabAuthenticationError | MatelabProviderError:
41
57
  if code == 1:
42
58
  return MatelabAuthenticationError("Matelab authentication failed.")
@@ -138,6 +154,37 @@ class SessionTransport:
138
154
  )
139
155
  return self.session
140
156
 
157
+ async def _send_multipart(
158
+ self,
159
+ operation: Operation[WireResponse],
160
+ *,
161
+ headers: dict[str, str],
162
+ payload: Mapping[str, JsonValue] | None,
163
+ files: _Files | None,
164
+ ) -> Response:
165
+ multipart = {
166
+ **{key: (None, str(value)) for key, value in (payload or {}).items()},
167
+ **(files or {}),
168
+ }
169
+ if files is None or all(isinstance(content, bytes) for _, content, _ in files.values()):
170
+ return await self._http.request(operation.method, operation.path, headers=headers, files=multipart)
171
+
172
+ base_request = self._http.build_request(operation.method, operation.path, headers=headers)
173
+ multipart_headers = base_request.headers.copy()
174
+ _ = multipart_headers.pop("Content-Length", None)
175
+ _ = multipart_headers.pop("Transfer-Encoding", None)
176
+ request = await asyncio.to_thread(
177
+ Request,
178
+ base_request.method,
179
+ base_request.url,
180
+ headers=multipart_headers,
181
+ files=multipart,
182
+ extensions=base_request.extensions,
183
+ )
184
+ if isinstance(request.stream, SyncByteStream):
185
+ request.stream = _ThreadedMultipartStream(request.stream)
186
+ return await self._http.send(request)
187
+
141
188
  async def request(
142
189
  self, operation: Operation[WireResponse], *, payload: _Payload | None = None, files: _Files | None = None
143
190
  ) -> WireResponse:
@@ -169,12 +216,11 @@ class SessionTransport:
169
216
  operation.method, operation.path, headers=headers, data=encoded_payload
170
217
  )
171
218
  elif operation.encoding is Encoding.MULTIPART:
172
- multipart = {
173
- **{key: (None, str(value)) for key, value in (encoded_payload or {}).items()},
174
- **(files or {}),
175
- }
176
- response = await self._http.request(
177
- operation.method, operation.path, headers=headers, files=multipart
219
+ response = await self._send_multipart(
220
+ operation,
221
+ headers=headers,
222
+ payload=encoded_payload,
223
+ files=files,
178
224
  )
179
225
  else:
180
226
  response = await self._http.request(operation.method, operation.path, headers=headers)
@@ -9,6 +9,7 @@ from typing import IO, Literal, TypeAlias, cast
9
9
  from urllib.parse import parse_qs, unquote
10
10
 
11
11
  from pydantic import JsonValue
12
+ from typing_extensions import override
12
13
 
13
14
  from matelab._generated import models as wire_models
14
15
  from matelab._transport import Encoding, Operation, SessionTransport, StreamOperation
@@ -713,9 +714,8 @@ class _RichTextAttachmentParser(HTMLParser):
713
714
  super().__init__(convert_charrefs=True)
714
715
  self.attachments: list[_ParsedAttachment] = []
715
716
 
716
- def handle_starttag( # pyright: ignore[reportImplicitOverride]
717
- self, tag: str, attrs: list[tuple[str, str | None]]
718
- ) -> None:
717
+ @override
718
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
719
719
  target_attribute = "src" if tag.casefold() == "img" else "href" if tag.casefold() == "a" else None
720
720
  if target_attribute is None:
721
721
  return
@@ -2595,26 +2595,22 @@ class Records:
2595
2595
  wire_html = html
2596
2596
  canonical_html = html
2597
2597
  for name, attachment in selected_attachments.items():
2598
+ temporary_url = attachment._temporary_url # pyright: ignore[reportPrivateUsage]
2599
+ canonical_reference = attachment._canonical_reference # pyright: ignore[reportPrivateUsage]
2598
2600
  if (
2599
2601
  attachment.source != source
2600
2602
  or attachment.temporary_file_id < 1
2601
2603
  or not attachment.binding.uid
2602
2604
  or len(attachment.binding.uid) > 45
2603
- or not attachment._temporary_url # pyright: ignore[reportPrivateUsage]
2604
- or not attachment._canonical_reference # pyright: ignore[reportPrivateUsage]
2605
+ or not temporary_url
2606
+ or not canonical_reference
2605
2607
  or attachment.size < 0
2606
2608
  or re.fullmatch(r"[0-9a-f]{64}", attachment.sha256) is None
2607
2609
  ):
2608
2610
  raise MatelabUsageError("Staged comment attachment belongs to a different record.")
2609
2611
  marker = f"matelab-attachment:{name}"
2610
- wire_html = wire_html.replace(
2611
- marker,
2612
- attachment._temporary_url, # pyright: ignore[reportPrivateUsage]
2613
- )
2614
- canonical_html = canonical_html.replace(
2615
- marker,
2616
- attachment._canonical_reference, # pyright: ignore[reportPrivateUsage]
2617
- )
2612
+ wire_html = wire_html.replace(marker, temporary_url)
2613
+ canonical_html = canonical_html.replace(marker, canonical_reference)
2618
2614
  if _ATTACHMENT_MARKER_PATTERN.search(wire_html):
2619
2615
  raise MatelabUsageError("Comment content contains an unresolved attachment marker.")
2620
2616
  return wire_html.strip(), canonical_html.strip(), next(iter(binding_uids), "")
@@ -1,12 +1,10 @@
1
- from typing import ClassVar
2
-
3
1
  from pydantic import BaseModel, ConfigDict, Field
4
2
 
5
3
 
6
4
  class Token(BaseModel):
7
5
  """Immutable Provider token with a persistable value hidden only from representations."""
8
6
 
9
- model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True, extra="forbid")
7
+ model_config = ConfigDict(frozen=True, extra="forbid")
10
8
 
11
9
  value: str = Field(min_length=1, repr=False)
12
10
  expires_at_ms: int = Field(gt=0)
@@ -15,7 +13,7 @@ class Token(BaseModel):
15
13
  class Identity(BaseModel):
16
14
  """Immutable identity associated with a validated Matelab Session."""
17
15
 
18
- model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True, extra="forbid")
16
+ model_config = ConfigDict(frozen=True, extra="forbid")
19
17
 
20
18
  userid: int = Field(gt=0)
21
19
  username: str = Field(min_length=1)
@@ -25,7 +23,7 @@ class Identity(BaseModel):
25
23
  class Session(BaseModel):
26
24
  """Stable normalized snapshot of one client's Matelab authentication state."""
27
25
 
28
- model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True, extra="forbid")
26
+ model_config = ConfigDict(frozen=True, extra="forbid")
29
27
 
30
28
  access: Token
31
29
  refresh: Token
@@ -420,9 +420,14 @@ async def test_injected_http_client_remains_open_when_sdk_closes() -> None:
420
420
 
421
421
 
422
422
  @pytest.mark.asyncio
423
- async def test_sdk_owned_http_client_closes_with_sdk() -> None:
423
+ async def test_sdk_owned_http_client_closes_with_sdk(monkeypatch: pytest.MonkeyPatch) -> None:
424
+ owned_http_client = AsyncClient(base_url="https://matelab.example/api")
425
+
426
+ def build_http_client(**_: object) -> AsyncClient:
427
+ return owned_http_client
428
+
429
+ monkeypatch.setattr("matelab.client.AsyncClient", build_http_client)
424
430
  client = AsyncMatelab("https://matelab.example/api")
425
- owned_http_client = client._http # pyright: ignore[reportPrivateUsage]
426
431
 
427
432
  await client.aclose()
428
433
 
@@ -21,7 +21,12 @@ _ROOT = Path(__file__).resolve().parents[1]
21
21
  _PYPROJECT = tomllib.loads((_ROOT / "pyproject.toml").read_text())
22
22
  _PROJECT = cast(dict[str, object], _PYPROJECT["project"])
23
23
  _RELEASE_VERSION = cast(str, _PROJECT["version"])
24
- _EXPECTED_REQUIRES_DIST = {"httpx2<3,>=2.9.1", "jsonschema<5,>=4.26", "pydantic<3,>=2.13.4"}
24
+ _EXPECTED_REQUIRES_DIST = {
25
+ "httpx2<3,>=2.9.1",
26
+ "jsonschema<5,>=4.26",
27
+ "pydantic<3,>=2.13.4",
28
+ "typing-extensions<5,>=4.14.1",
29
+ }
25
30
  _EXPECTED_LICENSE_FILES = {"LICENSE", "NOTICE"}
26
31
 
27
32
 
@@ -133,6 +138,7 @@ def test_release_dependencies_publish_verified_compatibility_ranges() -> None:
133
138
  "httpx2>=2.9.1,<3",
134
139
  "jsonschema>=4.26,<5",
135
140
  "pydantic>=2.13.4,<3",
141
+ "typing-extensions>=4.14.1,<5",
136
142
  ]
137
143
  assert project["license"] == "Apache-2.0"
138
144
  assert project["license-files"] == ["LICENSE", "NOTICE"]
@@ -2,6 +2,7 @@ from collections.abc import AsyncIterator, Callable
2
2
 
3
3
  import pytest
4
4
  from httpx2 import AsyncByteStream, AsyncClient, MockTransport, ReadError, Request, Response
5
+ from typing_extensions import override
5
6
 
6
7
  from matelab import (
7
8
  ByteRange,
@@ -25,15 +26,18 @@ class _TrackingByteStream(AsyncByteStream):
25
26
  self._content = content
26
27
  self.closed = False
27
28
 
28
- async def __aiter__(self) -> AsyncIterator[bytes]: # pyright: ignore[reportImplicitOverride]
29
+ @override
30
+ async def __aiter__(self) -> AsyncIterator[bytes]:
29
31
  yield self._content
30
32
 
31
- async def aclose(self) -> None: # pyright: ignore[reportImplicitOverride]
33
+ @override
34
+ async def aclose(self) -> None:
32
35
  self.closed = True
33
36
 
34
37
 
35
38
  class _FailingByteStream(AsyncByteStream):
36
- async def __aiter__(self) -> AsyncIterator[bytes]: # pyright: ignore[reportImplicitOverride]
39
+ @override
40
+ async def __aiter__(self) -> AsyncIterator[bytes]:
37
41
  yield b"partial"
38
42
  raise ReadError("private transport details")
39
43
 
@@ -1,11 +1,15 @@
1
1
  import asyncio
2
2
  import json
3
+ import threading
3
4
  import time
4
5
  from collections.abc import AsyncIterator
5
6
  from io import BytesIO
7
+ from pathlib import Path
8
+ from typing import Literal
6
9
 
7
10
  import pytest
8
11
  from httpx2 import AsyncByteStream, AsyncClient, MockTransport, Request, Response
12
+ from typing_extensions import override
9
13
 
10
14
  from matelab._generated.models import BasicSuccessResponse, RecordListResponse, TokenRefreshResponse
11
15
  from matelab._transport import Encoding, Operation, SessionTransport
@@ -21,10 +25,40 @@ from matelab.session import Identity, Session, Token
21
25
 
22
26
 
23
27
  class _InvalidGzipStream(AsyncByteStream):
24
- async def __aiter__(self) -> AsyncIterator[bytes]: # pyright: ignore[reportImplicitOverride]
28
+ @override
29
+ async def __aiter__(self) -> AsyncIterator[bytes]:
25
30
  yield b"not-gzip"
26
31
 
27
32
 
33
+ class _LoopSensitiveFile(BytesIO):
34
+ def __init__(self, content: bytes, blocked_operation: Literal["read", "seek"]) -> None:
35
+ super().__init__(content)
36
+ self.blocked_operation = blocked_operation
37
+ self.operation_started = threading.Event()
38
+ self.event_loop_progress = threading.Event()
39
+ self.event_loop_progressed_while_blocked = False
40
+ self._blocked_once = False
41
+
42
+ def _block_once(self) -> None:
43
+ if self._blocked_once:
44
+ return
45
+ self._blocked_once = True
46
+ self.operation_started.set()
47
+ self.event_loop_progressed_while_blocked = self.event_loop_progress.wait(timeout=0.5)
48
+
49
+ @override
50
+ def read(self, size: int | None = -1, /) -> bytes:
51
+ if self.blocked_operation == "read":
52
+ self._block_once()
53
+ return super().read(size)
54
+
55
+ @override
56
+ def seek(self, pos: int, whence: int = 0, /) -> int:
57
+ if self.blocked_operation == "seek":
58
+ self._block_once()
59
+ return super().seek(pos, whence)
60
+
61
+
28
62
  def _session(
29
63
  *,
30
64
  access: str = "access",
@@ -310,6 +344,70 @@ async def test_transport_encodes_query_form_and_multipart_operations() -> None:
310
344
  assert seen_paths == ["/api/query", "/api/form", "/api/multipart", "/api/multipart-fields"]
311
345
 
312
346
 
347
+ @pytest.mark.parametrize("blocked_operation", ["read", "seek"])
348
+ @pytest.mark.asyncio
349
+ async def test_transport_keeps_event_loop_responsive_while_reading_multipart_file(
350
+ blocked_operation: Literal["read", "seek"],
351
+ ) -> None:
352
+ content = _LoopSensitiveFile(b"contents", blocked_operation)
353
+
354
+ async def observe_blocked_file_operation() -> None:
355
+ while not content.operation_started.is_set():
356
+ await asyncio.sleep(0)
357
+ content.event_loop_progress.set()
358
+
359
+ async def handler(request: Request) -> Response:
360
+ assert b'filename="example.txt"' in request.content
361
+ assert b"\r\ncontents\r\n" in request.content
362
+ return Response(200, json={"code": 0, "msg": "", "errcode": 0, "errmsg": ""})
363
+
364
+ operation = Operation(
365
+ method="POST",
366
+ path="/multipart",
367
+ encoding=Encoding.MULTIPART,
368
+ response_model=BasicSuccessResponse,
369
+ success_codes=frozenset({0}),
370
+ )
371
+ observer = asyncio.create_task(observe_blocked_file_operation())
372
+ async with AsyncClient(base_url="https://matelab.example/api", transport=MockTransport(handler)) as http_client:
373
+ transport = SessionTransport(http_client=http_client, session=_session())
374
+ _ = await transport.request(
375
+ operation,
376
+ files={"file": ("example.txt", content, "text/plain")},
377
+ )
378
+ await observer
379
+
380
+ assert content.event_loop_progressed_while_blocked
381
+
382
+
383
+ @pytest.mark.asyncio
384
+ async def test_transport_uploads_regular_binary_file(tmp_path: Path) -> None:
385
+ path = tmp_path / "example.txt"
386
+ _ = path.write_bytes(b"contents")
387
+
388
+ async def handler(request: Request) -> Response:
389
+ assert request.headers["Content-Type"].startswith("multipart/form-data; boundary=")
390
+ assert int(request.headers["Content-Length"]) == len(request.content)
391
+ assert b'filename="example.txt"' in request.content
392
+ assert b"\r\ncontents\r\n" in request.content
393
+ return Response(200, json={"code": 0, "msg": "", "errcode": 0, "errmsg": ""})
394
+
395
+ operation = Operation(
396
+ method="POST",
397
+ path="/multipart",
398
+ encoding=Encoding.MULTIPART,
399
+ response_model=BasicSuccessResponse,
400
+ success_codes=frozenset({0}),
401
+ )
402
+ with path.open("rb") as content:
403
+ async with AsyncClient(base_url="https://matelab.example/api", transport=MockTransport(handler)) as http_client:
404
+ transport = SessionTransport(http_client=http_client, session=_session())
405
+ _ = await transport.request(
406
+ operation,
407
+ files={"file": (path.name, content, "text/plain")},
408
+ )
409
+
410
+
313
411
  @pytest.mark.asyncio
314
412
  async def test_transport_refreshes_access_inside_expiration_skew_before_request() -> None:
315
413
  paths: list[str] = []
@@ -352,12 +352,13 @@ wheels = [
352
352
 
353
353
  [[package]]
354
354
  name = "matelab-python-sdk"
355
- version = "0.1.0a9"
355
+ version = "0.1.0a10"
356
356
  source = { editable = "." }
357
357
  dependencies = [
358
358
  { name = "httpx2" },
359
359
  { name = "jsonschema" },
360
360
  { name = "pydantic" },
361
+ { name = "typing-extensions" },
361
362
  ]
362
363
 
363
364
  [package.dev-dependencies]
@@ -377,6 +378,7 @@ requires-dist = [
377
378
  { name = "httpx2", specifier = ">=2.9.1,<3" },
378
379
  { name = "jsonschema", specifier = ">=4.26,<5" },
379
380
  { name = "pydantic", specifier = ">=2.13.4,<3" },
381
+ { name = "typing-extensions", specifier = ">=4.14.1,<5" },
380
382
  ]
381
383
 
382
384
  [package.metadata.requires-dev]