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/_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
+ )