matelab-python-sdk 0.1.0a8__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.0a8 → matelab_python_sdk-0.1.0a10}/.github/workflows/release.yml +2 -0
  2. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/AGENTS.md +2 -2
  3. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/CHANGELOG.md +15 -0
  4. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/PKG-INFO +14 -11
  5. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/README.md +12 -10
  6. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/pyproject.toml +2 -1
  7. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/scripts/check_release.py +6 -1
  8. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/__init__.py +0 -2
  9. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/_transport.py +61 -15
  10. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/records.py +9 -13
  11. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/errors.py +2 -17
  12. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/session.py +3 -5
  13. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_client.py +16 -13
  14. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_cloud_drive.py +2 -2
  15. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_errors.py +13 -11
  16. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_records.py +2 -2
  17. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_release.py +16 -1
  18. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_streaming.py +9 -5
  19. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_transport.py +106 -6
  20. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_users.py +2 -2
  21. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/typing/consumer.py +5 -0
  22. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/uv.lock +3 -1
  23. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/.env.example +0 -0
  24. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/.gitignore +0 -0
  25. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/LICENSE +0 -0
  26. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/NOTICE +0 -0
  27. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/contracts/matelab-integration-v1.lock.json +0 -0
  28. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/contracts/matelab-integration-v1.openapi.yaml +0 -0
  29. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/docs/operation-coverage.yaml +0 -0
  30. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/scripts/check_installed_package.py +0 -0
  31. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/scripts/generate_models.py +0 -0
  32. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/_generated/__init__.py +0 -0
  33. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/_generated/models.py +0 -0
  34. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/_wire_validation.py +0 -0
  35. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/client.py +0 -0
  36. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/__init__.py +0 -0
  37. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/cloud_drive.py +0 -0
  38. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/groups.py +0 -0
  39. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/literature.py +0 -0
  40. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/notebooks.py +0 -0
  41. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/templates.py +0 -0
  42. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/uploads.py +0 -0
  43. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/users.py +0 -0
  44. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/py.typed +0 -0
  45. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/streaming.py +0 -0
  46. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/conftest.py +0 -0
  47. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/provider/test_provider_smoke.py +0 -0
  48. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_comments_and_downloads.py +0 -0
  49. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_consumer_smoke.py +0 -0
  50. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_extended_records.py +0 -0
  51. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_generation.py +0 -0
  52. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_groups.py +0 -0
  53. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_notebook_lifecycle.py +0 -0
  54. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_notebooks.py +0 -0
  55. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_operation_coverage.py +0 -0
  56. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_record_lifecycle.py +0 -0
  57. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_record_mutations.py +0 -0
  58. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_template_lifecycle.py +0 -0
  59. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_templates.py +0 -0
  60. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_uploads_and_literature.py +0 -0
  61. {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/typing/pyproject.toml +0 -0
@@ -23,6 +23,7 @@ jobs:
23
23
  uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
24
24
  with:
25
25
  version: "0.11.32"
26
+ python-version: ${{ matrix.python-version }}
26
27
  - name: Sync locked environment
27
28
  run: uv sync --frozen --python "${{ matrix.python-version }}"
28
29
  - name: Check generated wire models
@@ -53,6 +54,7 @@ jobs:
53
54
  uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
54
55
  with:
55
56
  version: "0.11.32"
57
+ python-version: "3.14"
56
58
  - name: Verify tag and configure timestamp
57
59
  shell: bash
58
60
  run: |
@@ -62,8 +62,8 @@
62
62
  - mutation 默认不自动重试。multipart 或 stream 只有在确认可重放时才能重试;download stream 不自动重放,
63
63
  必须支持显式关闭。
64
64
  - HTTP/transport、Provider business/authentication、Wire Contract/schema 和 client usage 错误必须稳定区分。
65
- - 每个公开 `MatelabError` 必须直接提供稳定的集成级 `category` 和保守的 `retryable`;Provider kind 和 HTTP
66
- status 只用于诊断,下游不得重新解释 Provider 数字 code 才能决定恢复类别。
65
+ - 每个公开 `MatelabError` 必须直接提供稳定的集成级 `category` 和保守的 `retryable`;HTTP status 只用于诊断,
66
+ 下游不得重新解释 Provider 数字 code 才能决定恢复类别。
67
67
  - password、token、SSO code/key、临时附件凭证、原始文件内容和敏感 payload 不得进入日志、异常或提交内容。
68
68
 
69
69
  ## 测试与质量门槛
@@ -1,5 +1,20 @@
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
+
10
+ ## 0.1.0a9 - 2026-08-04
11
+
12
+ - Breaking: removes `MatelabProviderErrorKind` and `MatelabProviderError.kind`; Provider wire codes now map directly
13
+ to the final `MatelabErrorCategory`, which integrations consume through `MatelabError.category`. This public API
14
+ removal requires a subsequent SemVer-appropriate release before distribution. Operation-specific code `0`/`10`
15
+ success handling and the bounded code `5` authentication refresh remain unchanged.
16
+ - Adds a conservative `retryable` flag to every public SDK error.
17
+
3
18
  ## 0.1.0a8 - 2026-08-04
4
19
 
5
20
  - Adds a unified `MatelabErrorCategory` and conservative `retryable` flag to every public SDK error, centralizing
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matelab-python-sdk
3
- Version: 0.1.0a8
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.0a8`. `[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.0a8-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
@@ -93,16 +94,17 @@ Provider details:
93
94
  |---|---|---:|---|
94
95
  | `MatelabAuthenticationError` | `AUTHENTICATION` | No | The Session or credentials cannot authenticate; obtain valid authentication before making a new call. |
95
96
  | `MatelabUsageError` | `VALIDATION` | No | The call cannot be represented safely; correct its arguments. |
96
- | `MatelabProviderError` | `BUSINESS`, `VALIDATION`, or `UPSTREAM` | No | The Provider rejected the request; route by `category`, with `kind` available only for diagnosis. |
97
+ | `MatelabProviderError` | `BUSINESS`, `VALIDATION`, or `UPSTREAM` | No | The Provider rejected the request; route directly by `category`. |
97
98
  | `MatelabTransportError` | `UPSTREAM` | No | The HTTP exchange failed; `status_code` is present for HTTP failures, and a mutation outcome may be unknown. |
98
99
  | `MatelabProtocolError` | `UPSTREAM` | No | The response violates the pinned Contract; treat it as Provider drift or an SDK defect. |
99
100
 
100
- For `MatelabProviderError`, the SDK maps `REJECTED` to `BUSINESS`, `VALIDATION` to `VALIDATION`, and `SERVER` or
101
- `UNKNOWN` to `UPSTREAM`. `MatelabProviderError.kind` remains a `MatelabProviderErrorKind` diagnostic, but integrations
102
- do not need it for recovery routing. Authentication codes are hidden behind `MatelabAuthenticationError`; raw Provider
103
- codes are not part of the public error interface.
101
+ For `MatelabProviderError`, the SDK maps Provider wire code `2` to `BUSINESS`, `4` to `VALIDATION`, and `3` or an
102
+ unknown code to `UPSTREAM`. Authentication codes are hidden behind `MatelabAuthenticationError`; raw Provider codes
103
+ are not part of the public error interface. Code `5` triggers at most one refresh and replay only for authenticated
104
+ operations that explicitly enable `retry_on_access_expired`; otherwise codes `1` and `5` raise
105
+ `MatelabAuthenticationError` directly.
104
106
  Codes `0` and `10` remain operation-specific successes selected by the pinned Contract; an operation that receives a
105
- success code it does not allow raises `MatelabProtocolError` rather than assigning an error kind.
107
+ success code it does not allow raises `MatelabProtocolError` rather than assigning an error category.
106
108
  `MatelabTransportError.status_code` remains available for HTTP failures. Provider response bodies and caller inputs are
107
109
  never attached to exceptions. `retryable` means the identical SDK call is safe to replay without further
108
110
  interpretation. It is conservatively `False` for every current SDK error because the Contract does not guarantee
@@ -627,8 +629,9 @@ object inheritance and preserves constraints the model generator cannot express
627
629
  writing the checked-in models. `WireModel` applies that metadata with the standard
628
630
  `jsonschema` Draft 2020-12 validator; the SDK does not maintain a second hand-written schema interpreter. The current lock resolves
629
631
  `datamodel-code-generator 0.71.0` and
630
- `hatchling 1.31.0`. Published metadata requires `httpx2>=2.9.1,<3`, `jsonschema>=4.26,<5`, and
631
- `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
632
635
  against the complete test suite on the supported Python boundary versions rather than inferred from
633
636
  `uv.lock`. The exact toolchain remains locked for development and release builds. Basedpyright and its
634
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.0a8`. `[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.0a8-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
@@ -66,16 +66,17 @@ 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
- | `MatelabProviderError` | `BUSINESS`, `VALIDATION`, or `UPSTREAM` | No | The Provider rejected the request; route by `category`, with `kind` available only for diagnosis. |
69
+ | `MatelabProviderError` | `BUSINESS`, `VALIDATION`, or `UPSTREAM` | No | The Provider rejected the request; route directly by `category`. |
70
70
  | `MatelabTransportError` | `UPSTREAM` | No | The HTTP exchange failed; `status_code` is present for HTTP failures, and a mutation outcome may be unknown. |
71
71
  | `MatelabProtocolError` | `UPSTREAM` | No | The response violates the pinned Contract; treat it as Provider drift or an SDK defect. |
72
72
 
73
- For `MatelabProviderError`, the SDK maps `REJECTED` to `BUSINESS`, `VALIDATION` to `VALIDATION`, and `SERVER` or
74
- `UNKNOWN` to `UPSTREAM`. `MatelabProviderError.kind` remains a `MatelabProviderErrorKind` diagnostic, but integrations
75
- do not need it for recovery routing. Authentication codes are hidden behind `MatelabAuthenticationError`; raw Provider
76
- codes are not part of the public error interface.
73
+ For `MatelabProviderError`, the SDK maps Provider wire code `2` to `BUSINESS`, `4` to `VALIDATION`, and `3` or an
74
+ unknown code to `UPSTREAM`. Authentication codes are hidden behind `MatelabAuthenticationError`; raw Provider codes
75
+ are not part of the public error interface. Code `5` triggers at most one refresh and replay only for authenticated
76
+ operations that explicitly enable `retry_on_access_expired`; otherwise codes `1` and `5` raise
77
+ `MatelabAuthenticationError` directly.
77
78
  Codes `0` and `10` remain operation-specific successes selected by the pinned Contract; an operation that receives a
78
- success code it does not allow raises `MatelabProtocolError` rather than assigning an error kind.
79
+ success code it does not allow raises `MatelabProtocolError` rather than assigning an error category.
79
80
  `MatelabTransportError.status_code` remains available for HTTP failures. Provider response bodies and caller inputs are
80
81
  never attached to exceptions. `retryable` means the identical SDK call is safe to replay without further
81
82
  interpretation. It is conservatively `False` for every current SDK error because the Contract does not guarantee
@@ -600,8 +601,9 @@ object inheritance and preserves constraints the model generator cannot express
600
601
  writing the checked-in models. `WireModel` applies that metadata with the standard
601
602
  `jsonschema` Draft 2020-12 validator; the SDK does not maintain a second hand-written schema interpreter. The current lock resolves
602
603
  `datamodel-code-generator 0.71.0` and
603
- `hatchling 1.31.0`. Published metadata requires `httpx2>=2.9.1,<3`, `jsonschema>=4.26,<5`, and
604
- `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
605
607
  against the complete test suite on the supported Python boundary versions rather than inferred from
606
608
  `uv.lock`. The exact toolchain remains locked for development and release builds. Basedpyright and its
607
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.0a8"
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']}>"
@@ -118,7 +118,6 @@ from matelab.errors import (
118
118
  MatelabErrorCategory,
119
119
  MatelabProtocolError,
120
120
  MatelabProviderError,
121
- MatelabProviderErrorKind,
122
121
  MatelabTransportError,
123
122
  MatelabUsageError,
124
123
  )
@@ -169,7 +168,6 @@ __all__ = [
169
168
  "MatelabErrorCategory",
170
169
  "MatelabProtocolError",
171
170
  "MatelabProviderError",
172
- "MatelabProviderErrorKind",
173
171
  "MatelabTransportError",
174
172
  "MatelabUsageError",
175
173
  "NotebookCollection",
@@ -1,22 +1,23 @@
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
15
16
  from matelab.errors import (
16
17
  MatelabAuthenticationError,
18
+ MatelabErrorCategory,
17
19
  MatelabProtocolError,
18
20
  MatelabProviderError,
19
- MatelabProviderErrorKind,
20
21
  MatelabTransportError,
21
22
  MatelabUsageError,
22
23
  )
@@ -37,17 +38,32 @@ 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.")
43
59
  if code == 5:
44
60
  return MatelabAuthenticationError("Access token expired.")
45
- kind = {
46
- 2: MatelabProviderErrorKind.REJECTED,
47
- 3: MatelabProviderErrorKind.SERVER,
48
- 4: MatelabProviderErrorKind.VALIDATION,
49
- }.get(code, MatelabProviderErrorKind.UNKNOWN)
50
- return MatelabProviderError(provider_message, kind=kind)
61
+ category = {
62
+ 2: MatelabErrorCategory.BUSINESS,
63
+ 3: MatelabErrorCategory.UPSTREAM,
64
+ 4: MatelabErrorCategory.VALIDATION,
65
+ }.get(code, MatelabErrorCategory.UPSTREAM)
66
+ return MatelabProviderError(provider_message, category=category)
51
67
 
52
68
 
53
69
  class Encoding(StrEnum):
@@ -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), "")
@@ -17,27 +17,12 @@ class MatelabError(Exception):
17
17
  retryable: bool = False
18
18
 
19
19
 
20
- class MatelabProviderErrorKind(StrEnum):
21
- """Stable interpretation of a Provider error envelope."""
22
-
23
- REJECTED = "rejected"
24
- SERVER = "server"
25
- VALIDATION = "validation"
26
- UNKNOWN = "unknown"
27
-
28
-
29
20
  class MatelabProviderError(MatelabError):
30
21
  """The Provider returned a non-authentication error envelope."""
31
22
 
32
- def __init__(self, message: str, *, kind: MatelabProviderErrorKind) -> None:
23
+ def __init__(self, message: str, *, category: MatelabErrorCategory) -> None:
33
24
  super().__init__(message)
34
- self.kind = kind
35
- self.category = {
36
- MatelabProviderErrorKind.REJECTED: MatelabErrorCategory.BUSINESS,
37
- MatelabProviderErrorKind.SERVER: MatelabErrorCategory.UPSTREAM,
38
- MatelabProviderErrorKind.VALIDATION: MatelabErrorCategory.VALIDATION,
39
- MatelabProviderErrorKind.UNKNOWN: MatelabErrorCategory.UPSTREAM,
40
- }[kind]
25
+ self.category = category
41
26
 
42
27
 
43
28
  class MatelabAuthenticationError(MatelabError):
@@ -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
@@ -13,7 +13,6 @@ from matelab import (
13
13
  MatelabErrorCategory,
14
14
  MatelabProtocolError,
15
15
  MatelabProviderError,
16
- MatelabProviderErrorKind,
17
16
  MatelabUsageError,
18
17
  Session,
19
18
  Token,
@@ -133,7 +132,7 @@ async def test_authenticate_does_not_expose_provider_error_body() -> None:
133
132
  rendered = str(caught.value)
134
133
  assert password not in rendered
135
134
  assert rendered == "Matelab request failed."
136
- assert caught.value.kind is MatelabProviderErrorKind.REJECTED
135
+ assert caught.value.category is MatelabErrorCategory.BUSINESS
137
136
  assert not hasattr(caught.value, "code")
138
137
  assert not hasattr(caught.value, "payload")
139
138
 
@@ -227,7 +226,7 @@ async def test_exchange_chat_sso_code_does_not_expose_provider_error_body() -> N
227
226
  assert code not in rendered
228
227
  assert key not in rendered
229
228
  assert rendered == "Matelab request failed."
230
- assert caught.value.kind is MatelabProviderErrorKind.REJECTED
229
+ assert caught.value.category is MatelabErrorCategory.BUSINESS
231
230
  assert not hasattr(caught.value, "payload")
232
231
 
233
232
 
@@ -366,18 +365,18 @@ def test_session_models_reject_invalid_values_and_extra_fields() -> None:
366
365
 
367
366
 
368
367
  @pytest.mark.parametrize(
369
- ("code", "kind", "category"),
368
+ ("code", "category"),
370
369
  [
371
- (2, MatelabProviderErrorKind.REJECTED, MatelabErrorCategory.BUSINESS),
372
- (3, MatelabProviderErrorKind.SERVER, MatelabErrorCategory.UPSTREAM),
373
- (4, MatelabProviderErrorKind.VALIDATION, MatelabErrorCategory.VALIDATION),
374
- (11, MatelabProviderErrorKind.UNKNOWN, MatelabErrorCategory.UPSTREAM),
375
- (99, MatelabProviderErrorKind.UNKNOWN, MatelabErrorCategory.UPSTREAM),
370
+ (2, MatelabErrorCategory.BUSINESS),
371
+ (3, MatelabErrorCategory.UPSTREAM),
372
+ (4, MatelabErrorCategory.VALIDATION),
373
+ (11, MatelabErrorCategory.UPSTREAM),
374
+ (99, MatelabErrorCategory.UPSTREAM),
376
375
  ],
377
376
  )
378
377
  @pytest.mark.asyncio
379
378
  async def test_provider_error_codes_are_hidden_behind_stable_semantics(
380
- code: int, kind: MatelabProviderErrorKind, category: MatelabErrorCategory
379
+ code: int, category: MatelabErrorCategory
381
380
  ) -> None:
382
381
  async def handler(_: Request) -> Response:
383
382
  return Response(200, json={"code": code, "msg": "rejected", "errcode": code, "errmsg": "rejected"})
@@ -388,7 +387,6 @@ async def test_provider_error_codes_are_hidden_behind_stable_semantics(
388
387
  _ = await client.authenticate("alice@example.org", "bad-password")
389
388
 
390
389
  assert isinstance(caught.value, MatelabError)
391
- assert caught.value.kind is kind
392
390
  assert caught.value.category is category
393
391
  assert caught.value.retryable is False
394
392
  assert not hasattr(caught.value, "code")
@@ -422,9 +420,14 @@ async def test_injected_http_client_remains_open_when_sdk_closes() -> None:
422
420
 
423
421
 
424
422
  @pytest.mark.asyncio
425
- 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)
426
430
  client = AsyncMatelab("https://matelab.example/api")
427
- owned_http_client = client._http # pyright: ignore[reportPrivateUsage]
428
431
 
429
432
  await client.aclose()
430
433
 
@@ -12,9 +12,9 @@ from matelab import (
12
12
  CloudFile,
13
13
  CloudFileRef,
14
14
  CloudFolderRef,
15
+ MatelabErrorCategory,
15
16
  MatelabProtocolError,
16
17
  MatelabProviderError,
17
- MatelabProviderErrorKind,
18
18
  MatelabUsageError,
19
19
  Session,
20
20
  StagedFile,
@@ -430,4 +430,4 @@ async def test_download_cloud_file_uses_identity_thumbnail_preview_and_range_str
430
430
  CloudFileRef(cloud_file_id=501), thumbnail=True, preview=False, byte_range=ByteRange.between(1, 3)
431
431
  )
432
432
 
433
- assert caught.value.kind is MatelabProviderErrorKind.REJECTED
433
+ assert caught.value.category is MatelabErrorCategory.BUSINESS
@@ -1,17 +1,22 @@
1
1
  import pytest
2
2
 
3
+ import matelab
3
4
  from matelab import (
4
5
  MatelabAuthenticationError,
5
6
  MatelabError,
6
7
  MatelabErrorCategory,
7
8
  MatelabProtocolError,
8
9
  MatelabProviderError,
9
- MatelabProviderErrorKind,
10
10
  MatelabTransportError,
11
11
  MatelabUsageError,
12
12
  )
13
13
 
14
14
 
15
+ def test_provider_error_kind_is_not_part_of_public_interface() -> None:
16
+ assert "MatelabProviderErrorKind" not in matelab.__all__
17
+ assert not hasattr(matelab, "MatelabProviderErrorKind")
18
+
19
+
15
20
  def test_error_category_values_are_stable_for_integrations() -> None:
16
21
  assert {category.name: category.value for category in MatelabErrorCategory} == {
17
22
  "AUTHENTICATION": "authentication",
@@ -39,22 +44,19 @@ def test_public_errors_expose_integration_category_and_conservative_retryability
39
44
 
40
45
 
41
46
  @pytest.mark.parametrize(
42
- ("kind", "category"),
47
+ "category",
43
48
  [
44
- (MatelabProviderErrorKind.REJECTED, MatelabErrorCategory.BUSINESS),
45
- (MatelabProviderErrorKind.SERVER, MatelabErrorCategory.UPSTREAM),
46
- (MatelabProviderErrorKind.VALIDATION, MatelabErrorCategory.VALIDATION),
47
- (MatelabProviderErrorKind.UNKNOWN, MatelabErrorCategory.UPSTREAM),
49
+ MatelabErrorCategory.BUSINESS,
50
+ MatelabErrorCategory.UPSTREAM,
51
+ MatelabErrorCategory.VALIDATION,
48
52
  ],
49
53
  )
50
- def test_provider_error_kind_is_diagnostic_and_category_is_integration_semantics(
51
- kind: MatelabProviderErrorKind, category: MatelabErrorCategory
52
- ) -> None:
53
- error = MatelabProviderError("failure", kind=kind)
54
+ def test_provider_error_exposes_final_integration_category(category: MatelabErrorCategory) -> None:
55
+ error = MatelabProviderError("failure", category=category)
54
56
 
55
- assert error.kind is kind
56
57
  assert error.category is category
57
58
  assert error.retryable is False
59
+ assert not hasattr(error, "kind")
58
60
 
59
61
 
60
62
  def test_transport_error_preserves_http_status_without_changing_integration_semantics() -> None:
@@ -7,9 +7,9 @@ from httpx2 import AsyncClient, MockTransport, Request, Response
7
7
  from matelab import (
8
8
  AsyncMatelab,
9
9
  Identity,
10
+ MatelabErrorCategory,
10
11
  MatelabProtocolError,
11
12
  MatelabProviderError,
12
- MatelabProviderErrorKind,
13
13
  MatelabUsageError,
14
14
  NotebookRef,
15
15
  RecordLocator,
@@ -184,7 +184,7 @@ async def test_record_domain_classifies_provider_business_errors() -> None:
184
184
  with pytest.raises(MatelabProviderError, match="Matelab request failed") as caught:
185
185
  _ = await client.records.list(notebook=notebook)
186
186
 
187
- assert caught.value.kind is MatelabProviderErrorKind.REJECTED
187
+ assert caught.value.category is MatelabErrorCategory.BUSINESS
188
188
 
189
189
 
190
190
  @pytest.mark.asyncio
@@ -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
 
@@ -101,6 +106,15 @@ def test_release_version_has_one_runtime_source() -> None:
101
106
  assert all("__version__" not in path.read_text() for path in (_ROOT / "src" / "matelab").rglob("*.py"))
102
107
 
103
108
 
109
+ def test_release_workflow_uses_python_specific_uv_cache_keys() -> None:
110
+ workflow = (_ROOT / ".github" / "workflows" / "release.yml").read_text()
111
+ test_job = workflow.partition(" test:")[2].partition("\n build:")[0]
112
+ build_job = workflow.partition(" build:")[2].partition("\n publish:")[0]
113
+
114
+ assert " python-version: ${{ matrix.python-version }}" in test_job
115
+ assert ' python-version: "3.14"' in build_job
116
+
117
+
104
118
  def test_lockfile_mirrors_project_version() -> None:
105
119
  pyproject = tomllib.loads((_ROOT / "pyproject.toml").read_text())
106
120
  project = cast(dict[str, object], pyproject["project"])
@@ -124,6 +138,7 @@ def test_release_dependencies_publish_verified_compatibility_ranges() -> None:
124
138
  "httpx2>=2.9.1,<3",
125
139
  "jsonschema>=4.26,<5",
126
140
  "pydantic>=2.13.4,<3",
141
+ "typing-extensions>=4.14.1,<5",
127
142
  ]
128
143
  assert project["license"] == "Apache-2.0"
129
144
  assert project["license-files"] == ["LICENSE", "NOTICE"]
@@ -2,13 +2,14 @@ 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,
8
9
  MatelabAuthenticationError,
10
+ MatelabErrorCategory,
9
11
  MatelabProtocolError,
10
12
  MatelabProviderError,
11
- MatelabProviderErrorKind,
12
13
  MatelabTransportError,
13
14
  MatelabUsageError,
14
15
  Session,
@@ -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
 
@@ -257,7 +261,7 @@ async def test_stream_classifies_json_error_without_exposing_response_body(provi
257
261
  assert _HASH not in rendered
258
262
  assert access_token not in rendered
259
263
  assert rendered == "Matelab download failed."
260
- assert caught.value.kind is MatelabProviderErrorKind.REJECTED
264
+ assert caught.value.category is MatelabErrorCategory.BUSINESS
261
265
  assert not hasattr(caught.value, "code")
262
266
  assert not hasattr(caught.value, "payload")
263
267
 
@@ -1,19 +1,23 @@
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
12
16
  from matelab.errors import (
13
17
  MatelabAuthenticationError,
18
+ MatelabErrorCategory,
14
19
  MatelabProtocolError,
15
20
  MatelabProviderError,
16
- MatelabProviderErrorKind,
17
21
  MatelabTransportError,
18
22
  MatelabUsageError,
19
23
  )
@@ -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",
@@ -39,8 +73,9 @@ def _session(
39
73
  )
40
74
 
41
75
 
76
+ @pytest.mark.parametrize("code", [0, 10])
42
77
  @pytest.mark.asyncio
43
- async def test_transport_uses_operation_encoding_bearer_and_success_codes() -> None:
78
+ async def test_transport_accepts_each_success_code_only_when_declared_for_operation(code: int) -> None:
44
79
  async def handler(request: Request) -> Response:
45
80
  assert request.method == "POST"
46
81
  assert request.url.path == "/api/eln_api/items"
@@ -48,7 +83,8 @@ async def test_transport_uses_operation_encoding_bearer_and_success_codes() -> N
48
83
  assert request.headers["Content-Type"] == "application/json"
49
84
  assert json.loads(request.content) == {"eln": "Example Notebook"}
50
85
  return Response(
51
- 200, json={"code": 10, "msg": "", "errcode": 10, "errmsg": "", "items": [], "subtypes": [], "trash_num": 0}
86
+ 200,
87
+ json={"code": code, "msg": "", "errcode": code, "errmsg": "", "items": [], "subtypes": [], "trash_num": 0},
52
88
  )
53
89
 
54
90
  operation = Operation(
@@ -56,7 +92,7 @@ async def test_transport_uses_operation_encoding_bearer_and_success_codes() -> N
56
92
  path="/eln_api/items",
57
93
  encoding=Encoding.JSON,
58
94
  response_model=RecordListResponse,
59
- success_codes=frozenset({0, 10}),
95
+ success_codes=frozenset({code}),
60
96
  )
61
97
  async with AsyncClient(base_url="https://matelab.example/api", transport=MockTransport(handler)) as http_client:
62
98
  transport = SessionTransport(http_client=http_client, session=_session())
@@ -104,7 +140,7 @@ async def test_transport_classifies_provider_business_errors() -> None:
104
140
  with pytest.raises(MatelabProviderError, match="Matelab request failed") as caught:
105
141
  _ = await transport.request(operation, payload={"eln": ""})
106
142
 
107
- assert caught.value.kind is MatelabProviderErrorKind.REJECTED
143
+ assert caught.value.category is MatelabErrorCategory.BUSINESS
108
144
  assert not hasattr(caught.value, "code")
109
145
  assert not hasattr(caught.value, "payload")
110
146
 
@@ -308,6 +344,70 @@ async def test_transport_encodes_query_form_and_multipart_operations() -> None:
308
344
  assert seen_paths == ["/api/query", "/api/form", "/api/multipart", "/api/multipart-fields"]
309
345
 
310
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
+
311
411
  @pytest.mark.asyncio
312
412
  async def test_transport_refreshes_access_inside_expiration_skew_before_request() -> None:
313
413
  paths: list[str] = []
@@ -5,9 +5,9 @@ from httpx2 import AsyncClient, MockTransport, Request, Response
5
5
 
6
6
  from matelab import (
7
7
  AsyncMatelab,
8
+ MatelabErrorCategory,
8
9
  MatelabProtocolError,
9
10
  MatelabProviderError,
10
- MatelabProviderErrorKind,
11
11
  MatelabTransportError,
12
12
  MatelabUsageError,
13
13
  Session,
@@ -112,7 +112,7 @@ async def test_search_users_classifies_provider_business_errors(provider_session
112
112
  with pytest.raises(MatelabProviderError, match="Matelab request failed") as caught:
113
113
  _ = await client.users.search("Example")
114
114
 
115
- assert caught.value.kind is MatelabProviderErrorKind.REJECTED
115
+ assert caught.value.category is MatelabErrorCategory.BUSINESS
116
116
 
117
117
 
118
118
  @pytest.mark.asyncio
@@ -2,6 +2,7 @@ from matelab import (
2
2
  AsyncMatelab,
3
3
  MatelabError,
4
4
  MatelabErrorCategory,
5
+ MatelabProviderError,
5
6
  NotebookCollection,
6
7
  NotebookRef,
7
8
  RecordAttachmentChange,
@@ -29,6 +30,10 @@ def consume_error(error: MatelabError) -> tuple[MatelabErrorCategory, bool]:
29
30
  return category, retryable
30
31
 
31
32
 
33
+ def construct_provider_error(category: MatelabErrorCategory) -> MatelabProviderError:
34
+ return MatelabProviderError("failure", category=category)
35
+
36
+
32
37
  async def consume_public_types(client: AsyncMatelab) -> tuple[Session | None, NotebookCollection]:
33
38
  notebooks: NotebookCollection = await client.notebooks.list()
34
39
  session: Session | None = client.session
@@ -352,12 +352,13 @@ wheels = [
352
352
 
353
353
  [[package]]
354
354
  name = "matelab-python-sdk"
355
- version = "0.1.0a8"
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]