matelab-python-sdk 0.1.0a9__tar.gz → 0.1.0a10__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/CHANGELOG.md +7 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/PKG-INFO +7 -5
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/README.md +5 -4
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/pyproject.toml +2 -1
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/scripts/check_release.py +6 -1
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/_transport.py +54 -8
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/records.py +9 -13
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/session.py +3 -5
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_client.py +7 -2
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_release.py +7 -1
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_streaming.py +7 -3
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_transport.py +99 -1
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/uv.lock +3 -1
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/.env.example +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/.github/workflows/release.yml +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/.gitignore +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/AGENTS.md +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/LICENSE +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/NOTICE +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/contracts/matelab-integration-v1.lock.json +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/contracts/matelab-integration-v1.openapi.yaml +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/docs/operation-coverage.yaml +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/scripts/check_installed_package.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/scripts/generate_models.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/__init__.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/_generated/__init__.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/_generated/models.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/_wire_validation.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/client.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/__init__.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/cloud_drive.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/groups.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/literature.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/notebooks.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/templates.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/uploads.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/users.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/errors.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/py.typed +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/streaming.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/conftest.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/provider/test_provider_smoke.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_cloud_drive.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_comments_and_downloads.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_consumer_smoke.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_errors.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_extended_records.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_generation.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_groups.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_notebook_lifecycle.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_notebooks.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_operation_coverage.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_record_lifecycle.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_record_mutations.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_records.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_template_lifecycle.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_templates.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_uploads_and_literature.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_users.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/typing/consumer.py +0 -0
- {matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/typing/pyproject.toml +0 -0
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0a10 - 2026-08-05
|
|
4
|
+
|
|
5
|
+
- Keeps synchronous multipart file length probes and chunk reads off the event-loop thread while preserving streamed
|
|
6
|
+
uploads, correct content length, the bytes fast path, and existing non-replay retry restrictions.
|
|
7
|
+
- Replaces implicit-override suppressions with explicit `@override` declarations, simplifies inferred model attributes,
|
|
8
|
+
and declares the Python 3.11 `typing-extensions` runtime dependency directly.
|
|
9
|
+
|
|
3
10
|
## 0.1.0a9 - 2026-08-04
|
|
4
11
|
|
|
5
12
|
- Breaking: removes `MatelabProviderErrorKind` and `MatelabProviderError.kind`; Provider wire codes now map directly
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: matelab-python-sdk
|
|
3
|
-
Version: 0.1.
|
|
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
|
|
@@ -628,8 +629,9 @@ object inheritance and preserves constraints the model generator cannot express
|
|
|
628
629
|
writing the checked-in models. `WireModel` applies that metadata with the standard
|
|
629
630
|
`jsonschema` Draft 2020-12 validator; the SDK does not maintain a second hand-written schema interpreter. The current lock resolves
|
|
630
631
|
`datamodel-code-generator 0.71.0` and
|
|
631
|
-
`hatchling 1.31.0`. Published metadata requires `httpx2>=2.9.1,<3`, `jsonschema>=4.26,<5`,
|
|
632
|
-
`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
|
|
633
635
|
against the complete test suite on the supported Python boundary versions rather than inferred from
|
|
634
636
|
`uv.lock`. The exact toolchain remains locked for development and release builds. Basedpyright and its
|
|
635
637
|
Node wheel retain the compatible exact pair `basedpyright==1.39.9` and
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Reusable async Python client for the Matelab Integration Contract.
|
|
4
4
|
|
|
5
|
-
The current alpha is `0.1.
|
|
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
|
|
@@ -601,8 +601,9 @@ object inheritance and preserves constraints the model generator cannot express
|
|
|
601
601
|
writing the checked-in models. `WireModel` applies that metadata with the standard
|
|
602
602
|
`jsonschema` Draft 2020-12 validator; the SDK does not maintain a second hand-written schema interpreter. The current lock resolves
|
|
603
603
|
`datamodel-code-generator 0.71.0` and
|
|
604
|
-
`hatchling 1.31.0`. Published metadata requires `httpx2>=2.9.1,<3`, `jsonschema>=4.26,<5`,
|
|
605
|
-
`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
|
|
606
607
|
against the complete test suite on the supported Python boundary versions rather than inferred from
|
|
607
608
|
`uv.lock`. The exact toolchain remains locked for development and release builds. Basedpyright and its
|
|
608
609
|
Node wheel retain the compatible exact pair `basedpyright==1.39.9` and
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "matelab-python-sdk"
|
|
7
|
-
version = "0.1.
|
|
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']}>"
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import re
|
|
3
3
|
import time
|
|
4
|
-
from collections.abc import Mapping
|
|
4
|
+
from collections.abc import AsyncIterator, Mapping
|
|
5
5
|
from contextlib import suppress
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from enum import StrEnum
|
|
8
8
|
from typing import IO, Generic, TypeAlias, TypeVar, cast
|
|
9
9
|
|
|
10
|
-
from httpx2 import AsyncClient, RequestError, Response
|
|
10
|
+
from httpx2 import AsyncByteStream, AsyncClient, Request, RequestError, Response, SyncByteStream
|
|
11
11
|
from pydantic import BaseModel, JsonValue, ValidationError
|
|
12
|
+
from typing_extensions import override
|
|
12
13
|
|
|
13
14
|
from matelab._generated import models as wire_models
|
|
14
15
|
from matelab._wire_validation import validate_request
|
|
@@ -37,6 +38,21 @@ def _payload_mapping(payload: _Payload) -> Mapping[str, JsonValue]:
|
|
|
37
38
|
return payload
|
|
38
39
|
|
|
39
40
|
|
|
41
|
+
class _ThreadedMultipartStream(AsyncByteStream):
|
|
42
|
+
def __init__(self, stream: SyncByteStream) -> None:
|
|
43
|
+
self._stream = stream
|
|
44
|
+
|
|
45
|
+
@override
|
|
46
|
+
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
47
|
+
iterator = iter(self._stream)
|
|
48
|
+
while (chunk := await asyncio.to_thread(next, iterator, None)) is not None:
|
|
49
|
+
yield chunk
|
|
50
|
+
|
|
51
|
+
@override
|
|
52
|
+
async def aclose(self) -> None:
|
|
53
|
+
await asyncio.to_thread(self._stream.close)
|
|
54
|
+
|
|
55
|
+
|
|
40
56
|
def _provider_error(code: int, *, provider_message: str) -> MatelabAuthenticationError | MatelabProviderError:
|
|
41
57
|
if code == 1:
|
|
42
58
|
return MatelabAuthenticationError("Matelab authentication failed.")
|
|
@@ -138,6 +154,37 @@ class SessionTransport:
|
|
|
138
154
|
)
|
|
139
155
|
return self.session
|
|
140
156
|
|
|
157
|
+
async def _send_multipart(
|
|
158
|
+
self,
|
|
159
|
+
operation: Operation[WireResponse],
|
|
160
|
+
*,
|
|
161
|
+
headers: dict[str, str],
|
|
162
|
+
payload: Mapping[str, JsonValue] | None,
|
|
163
|
+
files: _Files | None,
|
|
164
|
+
) -> Response:
|
|
165
|
+
multipart = {
|
|
166
|
+
**{key: (None, str(value)) for key, value in (payload or {}).items()},
|
|
167
|
+
**(files or {}),
|
|
168
|
+
}
|
|
169
|
+
if files is None or all(isinstance(content, bytes) for _, content, _ in files.values()):
|
|
170
|
+
return await self._http.request(operation.method, operation.path, headers=headers, files=multipart)
|
|
171
|
+
|
|
172
|
+
base_request = self._http.build_request(operation.method, operation.path, headers=headers)
|
|
173
|
+
multipart_headers = base_request.headers.copy()
|
|
174
|
+
_ = multipart_headers.pop("Content-Length", None)
|
|
175
|
+
_ = multipart_headers.pop("Transfer-Encoding", None)
|
|
176
|
+
request = await asyncio.to_thread(
|
|
177
|
+
Request,
|
|
178
|
+
base_request.method,
|
|
179
|
+
base_request.url,
|
|
180
|
+
headers=multipart_headers,
|
|
181
|
+
files=multipart,
|
|
182
|
+
extensions=base_request.extensions,
|
|
183
|
+
)
|
|
184
|
+
if isinstance(request.stream, SyncByteStream):
|
|
185
|
+
request.stream = _ThreadedMultipartStream(request.stream)
|
|
186
|
+
return await self._http.send(request)
|
|
187
|
+
|
|
141
188
|
async def request(
|
|
142
189
|
self, operation: Operation[WireResponse], *, payload: _Payload | None = None, files: _Files | None = None
|
|
143
190
|
) -> WireResponse:
|
|
@@ -169,12 +216,11 @@ class SessionTransport:
|
|
|
169
216
|
operation.method, operation.path, headers=headers, data=encoded_payload
|
|
170
217
|
)
|
|
171
218
|
elif operation.encoding is Encoding.MULTIPART:
|
|
172
|
-
|
|
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), "")
|
|
@@ -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
|
|
@@ -420,9 +420,14 @@ async def test_injected_http_client_remains_open_when_sdk_closes() -> None:
|
|
|
420
420
|
|
|
421
421
|
|
|
422
422
|
@pytest.mark.asyncio
|
|
423
|
-
async def test_sdk_owned_http_client_closes_with_sdk() -> None:
|
|
423
|
+
async def test_sdk_owned_http_client_closes_with_sdk(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
424
|
+
owned_http_client = AsyncClient(base_url="https://matelab.example/api")
|
|
425
|
+
|
|
426
|
+
def build_http_client(**_: object) -> AsyncClient:
|
|
427
|
+
return owned_http_client
|
|
428
|
+
|
|
429
|
+
monkeypatch.setattr("matelab.client.AsyncClient", build_http_client)
|
|
424
430
|
client = AsyncMatelab("https://matelab.example/api")
|
|
425
|
-
owned_http_client = client._http # pyright: ignore[reportPrivateUsage]
|
|
426
431
|
|
|
427
432
|
await client.aclose()
|
|
428
433
|
|
|
@@ -21,7 +21,12 @@ _ROOT = Path(__file__).resolve().parents[1]
|
|
|
21
21
|
_PYPROJECT = tomllib.loads((_ROOT / "pyproject.toml").read_text())
|
|
22
22
|
_PROJECT = cast(dict[str, object], _PYPROJECT["project"])
|
|
23
23
|
_RELEASE_VERSION = cast(str, _PROJECT["version"])
|
|
24
|
-
_EXPECTED_REQUIRES_DIST = {
|
|
24
|
+
_EXPECTED_REQUIRES_DIST = {
|
|
25
|
+
"httpx2<3,>=2.9.1",
|
|
26
|
+
"jsonschema<5,>=4.26",
|
|
27
|
+
"pydantic<3,>=2.13.4",
|
|
28
|
+
"typing-extensions<5,>=4.14.1",
|
|
29
|
+
}
|
|
25
30
|
_EXPECTED_LICENSE_FILES = {"LICENSE", "NOTICE"}
|
|
26
31
|
|
|
27
32
|
|
|
@@ -133,6 +138,7 @@ def test_release_dependencies_publish_verified_compatibility_ranges() -> None:
|
|
|
133
138
|
"httpx2>=2.9.1,<3",
|
|
134
139
|
"jsonschema>=4.26,<5",
|
|
135
140
|
"pydantic>=2.13.4,<3",
|
|
141
|
+
"typing-extensions>=4.14.1,<5",
|
|
136
142
|
]
|
|
137
143
|
assert project["license"] == "Apache-2.0"
|
|
138
144
|
assert project["license-files"] == ["LICENSE", "NOTICE"]
|
|
@@ -2,6 +2,7 @@ from collections.abc import AsyncIterator, Callable
|
|
|
2
2
|
|
|
3
3
|
import pytest
|
|
4
4
|
from httpx2 import AsyncByteStream, AsyncClient, MockTransport, ReadError, Request, Response
|
|
5
|
+
from typing_extensions import override
|
|
5
6
|
|
|
6
7
|
from matelab import (
|
|
7
8
|
ByteRange,
|
|
@@ -25,15 +26,18 @@ class _TrackingByteStream(AsyncByteStream):
|
|
|
25
26
|
self._content = content
|
|
26
27
|
self.closed = False
|
|
27
28
|
|
|
28
|
-
|
|
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
|
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import json
|
|
3
|
+
import threading
|
|
3
4
|
import time
|
|
4
5
|
from collections.abc import AsyncIterator
|
|
5
6
|
from io import BytesIO
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
6
9
|
|
|
7
10
|
import pytest
|
|
8
11
|
from httpx2 import AsyncByteStream, AsyncClient, MockTransport, Request, Response
|
|
12
|
+
from typing_extensions import override
|
|
9
13
|
|
|
10
14
|
from matelab._generated.models import BasicSuccessResponse, RecordListResponse, TokenRefreshResponse
|
|
11
15
|
from matelab._transport import Encoding, Operation, SessionTransport
|
|
@@ -21,10 +25,40 @@ from matelab.session import Identity, Session, Token
|
|
|
21
25
|
|
|
22
26
|
|
|
23
27
|
class _InvalidGzipStream(AsyncByteStream):
|
|
24
|
-
|
|
28
|
+
@override
|
|
29
|
+
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
25
30
|
yield b"not-gzip"
|
|
26
31
|
|
|
27
32
|
|
|
33
|
+
class _LoopSensitiveFile(BytesIO):
|
|
34
|
+
def __init__(self, content: bytes, blocked_operation: Literal["read", "seek"]) -> None:
|
|
35
|
+
super().__init__(content)
|
|
36
|
+
self.blocked_operation = blocked_operation
|
|
37
|
+
self.operation_started = threading.Event()
|
|
38
|
+
self.event_loop_progress = threading.Event()
|
|
39
|
+
self.event_loop_progressed_while_blocked = False
|
|
40
|
+
self._blocked_once = False
|
|
41
|
+
|
|
42
|
+
def _block_once(self) -> None:
|
|
43
|
+
if self._blocked_once:
|
|
44
|
+
return
|
|
45
|
+
self._blocked_once = True
|
|
46
|
+
self.operation_started.set()
|
|
47
|
+
self.event_loop_progressed_while_blocked = self.event_loop_progress.wait(timeout=0.5)
|
|
48
|
+
|
|
49
|
+
@override
|
|
50
|
+
def read(self, size: int | None = -1, /) -> bytes:
|
|
51
|
+
if self.blocked_operation == "read":
|
|
52
|
+
self._block_once()
|
|
53
|
+
return super().read(size)
|
|
54
|
+
|
|
55
|
+
@override
|
|
56
|
+
def seek(self, pos: int, whence: int = 0, /) -> int:
|
|
57
|
+
if self.blocked_operation == "seek":
|
|
58
|
+
self._block_once()
|
|
59
|
+
return super().seek(pos, whence)
|
|
60
|
+
|
|
61
|
+
|
|
28
62
|
def _session(
|
|
29
63
|
*,
|
|
30
64
|
access: str = "access",
|
|
@@ -310,6 +344,70 @@ async def test_transport_encodes_query_form_and_multipart_operations() -> None:
|
|
|
310
344
|
assert seen_paths == ["/api/query", "/api/form", "/api/multipart", "/api/multipart-fields"]
|
|
311
345
|
|
|
312
346
|
|
|
347
|
+
@pytest.mark.parametrize("blocked_operation", ["read", "seek"])
|
|
348
|
+
@pytest.mark.asyncio
|
|
349
|
+
async def test_transport_keeps_event_loop_responsive_while_reading_multipart_file(
|
|
350
|
+
blocked_operation: Literal["read", "seek"],
|
|
351
|
+
) -> None:
|
|
352
|
+
content = _LoopSensitiveFile(b"contents", blocked_operation)
|
|
353
|
+
|
|
354
|
+
async def observe_blocked_file_operation() -> None:
|
|
355
|
+
while not content.operation_started.is_set():
|
|
356
|
+
await asyncio.sleep(0)
|
|
357
|
+
content.event_loop_progress.set()
|
|
358
|
+
|
|
359
|
+
async def handler(request: Request) -> Response:
|
|
360
|
+
assert b'filename="example.txt"' in request.content
|
|
361
|
+
assert b"\r\ncontents\r\n" in request.content
|
|
362
|
+
return Response(200, json={"code": 0, "msg": "", "errcode": 0, "errmsg": ""})
|
|
363
|
+
|
|
364
|
+
operation = Operation(
|
|
365
|
+
method="POST",
|
|
366
|
+
path="/multipart",
|
|
367
|
+
encoding=Encoding.MULTIPART,
|
|
368
|
+
response_model=BasicSuccessResponse,
|
|
369
|
+
success_codes=frozenset({0}),
|
|
370
|
+
)
|
|
371
|
+
observer = asyncio.create_task(observe_blocked_file_operation())
|
|
372
|
+
async with AsyncClient(base_url="https://matelab.example/api", transport=MockTransport(handler)) as http_client:
|
|
373
|
+
transport = SessionTransport(http_client=http_client, session=_session())
|
|
374
|
+
_ = await transport.request(
|
|
375
|
+
operation,
|
|
376
|
+
files={"file": ("example.txt", content, "text/plain")},
|
|
377
|
+
)
|
|
378
|
+
await observer
|
|
379
|
+
|
|
380
|
+
assert content.event_loop_progressed_while_blocked
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@pytest.mark.asyncio
|
|
384
|
+
async def test_transport_uploads_regular_binary_file(tmp_path: Path) -> None:
|
|
385
|
+
path = tmp_path / "example.txt"
|
|
386
|
+
_ = path.write_bytes(b"contents")
|
|
387
|
+
|
|
388
|
+
async def handler(request: Request) -> Response:
|
|
389
|
+
assert request.headers["Content-Type"].startswith("multipart/form-data; boundary=")
|
|
390
|
+
assert int(request.headers["Content-Length"]) == len(request.content)
|
|
391
|
+
assert b'filename="example.txt"' in request.content
|
|
392
|
+
assert b"\r\ncontents\r\n" in request.content
|
|
393
|
+
return Response(200, json={"code": 0, "msg": "", "errcode": 0, "errmsg": ""})
|
|
394
|
+
|
|
395
|
+
operation = Operation(
|
|
396
|
+
method="POST",
|
|
397
|
+
path="/multipart",
|
|
398
|
+
encoding=Encoding.MULTIPART,
|
|
399
|
+
response_model=BasicSuccessResponse,
|
|
400
|
+
success_codes=frozenset({0}),
|
|
401
|
+
)
|
|
402
|
+
with path.open("rb") as content:
|
|
403
|
+
async with AsyncClient(base_url="https://matelab.example/api", transport=MockTransport(handler)) as http_client:
|
|
404
|
+
transport = SessionTransport(http_client=http_client, session=_session())
|
|
405
|
+
_ = await transport.request(
|
|
406
|
+
operation,
|
|
407
|
+
files={"file": (path.name, content, "text/plain")},
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
313
411
|
@pytest.mark.asyncio
|
|
314
412
|
async def test_transport_refreshes_access_inside_expiration_skew_before_request() -> None:
|
|
315
413
|
paths: list[str] = []
|
|
@@ -352,12 +352,13 @@ wheels = [
|
|
|
352
352
|
|
|
353
353
|
[[package]]
|
|
354
354
|
name = "matelab-python-sdk"
|
|
355
|
-
version = "0.1.
|
|
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
|
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/scripts/check_installed_package.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.1.0a9 → 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.0a9 → matelab_python_sdk-0.1.0a10}/src/matelab/domains/cloud_drive.py
RENAMED
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.1.0a9 → 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
|
|
File without changes
|
{matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/provider/test_provider_smoke.py
RENAMED
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.1.0a9 → 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
|
|
File without changes
|
|
File without changes
|
{matelab_python_sdk-0.1.0a9 → matelab_python_sdk-0.1.0a10}/tests/test_uploads_and_literature.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|