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.
- matilda_client/__init__.py +196 -0
- matilda_client/_client.py +379 -0
- matilda_client/_config.py +78 -0
- matilda_client/_errors.py +117 -0
- matilda_client/_events.py +173 -0
- matilda_client/_schema.py +102 -0
- matilda_client/_sse.py +320 -0
- matilda_client/_version.py +28 -0
- matilda_client/api_keys.py +44 -0
- matilda_client/auth.py +616 -0
- matilda_client/auth_local.py +598 -0
- matilda_client/chat.py +376 -0
- matilda_client/cli.py +116 -0
- matilda_client/conversations.py +45 -0
- matilda_client/feedback.py +107 -0
- matilda_client/files.py +312 -0
- matilda_client/py.typed +0 -0
- matilda_client-0.3.0.dist-info/METADATA +217 -0
- matilda_client-0.3.0.dist-info/RECORD +22 -0
- matilda_client-0.3.0.dist-info/WHEEL +5 -0
- matilda_client-0.3.0.dist-info/entry_points.txt +2 -0
- matilda_client-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -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}"
|