matelab-python-sdk 0.1.0a1__py3-none-any.whl
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/__init__.py +372 -0
- matelab/_generated/__init__.py +1 -0
- matelab/_generated/models.py +3553 -0
- matelab/_transport.py +481 -0
- matelab/client.py +191 -0
- matelab/cloud_drive.py +789 -0
- matelab/errors.py +38 -0
- matelab/groups.py +78 -0
- matelab/literature.py +1243 -0
- matelab/models.py +27 -0
- matelab/notebooks.py +467 -0
- matelab/py.typed +1 -0
- matelab/records.py +2511 -0
- matelab/streaming.py +82 -0
- matelab/templates.py +737 -0
- matelab/uploads.py +247 -0
- matelab/users.py +77 -0
- matelab_python_sdk-0.1.0a1.dist-info/METADATA +538 -0
- matelab_python_sdk-0.1.0a1.dist-info/RECORD +22 -0
- matelab_python_sdk-0.1.0a1.dist-info/WHEEL +4 -0
- matelab_python_sdk-0.1.0a1.dist-info/licenses/LICENSE +202 -0
- matelab_python_sdk-0.1.0a1.dist-info/licenses/NOTICE +2 -0
matelab/_transport.py
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import IO, Generic, TypeAlias, TypeVar, cast
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from pydantic import BaseModel, ValidationError
|
|
12
|
+
|
|
13
|
+
from matelab._generated.models import ErrorResponse, TokenRefreshRequest, TokenRefreshResponse
|
|
14
|
+
from matelab.errors import (
|
|
15
|
+
MatelabAuthenticationError,
|
|
16
|
+
MatelabProtocolError,
|
|
17
|
+
MatelabProviderError,
|
|
18
|
+
MatelabTransportError,
|
|
19
|
+
MatelabUsageError,
|
|
20
|
+
)
|
|
21
|
+
from matelab.models import Session, Token
|
|
22
|
+
from matelab.streaming import DownloadStream
|
|
23
|
+
|
|
24
|
+
WireResponse = TypeVar("WireResponse", bound=BaseModel)
|
|
25
|
+
_ACCESS_EXPIRY_SKEW_MS = 30_000
|
|
26
|
+
_ParameterValue: TypeAlias = str | int | float | bool | None
|
|
27
|
+
_FileContent: TypeAlias = IO[bytes] | bytes | str
|
|
28
|
+
_FilePart: TypeAlias = (
|
|
29
|
+
_FileContent
|
|
30
|
+
| tuple[str | None, _FileContent]
|
|
31
|
+
| tuple[str | None, _FileContent, str | None]
|
|
32
|
+
| tuple[str | None, _FileContent, str | None, Mapping[str, str]]
|
|
33
|
+
)
|
|
34
|
+
_Files: TypeAlias = Mapping[str, _FilePart]
|
|
35
|
+
_ReplayPositions: TypeAlias = tuple[tuple[IO[bytes], int], ...]
|
|
36
|
+
_REDACTED = "[REDACTED]"
|
|
37
|
+
_SENSITIVE_KEYS = frozenset(
|
|
38
|
+
{
|
|
39
|
+
"access",
|
|
40
|
+
"access_token",
|
|
41
|
+
"authorization",
|
|
42
|
+
"code",
|
|
43
|
+
"file",
|
|
44
|
+
"h",
|
|
45
|
+
"hash",
|
|
46
|
+
"key",
|
|
47
|
+
"password",
|
|
48
|
+
"refresh",
|
|
49
|
+
"refresh_token",
|
|
50
|
+
"token",
|
|
51
|
+
"uid",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
_CONTENT_RANGE_PATTERN = re.compile(r"^bytes [0-9]+-[0-9]+/[0-9]+$")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def multipart_fields(payload: Mapping[str, object]) -> dict[str, tuple[None, str]]:
|
|
58
|
+
return {key: (None, str(value)) for key, value in payload.items()}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_sensitive_key(key: str | None) -> bool:
|
|
62
|
+
if key is None:
|
|
63
|
+
return False
|
|
64
|
+
normalized = key.casefold().replace("-", "_")
|
|
65
|
+
return normalized in _SENSITIVE_KEYS or normalized.endswith(("_password", "_secret", "_token"))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _collect_sensitive_values(value: object, *, key: str | None = None) -> set[str]:
|
|
69
|
+
values: set[str] = set()
|
|
70
|
+
if _is_sensitive_key(key) and isinstance(value, str) and value:
|
|
71
|
+
values.add(value)
|
|
72
|
+
if isinstance(value, Mapping):
|
|
73
|
+
for raw_key, nested in cast(Mapping[object, object], value).items():
|
|
74
|
+
values.update(_collect_sensitive_values(nested, key=raw_key if isinstance(raw_key, str) else None))
|
|
75
|
+
elif isinstance(value, (list, tuple)):
|
|
76
|
+
for nested in cast(list[object] | tuple[object, ...], value):
|
|
77
|
+
values.update(_collect_sensitive_values(nested))
|
|
78
|
+
return values
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _collect_sensitive_multipart_values(files: _Files | None) -> set[str]:
|
|
82
|
+
if files is None:
|
|
83
|
+
return set()
|
|
84
|
+
values: set[str] = set()
|
|
85
|
+
for key, part in files.items():
|
|
86
|
+
if not _is_sensitive_key(key) or not isinstance(part, tuple) or part[0] is not None:
|
|
87
|
+
continue
|
|
88
|
+
content = part[1]
|
|
89
|
+
if isinstance(content, str) and content:
|
|
90
|
+
values.add(content)
|
|
91
|
+
return values
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _sanitize_value(value: object, sensitive_values: set[str], *, key: str | None = None) -> object:
|
|
95
|
+
if _is_sensitive_key(key) and isinstance(value, (str, bytes)):
|
|
96
|
+
return _REDACTED
|
|
97
|
+
if isinstance(value, str):
|
|
98
|
+
return _redact_text(value, sensitive_values)
|
|
99
|
+
if isinstance(value, Mapping):
|
|
100
|
+
return {
|
|
101
|
+
str(raw_key): _sanitize_value(nested, sensitive_values, key=raw_key if isinstance(raw_key, str) else None)
|
|
102
|
+
for raw_key, nested in cast(Mapping[object, object], value).items()
|
|
103
|
+
}
|
|
104
|
+
if isinstance(value, list):
|
|
105
|
+
return [_sanitize_value(item, sensitive_values) for item in cast(list[object], value)]
|
|
106
|
+
if isinstance(value, tuple):
|
|
107
|
+
return tuple(_sanitize_value(item, sensitive_values) for item in cast(tuple[object, ...], value))
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _sanitize_payload(payload: Mapping[str, object], sensitive_values: set[str]) -> dict[str, object]:
|
|
112
|
+
return {key: _sanitize_value(value, sensitive_values, key=key) for key, value in payload.items()}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _redact_text(message: str, sensitive_values: set[str]) -> str:
|
|
116
|
+
redacted = message
|
|
117
|
+
for value in sorted(sensitive_values, key=len, reverse=True):
|
|
118
|
+
redacted = redacted.replace(value, _REDACTED)
|
|
119
|
+
return redacted
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Encoding(StrEnum):
|
|
123
|
+
NONE = "none"
|
|
124
|
+
QUERY = "query"
|
|
125
|
+
JSON = "json"
|
|
126
|
+
FORM = "form"
|
|
127
|
+
MULTIPART = "multipart"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True, slots=True)
|
|
131
|
+
class Operation(Generic[WireResponse]):
|
|
132
|
+
method: str
|
|
133
|
+
path: str
|
|
134
|
+
encoding: Encoding
|
|
135
|
+
response_model: type[WireResponse]
|
|
136
|
+
success_codes: frozenset[int]
|
|
137
|
+
authenticated: bool = True
|
|
138
|
+
authentication_errors: bool = False
|
|
139
|
+
retry_on_access_expired: bool = False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True, slots=True)
|
|
143
|
+
class StreamOperation:
|
|
144
|
+
method: str
|
|
145
|
+
path: str
|
|
146
|
+
authenticated: bool = True
|
|
147
|
+
retry_on_access_expired: bool = False
|
|
148
|
+
media_types: frozenset[str] | None = None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
_REFRESH_TOKENS = Operation(
|
|
152
|
+
method="POST",
|
|
153
|
+
path="/tokens/tokens_refresh",
|
|
154
|
+
encoding=Encoding.FORM,
|
|
155
|
+
response_model=TokenRefreshResponse,
|
|
156
|
+
success_codes=frozenset({0}),
|
|
157
|
+
authenticated=False,
|
|
158
|
+
authentication_errors=True,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class SessionTransport:
|
|
163
|
+
def __init__(self, http_client: httpx.AsyncClient, *, session: Session | None = None) -> None:
|
|
164
|
+
self._http: httpx.AsyncClient = http_client
|
|
165
|
+
self.session: Session | None = session
|
|
166
|
+
self._refresh_lock: asyncio.Lock = asyncio.Lock()
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _now_ms() -> int:
|
|
170
|
+
return time.time_ns() // 1_000_000
|
|
171
|
+
|
|
172
|
+
def require_session(self) -> Session:
|
|
173
|
+
if self.session is None:
|
|
174
|
+
raise MatelabAuthenticationError("No Matelab session is configured.")
|
|
175
|
+
return self.session
|
|
176
|
+
|
|
177
|
+
def absolute_url(self, path: str, query: str) -> str:
|
|
178
|
+
"""Resolve a Provider-returned relative path without exposing transport state."""
|
|
179
|
+
base = str(self._http.base_url).rstrip("/")
|
|
180
|
+
suffix = path.lstrip("/")
|
|
181
|
+
return f"{base}/{suffix}?{query}"
|
|
182
|
+
|
|
183
|
+
async def _access_token(self) -> str:
|
|
184
|
+
session = self.require_session()
|
|
185
|
+
if session.access.expires_at_ms <= self._now_ms() + _ACCESS_EXPIRY_SKEW_MS:
|
|
186
|
+
_ = await self.refresh()
|
|
187
|
+
session = self.require_session()
|
|
188
|
+
return session.access.value
|
|
189
|
+
|
|
190
|
+
async def refresh(self, *, force: bool = False, stale_access_token: str | None = None) -> Session:
|
|
191
|
+
observed_access_token = stale_access_token
|
|
192
|
+
if force and observed_access_token is None:
|
|
193
|
+
observed_access_token = self.require_session().access.value
|
|
194
|
+
async with self._refresh_lock:
|
|
195
|
+
session = self.require_session()
|
|
196
|
+
if observed_access_token is not None and session.access.value != observed_access_token:
|
|
197
|
+
return session
|
|
198
|
+
if not force and session.access.expires_at_ms > self._now_ms() + _ACCESS_EXPIRY_SKEW_MS:
|
|
199
|
+
return session
|
|
200
|
+
if session.refresh.expires_at_ms <= self._now_ms():
|
|
201
|
+
raise MatelabAuthenticationError("Matelab refresh token expired.")
|
|
202
|
+
try:
|
|
203
|
+
request = TokenRefreshRequest(refresh_token=session.refresh.value)
|
|
204
|
+
except ValidationError:
|
|
205
|
+
raise MatelabAuthenticationError("The Matelab refresh token is invalid.") from None
|
|
206
|
+
response = await self.request(_REFRESH_TOKENS, payload=request.model_dump(mode="json"))
|
|
207
|
+
self.session = Session(
|
|
208
|
+
access=Token(value=response.access.token, expires_at_ms=response.access.expiredAt),
|
|
209
|
+
refresh=Token(value=response.refresh.token, expires_at_ms=response.refresh.expiredAt),
|
|
210
|
+
identity=session.identity,
|
|
211
|
+
)
|
|
212
|
+
return self.session
|
|
213
|
+
|
|
214
|
+
async def request(
|
|
215
|
+
self,
|
|
216
|
+
operation: Operation[WireResponse],
|
|
217
|
+
*,
|
|
218
|
+
payload: Mapping[str, object] | None = None,
|
|
219
|
+
files: _Files | None = None,
|
|
220
|
+
bearer_token: str | None = None,
|
|
221
|
+
) -> WireResponse:
|
|
222
|
+
replay_positions = self._capture_replay_positions(operation, files) if bearer_token is None else ()
|
|
223
|
+
sensitive_values = _collect_sensitive_values(payload) | _collect_sensitive_multipart_values(files)
|
|
224
|
+
if bearer_token:
|
|
225
|
+
sensitive_values.add(bearer_token)
|
|
226
|
+
return await self._request(
|
|
227
|
+
operation,
|
|
228
|
+
payload=payload,
|
|
229
|
+
files=files,
|
|
230
|
+
bearer_token=bearer_token,
|
|
231
|
+
replay_positions=replay_positions,
|
|
232
|
+
sensitive_values=sensitive_values,
|
|
233
|
+
allow_access_expired_retry=True,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
async def stream(
|
|
237
|
+
self, operation: StreamOperation, *, payload: Mapping[str, object], range_header: str | None = None
|
|
238
|
+
) -> DownloadStream:
|
|
239
|
+
sensitive_values = _collect_sensitive_values(payload)
|
|
240
|
+
return await self._stream(
|
|
241
|
+
operation,
|
|
242
|
+
payload=payload,
|
|
243
|
+
range_header=range_header,
|
|
244
|
+
sensitive_values=sensitive_values,
|
|
245
|
+
allow_access_expired_retry=True,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
def _capture_replay_positions(operation: Operation[WireResponse], files: _Files | None) -> _ReplayPositions:
|
|
250
|
+
if files is None or not operation.authenticated or not operation.retry_on_access_expired:
|
|
251
|
+
return ()
|
|
252
|
+
positions: list[tuple[IO[bytes], int]] = []
|
|
253
|
+
for part in files.values():
|
|
254
|
+
content = part[1] if isinstance(part, tuple) else part
|
|
255
|
+
if isinstance(content, (bytes, str)):
|
|
256
|
+
continue
|
|
257
|
+
try:
|
|
258
|
+
position = content.tell()
|
|
259
|
+
_ = content.seek(position)
|
|
260
|
+
except (AttributeError, OSError, ValueError) as exc:
|
|
261
|
+
raise MatelabUsageError(
|
|
262
|
+
"Cannot retry multipart request because a file stream is not seekable."
|
|
263
|
+
) from exc
|
|
264
|
+
positions.append((content, position))
|
|
265
|
+
return tuple(positions)
|
|
266
|
+
|
|
267
|
+
@staticmethod
|
|
268
|
+
def _rewind_files(replay_positions: _ReplayPositions) -> None:
|
|
269
|
+
for content, position in replay_positions:
|
|
270
|
+
try:
|
|
271
|
+
_ = content.seek(position)
|
|
272
|
+
except (OSError, ValueError) as exc:
|
|
273
|
+
raise MatelabUsageError(
|
|
274
|
+
"Cannot retry multipart request because a file stream could not be rewound."
|
|
275
|
+
) from exc
|
|
276
|
+
|
|
277
|
+
async def _request(
|
|
278
|
+
self,
|
|
279
|
+
operation: Operation[WireResponse],
|
|
280
|
+
*,
|
|
281
|
+
payload: Mapping[str, object] | None,
|
|
282
|
+
files: _Files | None,
|
|
283
|
+
bearer_token: str | None,
|
|
284
|
+
replay_positions: _ReplayPositions,
|
|
285
|
+
sensitive_values: set[str],
|
|
286
|
+
allow_access_expired_retry: bool,
|
|
287
|
+
) -> WireResponse:
|
|
288
|
+
headers = {"Accept": "application/json"}
|
|
289
|
+
access_token: str | None = None
|
|
290
|
+
if operation.authenticated:
|
|
291
|
+
access_token = bearer_token if bearer_token is not None else await self._access_token()
|
|
292
|
+
headers["Authorization"] = f"Bearer {access_token}"
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
if operation.encoding is Encoding.JSON:
|
|
296
|
+
response = await self._http.request(operation.method, operation.path, headers=headers, json=payload)
|
|
297
|
+
elif operation.encoding is Encoding.QUERY:
|
|
298
|
+
params = cast(Mapping[str, _ParameterValue], payload)
|
|
299
|
+
response = await self._http.request(operation.method, operation.path, headers=headers, params=params)
|
|
300
|
+
elif operation.encoding is Encoding.FORM:
|
|
301
|
+
response = await self._http.request(operation.method, operation.path, headers=headers, data=payload)
|
|
302
|
+
elif operation.encoding is Encoding.MULTIPART:
|
|
303
|
+
response = await self._http.request(
|
|
304
|
+
operation.method, operation.path, headers=headers, data=payload, files=files
|
|
305
|
+
)
|
|
306
|
+
elif operation.encoding is Encoding.NONE:
|
|
307
|
+
response = await self._http.request(operation.method, operation.path, headers=headers)
|
|
308
|
+
else:
|
|
309
|
+
raise MatelabProtocolError(f"Unsupported request encoding: {operation.encoding}.")
|
|
310
|
+
except httpx.TransportError:
|
|
311
|
+
raise MatelabTransportError("Could not exchange data with the Matelab Provider.") from None
|
|
312
|
+
if not response.is_success:
|
|
313
|
+
raise MatelabTransportError(
|
|
314
|
+
f"Matelab returned HTTP {response.status_code}.", status_code=response.status_code
|
|
315
|
+
)
|
|
316
|
+
try:
|
|
317
|
+
raw_payload = cast(object, response.json())
|
|
318
|
+
except ValueError:
|
|
319
|
+
raise MatelabProtocolError("Matelab returned invalid JSON.") from None
|
|
320
|
+
if not isinstance(raw_payload, dict):
|
|
321
|
+
raise MatelabProtocolError("Matelab returned a non-object JSON payload.")
|
|
322
|
+
response_payload = cast(dict[str, object], raw_payload)
|
|
323
|
+
response_sensitive_values = sensitive_values | _collect_sensitive_values(response_payload)
|
|
324
|
+
sanitized_payload = _sanitize_payload(response_payload, response_sensitive_values)
|
|
325
|
+
code = response_payload.get("code")
|
|
326
|
+
if isinstance(code, bool) or not isinstance(code, int):
|
|
327
|
+
raise MatelabProtocolError(
|
|
328
|
+
"Matelab response does not contain an integer business code.", payload=sanitized_payload
|
|
329
|
+
)
|
|
330
|
+
if code not in operation.success_codes:
|
|
331
|
+
try:
|
|
332
|
+
error_response = ErrorResponse.model_validate(response_payload)
|
|
333
|
+
except ValidationError:
|
|
334
|
+
raise MatelabProtocolError(
|
|
335
|
+
"Matelab error response does not match the Integration Contract.", payload=sanitized_payload
|
|
336
|
+
) from None
|
|
337
|
+
if (
|
|
338
|
+
code == 5
|
|
339
|
+
and access_token is not None
|
|
340
|
+
and bearer_token is None
|
|
341
|
+
and operation.retry_on_access_expired
|
|
342
|
+
and allow_access_expired_retry
|
|
343
|
+
):
|
|
344
|
+
_ = await self.refresh(force=True, stale_access_token=access_token)
|
|
345
|
+
self._rewind_files(replay_positions)
|
|
346
|
+
return await self._request(
|
|
347
|
+
operation,
|
|
348
|
+
payload=payload,
|
|
349
|
+
files=files,
|
|
350
|
+
bearer_token=None,
|
|
351
|
+
replay_positions=replay_positions,
|
|
352
|
+
sensitive_values=sensitive_values,
|
|
353
|
+
allow_access_expired_retry=False,
|
|
354
|
+
)
|
|
355
|
+
error_type = (
|
|
356
|
+
MatelabAuthenticationError
|
|
357
|
+
if operation.authentication_errors or code == 5 or (operation.authenticated and code == 1)
|
|
358
|
+
else MatelabProviderError
|
|
359
|
+
)
|
|
360
|
+
raise error_type(
|
|
361
|
+
_redact_text(error_response.msg or "Matelab request failed.", response_sensitive_values),
|
|
362
|
+
code=code,
|
|
363
|
+
payload=sanitized_payload,
|
|
364
|
+
)
|
|
365
|
+
try:
|
|
366
|
+
return operation.response_model.model_validate(response_payload)
|
|
367
|
+
except ValidationError:
|
|
368
|
+
raise MatelabProtocolError(
|
|
369
|
+
"Matelab response does not match the Integration Contract.", payload=sanitized_payload
|
|
370
|
+
) from None
|
|
371
|
+
|
|
372
|
+
async def _stream(
|
|
373
|
+
self,
|
|
374
|
+
operation: StreamOperation,
|
|
375
|
+
*,
|
|
376
|
+
payload: Mapping[str, object],
|
|
377
|
+
range_header: str | None,
|
|
378
|
+
sensitive_values: set[str],
|
|
379
|
+
allow_access_expired_retry: bool,
|
|
380
|
+
) -> DownloadStream:
|
|
381
|
+
headers = {"Accept": "*/*"}
|
|
382
|
+
access_token: str | None = None
|
|
383
|
+
if operation.authenticated:
|
|
384
|
+
access_token = await self._access_token()
|
|
385
|
+
headers["Authorization"] = f"Bearer {access_token}"
|
|
386
|
+
if range_header is not None:
|
|
387
|
+
headers["Range"] = range_header
|
|
388
|
+
|
|
389
|
+
request = self._http.build_request(
|
|
390
|
+
operation.method, operation.path, headers=headers, params=cast(Mapping[str, _ParameterValue], payload)
|
|
391
|
+
)
|
|
392
|
+
try:
|
|
393
|
+
response = await self._http.send(request, stream=True)
|
|
394
|
+
except httpx.TransportError:
|
|
395
|
+
raise MatelabTransportError("Could not exchange data with the Matelab Provider.") from None
|
|
396
|
+
|
|
397
|
+
if response.status_code not in {200, 206}:
|
|
398
|
+
await response.aclose()
|
|
399
|
+
raise MatelabTransportError(
|
|
400
|
+
f"Matelab returned HTTP {response.status_code}.", status_code=response.status_code
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
media_type = (self._header(response, "Content-Type") or "").partition(";")[0].strip().casefold()
|
|
404
|
+
if media_type == "application/json":
|
|
405
|
+
body = await response.aread()
|
|
406
|
+
await response.aclose()
|
|
407
|
+
try:
|
|
408
|
+
raw_payload = cast(object, json.loads(body))
|
|
409
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
410
|
+
raise MatelabProtocolError("Matelab returned invalid JSON for a download.") from None
|
|
411
|
+
if not isinstance(raw_payload, dict):
|
|
412
|
+
raise MatelabProtocolError("Matelab returned a non-object JSON payload for a download.")
|
|
413
|
+
response_payload = cast(dict[str, object], raw_payload)
|
|
414
|
+
response_sensitive_values = sensitive_values | _collect_sensitive_values(response_payload)
|
|
415
|
+
sanitized_payload = _sanitize_payload(response_payload, response_sensitive_values)
|
|
416
|
+
code = response_payload.get("code")
|
|
417
|
+
if isinstance(code, bool) or not isinstance(code, int):
|
|
418
|
+
raise MatelabProtocolError(
|
|
419
|
+
"Matelab download response does not contain an integer business code.", payload=sanitized_payload
|
|
420
|
+
)
|
|
421
|
+
if code == 0:
|
|
422
|
+
raise MatelabProtocolError(
|
|
423
|
+
"Matelab returned a JSON success envelope instead of attachment bytes.", payload=sanitized_payload
|
|
424
|
+
)
|
|
425
|
+
try:
|
|
426
|
+
error_response = ErrorResponse.model_validate(response_payload)
|
|
427
|
+
except ValidationError:
|
|
428
|
+
raise MatelabProtocolError(
|
|
429
|
+
"Matelab download error response does not match the Integration Contract.",
|
|
430
|
+
payload=sanitized_payload,
|
|
431
|
+
) from None
|
|
432
|
+
if (
|
|
433
|
+
code == 5
|
|
434
|
+
and access_token is not None
|
|
435
|
+
and operation.retry_on_access_expired
|
|
436
|
+
and allow_access_expired_retry
|
|
437
|
+
):
|
|
438
|
+
_ = await self.refresh(force=True, stale_access_token=access_token)
|
|
439
|
+
return await self._stream(
|
|
440
|
+
operation,
|
|
441
|
+
payload=payload,
|
|
442
|
+
range_header=range_header,
|
|
443
|
+
sensitive_values=sensitive_values,
|
|
444
|
+
allow_access_expired_retry=False,
|
|
445
|
+
)
|
|
446
|
+
error_type = MatelabAuthenticationError if code in {1, 5} else MatelabProviderError
|
|
447
|
+
raise error_type(
|
|
448
|
+
_redact_text(error_response.msg or "Matelab download failed.", response_sensitive_values),
|
|
449
|
+
code=code,
|
|
450
|
+
payload=sanitized_payload,
|
|
451
|
+
)
|
|
452
|
+
if operation.media_types is not None and media_type not in operation.media_types:
|
|
453
|
+
await response.aclose()
|
|
454
|
+
raise MatelabProtocolError("Matelab returned an unexpected download media type.")
|
|
455
|
+
|
|
456
|
+
content_length = self._parse_content_length(response)
|
|
457
|
+
if response.status_code == 206 and (
|
|
458
|
+
(self._header(response, "Accept-Ranges") or "").casefold() != "bytes"
|
|
459
|
+
or content_length is None
|
|
460
|
+
or content_length < 1
|
|
461
|
+
or not _CONTENT_RANGE_PATTERN.fullmatch(self._header(response, "Content-Range") or "")
|
|
462
|
+
or not self._header(response, "Content-Disposition")
|
|
463
|
+
):
|
|
464
|
+
await response.aclose()
|
|
465
|
+
raise MatelabProtocolError("Matelab returned invalid partial-download metadata.")
|
|
466
|
+
return DownloadStream(response, content_length=content_length)
|
|
467
|
+
|
|
468
|
+
@staticmethod
|
|
469
|
+
def _parse_content_length(response: httpx.Response) -> int | None:
|
|
470
|
+
value = SessionTransport._header(response, "Content-Length")
|
|
471
|
+
if value is None:
|
|
472
|
+
return None
|
|
473
|
+
try:
|
|
474
|
+
content_length = int(value)
|
|
475
|
+
except ValueError:
|
|
476
|
+
return None
|
|
477
|
+
return content_length if content_length >= 0 else None
|
|
478
|
+
|
|
479
|
+
@staticmethod
|
|
480
|
+
def _header(response: httpx.Response, name: str) -> str | None:
|
|
481
|
+
return cast(str | None, response.headers.get(name))
|
matelab/client.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from typing import Self
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
from pydantic import SecretStr, ValidationError
|
|
5
|
+
|
|
6
|
+
from matelab._generated.models import (
|
|
7
|
+
AppIdentityResponse,
|
|
8
|
+
AuthToken,
|
|
9
|
+
ChatSsoCodeExchangeRequest,
|
|
10
|
+
ChatSsoCodeExchangeResponse,
|
|
11
|
+
TokenLoginRequest,
|
|
12
|
+
TokenLoginResponse,
|
|
13
|
+
TokenRefreshRequest,
|
|
14
|
+
TokenRefreshResponse,
|
|
15
|
+
)
|
|
16
|
+
from matelab._transport import Encoding, Operation, SessionTransport, multipart_fields
|
|
17
|
+
from matelab.cloud_drive import CloudDrive
|
|
18
|
+
from matelab.errors import MatelabAuthenticationError, MatelabUsageError
|
|
19
|
+
from matelab.groups import Groups
|
|
20
|
+
from matelab.literature import Literature
|
|
21
|
+
from matelab.models import Identity, Session, Token
|
|
22
|
+
from matelab.notebooks import Notebooks
|
|
23
|
+
from matelab.records import Records
|
|
24
|
+
from matelab.templates import Templates
|
|
25
|
+
from matelab.uploads import Uploads
|
|
26
|
+
from matelab.users import Users
|
|
27
|
+
|
|
28
|
+
_DEFAULT_BASE_URL = "https://matelab.iphy.ac.cn/api"
|
|
29
|
+
|
|
30
|
+
_LOGIN = Operation(
|
|
31
|
+
method="POST",
|
|
32
|
+
path="/tokens/tokens",
|
|
33
|
+
encoding=Encoding.MULTIPART,
|
|
34
|
+
response_model=TokenLoginResponse,
|
|
35
|
+
success_codes=frozenset({0}),
|
|
36
|
+
authenticated=False,
|
|
37
|
+
authentication_errors=True,
|
|
38
|
+
)
|
|
39
|
+
_EXTERNAL_TOKEN_REFRESH = Operation(
|
|
40
|
+
method="POST",
|
|
41
|
+
path="/tokens/tokens_refresh",
|
|
42
|
+
encoding=Encoding.FORM,
|
|
43
|
+
response_model=TokenRefreshResponse,
|
|
44
|
+
success_codes=frozenset({0}),
|
|
45
|
+
authenticated=False,
|
|
46
|
+
authentication_errors=True,
|
|
47
|
+
)
|
|
48
|
+
_APP_IDENTITY = Operation(
|
|
49
|
+
method="GET",
|
|
50
|
+
path="/actions/app",
|
|
51
|
+
encoding=Encoding.NONE,
|
|
52
|
+
response_model=AppIdentityResponse,
|
|
53
|
+
success_codes=frozenset({0}),
|
|
54
|
+
authentication_errors=True,
|
|
55
|
+
retry_on_access_expired=True,
|
|
56
|
+
)
|
|
57
|
+
_CHAT_SSO = Operation(
|
|
58
|
+
method="POST",
|
|
59
|
+
path="/unlogin_eln/code_check",
|
|
60
|
+
encoding=Encoding.FORM,
|
|
61
|
+
response_model=ChatSsoCodeExchangeResponse,
|
|
62
|
+
success_codes=frozenset({0}),
|
|
63
|
+
authenticated=False,
|
|
64
|
+
authentication_errors=True,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AsyncMatelab:
|
|
69
|
+
"""One-user async client that owns at most one process-local Matelab Session.
|
|
70
|
+
|
|
71
|
+
Do not share one instance across logical user sessions. Separate instances may share an injected
|
|
72
|
+
``httpx.AsyncClient`` while retaining independent Session state and refresh locks.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
78
|
+
*,
|
|
79
|
+
session: Session | None = None,
|
|
80
|
+
timeout: float = 30.0,
|
|
81
|
+
http_client: httpx.AsyncClient | None = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Create a client without performing network I/O.
|
|
84
|
+
|
|
85
|
+
``session`` restores caller-validated persisted state. The caller remains responsible for storing
|
|
86
|
+
the latest value exposed by :attr:`session`.
|
|
87
|
+
"""
|
|
88
|
+
self._owns_http_client: bool = http_client is None
|
|
89
|
+
self._http: httpx.AsyncClient = (
|
|
90
|
+
http_client
|
|
91
|
+
if http_client is not None
|
|
92
|
+
else httpx.AsyncClient(base_url=base_url.rstrip("/"), timeout=timeout)
|
|
93
|
+
)
|
|
94
|
+
self._transport: SessionTransport = SessionTransport(self._http, session=session)
|
|
95
|
+
self.cloud_drive: CloudDrive = CloudDrive(self._transport)
|
|
96
|
+
self.groups: Groups = Groups(self._transport)
|
|
97
|
+
self.literature: Literature = Literature(self._transport)
|
|
98
|
+
self.notebooks: Notebooks = Notebooks(self._transport)
|
|
99
|
+
self.records: Records = Records(self._transport)
|
|
100
|
+
self.templates: Templates = Templates(self._transport)
|
|
101
|
+
self.uploads: Uploads = Uploads(self._transport)
|
|
102
|
+
self.users: Users = Users(self._transport)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def session(self) -> Session | None:
|
|
106
|
+
"""Return the latest Session, including tokens rotated before a later request failure."""
|
|
107
|
+
return self._transport.session
|
|
108
|
+
|
|
109
|
+
async def __aenter__(self) -> Self:
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
async def __aexit__(self, *_: object) -> None:
|
|
113
|
+
await self.aclose()
|
|
114
|
+
|
|
115
|
+
async def aclose(self) -> None:
|
|
116
|
+
if self._owns_http_client:
|
|
117
|
+
await self._http.aclose()
|
|
118
|
+
|
|
119
|
+
async def authenticate(self, username: str, password: str) -> Session:
|
|
120
|
+
"""Authenticate, install, and return the same latest instance Session."""
|
|
121
|
+
try:
|
|
122
|
+
request = TokenLoginRequest(username=username, password=SecretStr(password))
|
|
123
|
+
except ValidationError:
|
|
124
|
+
raise MatelabUsageError("Matelab username or password does not satisfy the Integration Contract.") from None
|
|
125
|
+
response = await self._transport.request(
|
|
126
|
+
_LOGIN,
|
|
127
|
+
files=multipart_fields({"username": request.username, "password": request.password.get_secret_value()}),
|
|
128
|
+
)
|
|
129
|
+
identity = Identity(userid=response.userid, username=response.username, email=response.email)
|
|
130
|
+
session = self._session_from_wire(response.access, response.refresh, identity=identity)
|
|
131
|
+
self._transport.session = session
|
|
132
|
+
return session
|
|
133
|
+
|
|
134
|
+
async def exchange_chat_sso_code(self, *, code: str, key: str) -> Session:
|
|
135
|
+
"""Consume one Chat SSO code, install its Session, and return that same Session."""
|
|
136
|
+
try:
|
|
137
|
+
request = ChatSsoCodeExchangeRequest(code=code, key=SecretStr(key))
|
|
138
|
+
except ValidationError:
|
|
139
|
+
raise MatelabUsageError("Chat SSO credentials do not satisfy the Integration Contract.") from None
|
|
140
|
+
response = await self._transport.request(
|
|
141
|
+
_CHAT_SSO, payload={"code": request.code, "key": request.key.get_secret_value()}
|
|
142
|
+
)
|
|
143
|
+
identity = Identity(userid=response.userid, username=response.username, email=response.email)
|
|
144
|
+
session = self._session_from_wire(response.token.access, response.token.refresh, identity=identity)
|
|
145
|
+
self._transport.session = session
|
|
146
|
+
return session
|
|
147
|
+
|
|
148
|
+
async def bind_external_tokens(self, access_token: str, refresh_token: str) -> Session:
|
|
149
|
+
"""Validate and exchange external tokens, then atomically install the resulting Session.
|
|
150
|
+
|
|
151
|
+
The current Session is left unchanged unless both identity checks and the refresh exchange succeed.
|
|
152
|
+
"""
|
|
153
|
+
if not access_token or not refresh_token:
|
|
154
|
+
raise MatelabAuthenticationError("Matelab access and refresh tokens must be non-empty.")
|
|
155
|
+
original_identity = await self._resolve_identity(access_token)
|
|
156
|
+
refreshed = await self._exchange_external_refresh_token(refresh_token)
|
|
157
|
+
refreshed_identity = await self._resolve_identity(refreshed.access.token)
|
|
158
|
+
if refreshed_identity.userid != original_identity.userid:
|
|
159
|
+
raise MatelabAuthenticationError("Matelab access and refresh tokens belong to different users.")
|
|
160
|
+
session = self._session_from_wire(refreshed.access, refreshed.refresh, identity=refreshed_identity)
|
|
161
|
+
self._transport.session = session
|
|
162
|
+
return session
|
|
163
|
+
|
|
164
|
+
async def refresh(self) -> Session:
|
|
165
|
+
"""Force a refresh and return the latest instance Session."""
|
|
166
|
+
return await self._transport.refresh(force=True)
|
|
167
|
+
|
|
168
|
+
async def resolve_identity(self) -> Identity:
|
|
169
|
+
identity = await self._resolve_identity()
|
|
170
|
+
session = self._transport.require_session()
|
|
171
|
+
self._transport.session = Session(access=session.access, refresh=session.refresh, identity=identity)
|
|
172
|
+
return identity
|
|
173
|
+
|
|
174
|
+
async def _resolve_identity(self, access_token: str | None = None) -> Identity:
|
|
175
|
+
response = await self._transport.request(_APP_IDENTITY, bearer_token=access_token)
|
|
176
|
+
return Identity(userid=response.user.userid, username=response.user.username, email=response.user.email)
|
|
177
|
+
|
|
178
|
+
async def _exchange_external_refresh_token(self, refresh_token: str) -> TokenRefreshResponse:
|
|
179
|
+
try:
|
|
180
|
+
request = TokenRefreshRequest(refresh_token=refresh_token)
|
|
181
|
+
except ValidationError:
|
|
182
|
+
raise MatelabAuthenticationError("The Matelab refresh token is invalid.") from None
|
|
183
|
+
return await self._transport.request(_EXTERNAL_TOKEN_REFRESH, payload=request.model_dump(mode="json"))
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _session_from_wire(access: AuthToken, refresh: AuthToken, *, identity: Identity | None) -> Session:
|
|
187
|
+
return Session(
|
|
188
|
+
access=Token(value=access.token, expires_at_ms=access.expiredAt),
|
|
189
|
+
refresh=Token(value=refresh.token, expires_at_ms=refresh.expiredAt),
|
|
190
|
+
identity=identity,
|
|
191
|
+
)
|