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.
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/.github/workflows/release.yml +2 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/AGENTS.md +2 -2
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/CHANGELOG.md +15 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/PKG-INFO +14 -11
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/README.md +12 -10
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/pyproject.toml +2 -1
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/scripts/check_release.py +6 -1
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/__init__.py +0 -2
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/_transport.py +61 -15
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/records.py +9 -13
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/errors.py +2 -17
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/session.py +3 -5
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_client.py +16 -13
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_cloud_drive.py +2 -2
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_errors.py +13 -11
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_records.py +2 -2
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_release.py +16 -1
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_streaming.py +9 -5
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_transport.py +106 -6
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_users.py +2 -2
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/typing/consumer.py +5 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/uv.lock +3 -1
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/.env.example +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/.gitignore +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/LICENSE +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/NOTICE +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/contracts/matelab-integration-v1.lock.json +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/contracts/matelab-integration-v1.openapi.yaml +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/docs/operation-coverage.yaml +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/scripts/check_installed_package.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/scripts/generate_models.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/_generated/__init__.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/_generated/models.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/_wire_validation.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/client.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/__init__.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/cloud_drive.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/groups.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/literature.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/notebooks.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/templates.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/uploads.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/users.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/py.typed +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/streaming.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/conftest.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/provider/test_provider_smoke.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_comments_and_downloads.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_consumer_smoke.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_extended_records.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_generation.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_groups.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_notebook_lifecycle.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_notebooks.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_operation_coverage.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_record_lifecycle.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_record_mutations.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_template_lifecycle.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_templates.py +0 -0
- {matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_uploads_and_literature.py +0 -0
- {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`;
|
|
66
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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 `
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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`,
|
|
631
|
-
`pydantic>=2.13.4,<3
|
|
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.
|
|
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.
|
|
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
|
|
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 `
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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`,
|
|
604
|
-
`pydantic>=2.13.4,<3
|
|
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.
|
|
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 = {
|
|
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
|
-
|
|
46
|
-
2:
|
|
47
|
-
3:
|
|
48
|
-
4:
|
|
49
|
-
}.get(code,
|
|
50
|
-
return MatelabProviderError(provider_message,
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
717
|
-
|
|
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
|
|
2604
|
-
or not
|
|
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
|
-
|
|
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, *,
|
|
23
|
+
def __init__(self, message: str, *, category: MatelabErrorCategory) -> None:
|
|
33
24
|
super().__init__(message)
|
|
34
|
-
self.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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", "
|
|
368
|
+
("code", "category"),
|
|
370
369
|
[
|
|
371
|
-
(2,
|
|
372
|
-
(3,
|
|
373
|
-
(4,
|
|
374
|
-
(11,
|
|
375
|
-
(99,
|
|
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,
|
|
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.
|
|
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
|
-
|
|
47
|
+
"category",
|
|
43
48
|
[
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
(MatelabProviderErrorKind.UNKNOWN, MatelabErrorCategory.UPSTREAM),
|
|
49
|
+
MatelabErrorCategory.BUSINESS,
|
|
50
|
+
MatelabErrorCategory.UPSTREAM,
|
|
51
|
+
MatelabErrorCategory.VALIDATION,
|
|
48
52
|
],
|
|
49
53
|
)
|
|
50
|
-
def
|
|
51
|
-
|
|
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.
|
|
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 = {
|
|
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
|
-
|
|
29
|
+
@override
|
|
30
|
+
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
29
31
|
yield self._content
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
@override
|
|
34
|
+
async def aclose(self) -> None:
|
|
32
35
|
self.closed = True
|
|
33
36
|
|
|
34
37
|
|
|
35
38
|
class _FailingByteStream(AsyncByteStream):
|
|
36
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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,
|
|
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({
|
|
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.
|
|
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.
|
|
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.
|
|
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]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/scripts/check_installed_package.py
RENAMED
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/_generated/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/cloud_drive.py
RENAMED
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/literature.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/provider/test_provider_smoke.py
RENAMED
|
File without changes
|
{matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_comments_and_downloads.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.1.0a8 → matelab_python_sdk-0.1.0a10}/tests/test_uploads_and_literature.py
RENAMED
|
File without changes
|
|
File without changes
|