matilda-client 0.3.0__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.
@@ -0,0 +1,196 @@
1
+ """Public Python client SDK for the Matilda API.
2
+
3
+ Async-first (httpx) client mirroring the TypeScript
4
+ ``@maincode-ai/matilda-client-sdk``: streaming chat with typed events,
5
+ grammar-constrained structured output, robust chunked parallel file uploads,
6
+ conversations, feedback (including SDK bug reports), and API-key management.
7
+
8
+ Minimal dependencies: httpx + stdlib only, per ADR-025's SDK-boundary rule.
9
+
10
+ async with MatildaClient(token="eyJ...") as client:
11
+ response = await client.chat.create(input="G'day")
12
+ print(response.output_text)
13
+ """
14
+
15
+ from ._client import MatildaClient, PublicRuntimeConfig
16
+ from ._config import (
17
+ DEFAULT_BASE_URL,
18
+ DEFAULT_CHUNK_SIZE,
19
+ DEFAULT_CHUNKED_THRESHOLD,
20
+ DEFAULT_MAX_PART_RETRIES,
21
+ DEFAULT_PART_CONCURRENCY,
22
+ MAX_FILE_COUNT,
23
+ MatildaConfig,
24
+ TokenProvider,
25
+ )
26
+ from ._errors import (
27
+ CHAT_ERROR_CODES,
28
+ ChatErrorCode,
29
+ MatildaAPIError,
30
+ MatildaContentTypeError,
31
+ MatildaError,
32
+ MatildaObjectParseError,
33
+ MatildaStreamError,
34
+ QuotaExceededError,
35
+ SafetyReplaceError,
36
+ UploadError,
37
+ )
38
+ from ._events import (
39
+ ChatEvent,
40
+ Completed,
41
+ CursorEvent,
42
+ EventCallback,
43
+ GenerationStatus,
44
+ MatildaChatResponse,
45
+ MatildaObjectEvent,
46
+ MatildaObjectResponse,
47
+ ObjectEvent,
48
+ OutputTextDelta,
49
+ OutputTextReplace,
50
+ QueuedEvent,
51
+ ResponseCreated,
52
+ ResponseError,
53
+ StatusEvent,
54
+ StreamErrorInfo,
55
+ ToolCallCompleted,
56
+ ToolCallProgress,
57
+ ToolCallStarted,
58
+ Truncated,
59
+ Usage,
60
+ UsageEvent,
61
+ )
62
+ from ._sse import DEFAULT_STALL_TIMEOUT_MS
63
+ from ._version import (
64
+ MATILDA_API_VERSION_HEADER,
65
+ MATILDA_CURRENT_API_VERSION,
66
+ sdk_version,
67
+ )
68
+ from .auth import (
69
+ AuthError,
70
+ AuthServerMetadata,
71
+ BeginLoginResult,
72
+ DeviceAuthorization,
73
+ MemoryStorage,
74
+ StorageAdapter,
75
+ TokenManager,
76
+ TokenSet,
77
+ assert_state,
78
+ base64_url_encode,
79
+ begin_login,
80
+ build_authorize_url,
81
+ complete_login,
82
+ create_pkce_pair,
83
+ create_token_manager,
84
+ exchange_code_for_tokens,
85
+ fetch_auth_server_metadata,
86
+ memory_storage,
87
+ poll_device_token,
88
+ random_state,
89
+ refresh_token_grant,
90
+ request_device_code,
91
+ )
92
+ from .auth_local import (
93
+ DEFAULT_SUCCESS_REDIRECT,
94
+ AuthorizeUrlEvent,
95
+ AuthResource,
96
+ FileTokenStore,
97
+ LoginFlowEvent,
98
+ LoopbackReceiver,
99
+ StateEvent,
100
+ UserCodeEvent,
101
+ create_file_token_store,
102
+ create_loopback_receiver,
103
+ default_device_on_event,
104
+ run_device_login_flow,
105
+ run_loopback_login_flow,
106
+ )
107
+ from .feedback import FeedbackPlatform, ReportReason
108
+ from .files import S3_MIN_CHUNK_SIZE, FileComplete
109
+
110
+ __all__ = [
111
+ "CHAT_ERROR_CODES",
112
+ "DEFAULT_BASE_URL",
113
+ "DEFAULT_CHUNK_SIZE",
114
+ "DEFAULT_CHUNKED_THRESHOLD",
115
+ "DEFAULT_MAX_PART_RETRIES",
116
+ "DEFAULT_PART_CONCURRENCY",
117
+ "DEFAULT_STALL_TIMEOUT_MS",
118
+ "DEFAULT_SUCCESS_REDIRECT",
119
+ "MATILDA_API_VERSION_HEADER",
120
+ "MATILDA_CURRENT_API_VERSION",
121
+ "MAX_FILE_COUNT",
122
+ "S3_MIN_CHUNK_SIZE",
123
+ "AuthError",
124
+ "AuthResource",
125
+ "AuthServerMetadata",
126
+ "AuthorizeUrlEvent",
127
+ "BeginLoginResult",
128
+ "ChatErrorCode",
129
+ "ChatEvent",
130
+ "Completed",
131
+ "CursorEvent",
132
+ "DeviceAuthorization",
133
+ "EventCallback",
134
+ "FeedbackPlatform",
135
+ "FileComplete",
136
+ "FileTokenStore",
137
+ "GenerationStatus",
138
+ "LoginFlowEvent",
139
+ "LoopbackReceiver",
140
+ "MatildaAPIError",
141
+ "MatildaChatResponse",
142
+ "MatildaClient",
143
+ "MatildaConfig",
144
+ "MatildaContentTypeError",
145
+ "MatildaError",
146
+ "MatildaObjectEvent",
147
+ "MatildaObjectParseError",
148
+ "MatildaObjectResponse",
149
+ "MatildaStreamError",
150
+ "MemoryStorage",
151
+ "ObjectEvent",
152
+ "OutputTextDelta",
153
+ "OutputTextReplace",
154
+ "PublicRuntimeConfig",
155
+ "QueuedEvent",
156
+ "QuotaExceededError",
157
+ "ReportReason",
158
+ "ResponseCreated",
159
+ "ResponseError",
160
+ "SafetyReplaceError",
161
+ "StateEvent",
162
+ "StatusEvent",
163
+ "StorageAdapter",
164
+ "StreamErrorInfo",
165
+ "TokenManager",
166
+ "TokenProvider",
167
+ "TokenSet",
168
+ "ToolCallCompleted",
169
+ "ToolCallProgress",
170
+ "ToolCallStarted",
171
+ "Truncated",
172
+ "UploadError",
173
+ "Usage",
174
+ "UsageEvent",
175
+ "UserCodeEvent",
176
+ "assert_state",
177
+ "base64_url_encode",
178
+ "begin_login",
179
+ "build_authorize_url",
180
+ "complete_login",
181
+ "create_file_token_store",
182
+ "create_loopback_receiver",
183
+ "create_pkce_pair",
184
+ "create_token_manager",
185
+ "default_device_on_event",
186
+ "exchange_code_for_tokens",
187
+ "fetch_auth_server_metadata",
188
+ "memory_storage",
189
+ "poll_device_token",
190
+ "random_state",
191
+ "refresh_token_grant",
192
+ "request_device_code",
193
+ "run_device_login_flow",
194
+ "run_loopback_login_flow",
195
+ "sdk_version",
196
+ ]
@@ -0,0 +1,379 @@
1
+ """Core client: configuration, header/auth plumbing, and request helpers.
2
+
3
+ ``MatildaClient`` owns the HTTP seam (``_http``, overridable in tests) and
4
+ hosts the resource attributes — files, chat, conversations, feedback,
5
+ api_keys — mirroring how the TS SDK's ``Matilda`` composes ``MatildaCore``.
6
+
7
+ Every request helper mirrors the TS fetchWithRetry pattern: on HTTP 401 with
8
+ a token provider configured, the provider is called with
9
+ ``force_refresh=True`` and the request is retried exactly once.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, replace
15
+ from typing import Any
16
+
17
+ import httpx
18
+
19
+ from ._config import DEFAULT_BASE_URL, MatildaConfig, TokenProvider
20
+ from ._errors import MatildaAPIError, QuotaExceededError, UploadError
21
+ from ._version import (
22
+ MATILDA_API_VERSION_HEADER,
23
+ SDK_LANGUAGE,
24
+ SDK_NAME,
25
+ sdk_version,
26
+ )
27
+
28
+
29
+ def _positive_int_or_none(value: Any) -> int | None:
30
+ """Only positive ints propagate: non-positive server-advertised upload
31
+ sizing counts as absent (a zero chunk size would never advance the
32
+ slicing loop; negative values are nonsense)."""
33
+ if isinstance(value, bool) or not isinstance(value, int):
34
+ return None
35
+ return value if value > 0 else None
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class PublicRuntimeConfig:
40
+ """Server-advertised upload settings (unauthenticated GET /config/public).
41
+
42
+ Defaults are the safe direction: chunking on, direct part upload OFF — so
43
+ an unreachable config endpoint never silently switches transport.
44
+ """
45
+
46
+ upload_chunk_size_bytes: int | None = None
47
+ upload_chunked_threshold_bytes: int | None = None
48
+ upload_chunked_enabled: bool = True
49
+ upload_direct_enabled: bool = False
50
+ uploads_host: str | None = None
51
+
52
+
53
+ class MatildaClient:
54
+ """Top-level Matilda API client (async-first).
55
+
56
+ Example:
57
+ async with MatildaClient(token="eyJ...") as client:
58
+ result = await client.chat.create(input="G'day")
59
+ print(result.output_text)
60
+
61
+ Also accepts a pre-built ``config=MatildaConfig(...)``; per-request Bearer
62
+ tokens can come from ``config.token`` or the async ``config.get_token``
63
+ provider (the latter enables the 401-refresh-retry path).
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ token: str = "",
69
+ *,
70
+ base_url: str = DEFAULT_BASE_URL,
71
+ get_token: TokenProvider | None = None,
72
+ config: MatildaConfig | None = None,
73
+ ) -> None:
74
+ if config is not None:
75
+ self.config = config
76
+ else:
77
+ self.config = MatildaConfig(
78
+ base_url=base_url.rstrip("/"), token=token, get_token=get_token
79
+ )
80
+
81
+ from .api_keys import ApiKeysResource
82
+ from .auth_local import AuthResource
83
+ from .chat import ChatResource
84
+ from .conversations import ConversationsResource
85
+ from .feedback import FeedbackResource
86
+ from .files import FilesResource
87
+
88
+ self.files = FilesResource(self)
89
+ self.chat = ChatResource(self)
90
+ self.conversations = ConversationsResource(self)
91
+ self.feedback = FeedbackResource(self)
92
+ self.api_keys = ApiKeysResource(self)
93
+ self.auth = AuthResource(self)
94
+
95
+ self._runtime_config: PublicRuntimeConfig | None = None
96
+
97
+ def _set_token_provider(self, provider: TokenProvider | None) -> None:
98
+ """Swap the async token provider (AuthResource wires this after
99
+ login/logout so every request carries a managed, auto-refreshing
100
+ token)."""
101
+ self.config = replace(self.config, get_token=provider)
102
+
103
+ # --- HTTP seam ---------------------------------------------------------
104
+
105
+ def _http(self) -> httpx.AsyncClient:
106
+ """Fresh AsyncClient per call.
107
+
108
+ Kept as a factory (rather than a shared client) so tests can inject a
109
+ MockTransport and so long-lived SSE streams never hold a shared
110
+ connection pool hostage. A persistent-client optimisation can revisit
111
+ this without touching call sites.
112
+ """
113
+ # trust_env=False: don't route requests through HTTP(S)_PROXY or pick
114
+ # up ~/.netrc credentials (httpx defaults to True; TS fetch has
115
+ # neither behaviour).
116
+ return httpx.AsyncClient(
117
+ timeout=httpx.Timeout(self.config.timeout, connect=10.0),
118
+ trust_env=False,
119
+ )
120
+
121
+ def _url(self, path: str, *, base: str | None = None) -> str:
122
+ """Absolute URL for ``path`` — built manually so a ``base_url`` with a
123
+ path prefix (e.g. the canonical ``.../api``) is never truncated by
124
+ RFC-3986 relative-URL joining."""
125
+ root = (base if base is not None else self.config.base_url).rstrip("/")
126
+ return f"{root}/{path.lstrip('/')}"
127
+
128
+ # --- Auth + headers ----------------------------------------------------
129
+
130
+ async def _resolve_token(
131
+ self,
132
+ *,
133
+ access_token: str | None = None,
134
+ force_refresh: bool = False,
135
+ ) -> str | None:
136
+ if access_token:
137
+ return access_token
138
+ if self.config.get_token is not None:
139
+ try:
140
+ return await self.config.get_token(force_refresh)
141
+ except Exception:
142
+ return None
143
+ return self.config.token or None
144
+
145
+ async def _build_headers(
146
+ self,
147
+ *,
148
+ json_content: bool = True,
149
+ fingerprint: str | None = None,
150
+ access_token: str | None = None,
151
+ force_refresh: bool = False,
152
+ sdk_provenance: dict[str, str] | None = None,
153
+ ) -> dict[str, str]:
154
+ """Mirrors MatildaCore.buildHeaders: API version, Bearer, fingerprint,
155
+ and SDK provenance (provenance only rides along when authenticated).
156
+
157
+ ``sdk_provenance`` swaps the provenance triplet for ONE request — used
158
+ by the agent SDK to label its own traffic without mutating the shared
159
+ client (the TS SDK's per-core ``clientProvenance`` equivalent)."""
160
+ headers: dict[str, str] = {}
161
+ if json_content:
162
+ headers["Content-Type"] = "application/json"
163
+ if self.config.api_version:
164
+ headers[MATILDA_API_VERSION_HEADER] = self.config.api_version
165
+ token = await self._resolve_token(access_token=access_token, force_refresh=force_refresh)
166
+ if fingerprint:
167
+ headers["x-fingerprint"] = fingerprint
168
+ if token:
169
+ headers["Authorization"] = f"Bearer {token}"
170
+ if sdk_provenance is not None:
171
+ headers.update(sdk_provenance)
172
+ else:
173
+ headers["x-matilda-sdk-name"] = SDK_NAME
174
+ headers["x-matilda-sdk-language"] = SDK_LANGUAGE
175
+ headers["x-matilda-sdk-version"] = sdk_version()
176
+ return headers
177
+
178
+ # --- Requests ----------------------------------------------------------
179
+
180
+ async def _stream_request(
181
+ self,
182
+ method: str,
183
+ path: str,
184
+ *,
185
+ base: str | None = None,
186
+ json_body: Any = None,
187
+ content: bytes | None = None,
188
+ files: dict[str, Any] | None = None,
189
+ headers: dict[str, str] | None = None,
190
+ params: dict[str, Any] | None = None,
191
+ access_token: str | None = None,
192
+ fingerprint: str | None = None,
193
+ sdk_provenance: dict[str, str] | None = None,
194
+ ) -> tuple[httpx.AsyncClient, httpx.Response]:
195
+ """One streamed request with the 401 → force-refresh → retry-once dance.
196
+
197
+ Shared by the client's JSON helpers, the files resource's control-plane
198
+ calls, the chat SSE loop, and the agent SDK's SSE loops (which consume
199
+ it via ``MatildaClientProtocol``). Error mapping is deliberately left
200
+ to the caller: the client SDK maps through ``_raise_api`` (or
201
+ ``_raise_for_status`` for uploads) while the agent SDK raises
202
+ ``MatildaAgentRunError``.
203
+
204
+ Non-JSON bodies: pass ``content`` (raw bytes) or ``files`` (multipart)
205
+ instead of ``json_body`` — the JSON ``Content-Type`` is then dropped so
206
+ httpx can set the correct one; caller ``headers`` merge over the built
207
+ headers (e.g. ``Content-Type: application/octet-stream`` for a raw
208
+ part).
209
+
210
+ Returns the ``(http, response)`` pair with ``stream=True`` still open.
211
+ The caller owns both lifecycles: when the stream is exhausted (or the
212
+ caller bails out early), it must ``await resp.aclose()`` on a present
213
+ response and always ``await http.aclose()``.
214
+ """
215
+ http = self._http()
216
+ resp: httpx.Response | None = None
217
+ token_override = access_token
218
+ try:
219
+ for attempt in (0, 1):
220
+ req_headers = await self._build_headers(
221
+ json_content=content is None and files is None,
222
+ access_token=token_override,
223
+ fingerprint=fingerprint,
224
+ sdk_provenance=sdk_provenance,
225
+ )
226
+ if headers:
227
+ req_headers.update(headers)
228
+ req = http.build_request(
229
+ method,
230
+ self._url(path, base=base),
231
+ json=json_body,
232
+ content=content,
233
+ files=files,
234
+ params=params,
235
+ headers=req_headers,
236
+ )
237
+ resp = await http.send(req, stream=True)
238
+ if (
239
+ resp.status_code == 401
240
+ and attempt == 0
241
+ and self.config.get_token is not None
242
+ ):
243
+ fresh = await self._resolve_token(force_refresh=True)
244
+ if fresh:
245
+ await resp.aclose()
246
+ token_override = fresh
247
+ continue
248
+ break
249
+ except BaseException:
250
+ await http.aclose()
251
+ raise
252
+ if resp is None: # pragma: no cover - loop always assigns before break
253
+ await http.aclose()
254
+ raise RuntimeError("unreachable")
255
+ return http, resp
256
+
257
+ async def _request(
258
+ self,
259
+ method: str,
260
+ path: str,
261
+ *,
262
+ base: str | None = None,
263
+ json_body: Any = None,
264
+ content: bytes | None = None,
265
+ files: dict[str, Any] | None = None,
266
+ headers: dict[str, str] | None = None,
267
+ params: dict[str, Any] | None = None,
268
+ access_token: str | None = None,
269
+ fingerprint: str | None = None,
270
+ ) -> httpx.Response:
271
+ """One buffered request with the 401 → force-refresh → retry-once dance."""
272
+ http, resp = await self._stream_request(
273
+ method,
274
+ path,
275
+ base=base,
276
+ json_body=json_body,
277
+ content=content,
278
+ files=files,
279
+ headers=headers,
280
+ params=params,
281
+ access_token=access_token,
282
+ fingerprint=fingerprint,
283
+ )
284
+ try:
285
+ await resp.aread()
286
+ return resp
287
+ finally:
288
+ await resp.aclose()
289
+ await http.aclose()
290
+
291
+ async def _request_json(self, method: str, path: str, **kwargs: Any) -> Any:
292
+ resp = await self._request(method, path, **kwargs)
293
+ self._raise_api(resp)
294
+ return resp.json()
295
+
296
+ async def _request_void(self, method: str, path: str, **kwargs: Any) -> None:
297
+ resp = await self._request(method, path, **kwargs)
298
+ self._raise_api(resp)
299
+
300
+ def _raise_api(self, resp: httpx.Response) -> None:
301
+ if resp.status_code < 400:
302
+ return
303
+ if resp.status_code == 429:
304
+ quota = _parse_quota(resp)
305
+ if quota is not None:
306
+ raise QuotaExceededError(quota, resp.text)
307
+ raise MatildaAPIError(resp.status_code, resp.text)
308
+
309
+ # --- Legacy upload error mapping (files resource) ----------------------
310
+
311
+ def _raise_for_status(self, resp: httpx.Response) -> None:
312
+ if resp.status_code >= 400:
313
+ body = resp.text
314
+ if resp.status_code == 413:
315
+ raise UploadError(f"File too large or quota exceeded (413): {body}")
316
+ if resp.status_code == 401:
317
+ raise UploadError(f"Authentication failed (401): {body}")
318
+ raise UploadError(f"Matilda API error ({resp.status_code}): {body}")
319
+
320
+ # --- Public runtime config ----------------------------------------------
321
+
322
+ async def fetch_public_runtime_config(self) -> PublicRuntimeConfig:
323
+ """Unauthenticated GET /config/public — server-advertised upload
324
+ settings (chunked/direct mode, chunk sizes, trusted uploads host).
325
+ Raises MatildaAPIError on non-2xx; fail-open callers should use
326
+ ``_ensure_runtime_config``."""
327
+ headers = (
328
+ {MATILDA_API_VERSION_HEADER: self.config.api_version}
329
+ if self.config.api_version
330
+ else {}
331
+ )
332
+ async with self._http() as http:
333
+ resp = await http.get(self._url("/config/public"), headers=headers)
334
+ self._raise_api(resp)
335
+ data = resp.json()
336
+ return PublicRuntimeConfig(
337
+ upload_chunk_size_bytes=_positive_int_or_none(data.get("uploadChunkSizeBytes")),
338
+ upload_chunked_threshold_bytes=_positive_int_or_none(
339
+ data.get("uploadChunkedThresholdBytes")
340
+ ),
341
+ upload_chunked_enabled=data.get("uploadChunkedEnabled") is not False,
342
+ upload_direct_enabled=data.get("uploadDirectEnabled") is True,
343
+ uploads_host=data.get("uploadsHost") or None,
344
+ )
345
+
346
+ async def _ensure_runtime_config(self) -> PublicRuntimeConfig:
347
+ """Fetch-once, client-lifetime cached runtime config. Fail-open: any
348
+ error yields the defaults (chunked on, direct OFF) so a config outage
349
+ never switches transport — mirrors the TS SDK's runtime-config
350
+ resolution (defaults take the safe direction)."""
351
+ if self._runtime_config is None:
352
+ try:
353
+ self._runtime_config = await self.fetch_public_runtime_config()
354
+ except Exception:
355
+ self._runtime_config = PublicRuntimeConfig()
356
+ return self._runtime_config
357
+
358
+ # --- Lifecycle ---------------------------------------------------------
359
+
360
+ async def aclose(self) -> None:
361
+ """No shared connections to close today; kept so ``async with`` has a
362
+ stable contract if a persistent client is introduced."""
363
+
364
+ async def __aenter__(self) -> MatildaClient:
365
+ return self
366
+
367
+ async def __aexit__(self, *exc_info: object) -> None:
368
+ await self.aclose()
369
+
370
+
371
+ def _parse_quota(resp: httpx.Response) -> dict | None:
372
+ """Structured ``quota_exceeded`` body, or None (mirrors parseQuotaExceeded)."""
373
+ try:
374
+ body = resp.json()
375
+ except Exception:
376
+ return None
377
+ if isinstance(body, dict) and body.get("error") == "quota_exceeded":
378
+ return body
379
+ return None
@@ -0,0 +1,78 @@
1
+ """Client configuration, mirroring the TS SDK's ClientConfig."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+ from dataclasses import dataclass
7
+ from urllib.parse import urlsplit
8
+
9
+ from ._errors import MatildaError, UploadError
10
+ from ._version import MATILDA_CURRENT_API_VERSION
11
+
12
+ DEFAULT_BASE_URL = "https://matilda.maincode.com/api"
13
+ DEFAULT_CHUNK_SIZE = 8 * 1024 * 1024 # 8 MiB — mirrors core-api default
14
+ DEFAULT_CHUNKED_THRESHOLD = 16 * 1024 * 1024 # 16 MiB
15
+ DEFAULT_PART_CONCURRENCY = 4
16
+ DEFAULT_MAX_PART_RETRIES = 3
17
+ MAX_FILE_COUNT = 5
18
+
19
+ # Async token provider: called with ``force_refresh`` and expected to return a
20
+ # Bearer token or None. Wire this to a TokenManager-style refresher; on a 401 the
21
+ # client calls it with ``force_refresh=True`` and retries the request once.
22
+ TokenProvider = Callable[[bool], Awaitable[str | None]]
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class MatildaConfig:
27
+ """Client configuration.
28
+
29
+ ``token`` is a static Bearer JWT (e.g. from the matilda-code OAuth/PKCE
30
+ flow). For automatic refresh, pass ``get_token`` instead — an async
31
+ callable invoked on every request (and once more with
32
+ ``force_refresh=True`` after a 401, before the single retry).
33
+ """
34
+
35
+ base_url: str = DEFAULT_BASE_URL
36
+ token: str = ""
37
+ get_token: TokenProvider | None = None
38
+ timeout: float = 120.0
39
+ api_version: str = MATILDA_CURRENT_API_VERSION
40
+ chunk_size: int = DEFAULT_CHUNK_SIZE
41
+ chunked_threshold: int = DEFAULT_CHUNKED_THRESHOLD
42
+ part_concurrency: int = DEFAULT_PART_CONCURRENCY
43
+ max_part_retries: int = DEFAULT_MAX_PART_RETRIES
44
+
45
+ def __post_init__(self) -> None:
46
+ """Reject upload-control values that would hang or corrupt uploads:
47
+ a zero/negative chunk_size never advances the slicing loop, and
48
+ zero/negative concurrency deadlocks the part semaphore."""
49
+ if self.chunk_size <= 0:
50
+ raise ValueError(f"chunk_size must be positive (got {self.chunk_size})")
51
+ if self.chunked_threshold <= 0:
52
+ raise ValueError(f"chunked_threshold must be positive (got {self.chunked_threshold})")
53
+ if self.part_concurrency <= 0:
54
+ raise ValueError(f"part_concurrency must be positive (got {self.part_concurrency})")
55
+ if self.max_part_retries < 0:
56
+ raise ValueError(f"max_part_retries must be nonnegative (got {self.max_part_retries})")
57
+
58
+ @property
59
+ def auth_header(self) -> dict[str, str]:
60
+ """Static Bearer header built from ``token`` (raises when unset).
61
+
62
+ Prefer the awaited header builders on MatildaClient when a token
63
+ provider is configured — they can refresh.
64
+ """
65
+ if not self.token:
66
+ raise UploadError("No bearer token configured — call login() or pass token=.")
67
+ return {"Authorization": f"Bearer {self.token}"}
68
+
69
+ @property
70
+ def origin(self) -> str:
71
+ """Scheme+host of ``base_url`` — origin where core-auth endpoints live."""
72
+ parts = urlsplit(self.base_url)
73
+ if not parts.scheme or not parts.netloc:
74
+ raise MatildaError(
75
+ f'Auth flows require an absolute base URL (got "{self.base_url}"). '
76
+ 'Pass an absolute URL like "https://matilda.example/api".'
77
+ )
78
+ return f"{parts.scheme}://{parts.netloc}"