mango-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.
- mango_sdk/__init__.py +14 -0
- mango_sdk/_client.py +289 -0
- mango_sdk/_errors.py +77 -0
- mango_sdk/_generated.py +2926 -0
- mango_sdk/_streaming.py +291 -0
- mango_sdk/_types.py +31 -0
- mango_sdk/_version.py +3 -0
- mango_sdk/models.py +2954 -0
- mango_sdk/py.typed +0 -0
- mango_sdk-0.1.0a1.dist-info/METADATA +191 -0
- mango_sdk-0.1.0a1.dist-info/RECORD +13 -0
- mango_sdk-0.1.0a1.dist-info/WHEEL +4 -0
- mango_sdk-0.1.0a1.dist-info/licenses/LICENSE +202 -0
mango_sdk/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Mango's independent self-hosted runtime SDK. This is a development API."""
|
|
2
|
+
|
|
3
|
+
from . import models
|
|
4
|
+
from ._errors import APIError, MangoError, PaginationError, ResponseDecodeError
|
|
5
|
+
from ._generated import AsyncMango, Mango
|
|
6
|
+
from ._streaming import AsyncBinaryStream, AsyncSSEStream, BinaryStream, SSEStream, ServerSentEvent
|
|
7
|
+
from ._types import NOT_GIVEN, NotGiven, Upload
|
|
8
|
+
from ._version import __version__
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"APIError", "AsyncBinaryStream", "AsyncMango", "AsyncSSEStream", "BinaryStream",
|
|
12
|
+
"Mango", "MangoError", "NOT_GIVEN", "NotGiven", "PaginationError",
|
|
13
|
+
"ResponseDecodeError", "SSEStream", "ServerSentEvent", "Upload", "__version__", "models",
|
|
14
|
+
]
|
mango_sdk/_client.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Shared transport; never retries mutations or forwards bearer auth on redirects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, Iterator, Mapping
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import quote, urlsplit
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ._errors import APIError, PaginationError, ResponseDecodeError, read_api_error, read_async_api_error
|
|
13
|
+
from ._streaming import AsyncBinaryStream, AsyncSSEStream, BinaryStream, SSEStream
|
|
14
|
+
from ._types import NOT_GIVEN, NotGiven, Upload
|
|
15
|
+
from ._version import __version__
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _url(base_url: str, path: str, values: Mapping[str, Any]) -> str:
|
|
19
|
+
for key, value in values.items():
|
|
20
|
+
encoded = quote(str(value), safe="")
|
|
21
|
+
if encoded in (".", ".."):
|
|
22
|
+
encoded = encoded.replace(".", "%2E")
|
|
23
|
+
path = path.replace("{" + key + "}", encoded)
|
|
24
|
+
return base_url + path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _clean(value: Any) -> Any:
|
|
28
|
+
if isinstance(value, dict):
|
|
29
|
+
return {key: _clean(item) for key, item in value.items() if not isinstance(item, NotGiven)}
|
|
30
|
+
if isinstance(value, (list, tuple)):
|
|
31
|
+
if any(isinstance(item, NotGiven) for item in value):
|
|
32
|
+
raise TypeError("NOT_GIVEN is not a valid array element")
|
|
33
|
+
return [_clean(item) for item in value]
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _scalar(value: Any) -> str:
|
|
38
|
+
if value is None:
|
|
39
|
+
return ""
|
|
40
|
+
if isinstance(value, bool):
|
|
41
|
+
return "true" if value else "false"
|
|
42
|
+
return str(value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _query(values: Mapping[str, Any]) -> list[tuple[str, str]]:
|
|
46
|
+
result: list[tuple[str, str]] = []
|
|
47
|
+
for name, value in values.items():
|
|
48
|
+
if isinstance(value, NotGiven):
|
|
49
|
+
continue
|
|
50
|
+
if isinstance(value, (list, tuple)):
|
|
51
|
+
result.extend((name, _scalar(item)) for item in value)
|
|
52
|
+
else:
|
|
53
|
+
result.append((name, _scalar(value)))
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _multipart(body: Mapping[str, Any]) -> list[tuple[str, Any]]:
|
|
58
|
+
parts: list[tuple[str, Any]] = []
|
|
59
|
+
for name, value in body.items():
|
|
60
|
+
if isinstance(value, NotGiven):
|
|
61
|
+
continue
|
|
62
|
+
for item in value if isinstance(value, list) else [value]:
|
|
63
|
+
if isinstance(item, Upload):
|
|
64
|
+
parts.append((name, (item.filename, item.content, item.content_type)))
|
|
65
|
+
elif item is None:
|
|
66
|
+
raise TypeError("Multipart fields cannot be null")
|
|
67
|
+
else:
|
|
68
|
+
parts.append((name, (None, _scalar(item))))
|
|
69
|
+
return parts
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _check_base(base_url: str) -> str:
|
|
73
|
+
parts = urlsplit(base_url)
|
|
74
|
+
if parts.scheme not in ("http", "https") or not parts.netloc:
|
|
75
|
+
raise ValueError("base_url must be an absolute http(s) URL")
|
|
76
|
+
if parts.query or parts.fragment or parts.username or parts.password:
|
|
77
|
+
raise ValueError("base_url cannot contain credentials, a query, or a fragment")
|
|
78
|
+
return base_url.rstrip("/")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _next_page(page: Mapping[str, Any], query: dict[str, Any]) -> bool:
|
|
82
|
+
"""Mutate a local query copy, preserving Mango's two different cursor families."""
|
|
83
|
+
if "next_page" in page:
|
|
84
|
+
cursor = page["next_page"]
|
|
85
|
+
if cursor is None or cursor == "":
|
|
86
|
+
return False
|
|
87
|
+
key = "page"
|
|
88
|
+
elif page.get("has_more"):
|
|
89
|
+
key = "before_id" if query.get("before_id", NOT_GIVEN) not in (NOT_GIVEN, None, "") else "after_id"
|
|
90
|
+
cursor = page.get("first_id" if key == "before_id" else "last_id")
|
|
91
|
+
if not cursor:
|
|
92
|
+
raise PaginationError("Files page has_more=true without a usable boundary ID")
|
|
93
|
+
else:
|
|
94
|
+
return False
|
|
95
|
+
if cursor == query.get(key):
|
|
96
|
+
raise PaginationError("Server repeated its pagination cursor")
|
|
97
|
+
query[key] = cursor
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _decode(response: httpx.Response, mode: str) -> Any:
|
|
102
|
+
if not response.is_success:
|
|
103
|
+
raise APIError(response)
|
|
104
|
+
if mode == "empty":
|
|
105
|
+
return None
|
|
106
|
+
if mode == "text":
|
|
107
|
+
return response.text
|
|
108
|
+
try:
|
|
109
|
+
return response.json()
|
|
110
|
+
except ValueError as error:
|
|
111
|
+
raise ResponseDecodeError(
|
|
112
|
+
f"Invalid JSON in HTTP {response.status_code} response "
|
|
113
|
+
f"(request-id={response.headers.get('request-id')!r})"
|
|
114
|
+
) from error
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class _RequestBuilder:
|
|
118
|
+
_http: httpx.Client | httpx.AsyncClient
|
|
119
|
+
_base_url: str
|
|
120
|
+
_api_key: str | None
|
|
121
|
+
_stream_timeout: httpx.Timeout
|
|
122
|
+
|
|
123
|
+
def _build(
|
|
124
|
+
self, operation: Mapping[str, Any], path: Mapping[str, Any],
|
|
125
|
+
query: Mapping[str, Any], body: Any = NOT_GIVEN,
|
|
126
|
+
) -> httpx.Request:
|
|
127
|
+
headers = {"Accept": operation["accept"]}
|
|
128
|
+
if not operation["public"] and self._api_key:
|
|
129
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
130
|
+
kwargs: dict[str, Any] = {
|
|
131
|
+
"params": _query(query), "headers": headers,
|
|
132
|
+
}
|
|
133
|
+
if not isinstance(body, NotGiven):
|
|
134
|
+
if operation["request"] == "multipart":
|
|
135
|
+
kwargs["files"] = _multipart(body)
|
|
136
|
+
else:
|
|
137
|
+
# httpx treats json=None as no body. Encode null explicitly instead.
|
|
138
|
+
import json
|
|
139
|
+
kwargs["content"] = json.dumps(_clean(body), ensure_ascii=False).encode("utf-8")
|
|
140
|
+
headers["Content-Type"] = "application/json"
|
|
141
|
+
if operation["mode"] in ("sse", "binary"):
|
|
142
|
+
kwargs["timeout"] = self._stream_timeout
|
|
143
|
+
return self._http.build_request(
|
|
144
|
+
operation["method"], _url(self._base_url, operation["path"], path), **kwargs,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class BaseClient(_RequestBuilder):
|
|
149
|
+
"""HTTPX-backed client. Timeout is per network operation, not a total deadline.
|
|
150
|
+
|
|
151
|
+
No requests are retried automatically. An ambiguous mutation may already be
|
|
152
|
+
committed; reconcile persisted server state before deciding to resubmit it.
|
|
153
|
+
``transport`` is an explicit seam for local testing or custom networking.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
_http: httpx.Client
|
|
157
|
+
|
|
158
|
+
def __init__(
|
|
159
|
+
self, *, base_url: str = "http://localhost:8080", api_key: str | None = None,
|
|
160
|
+
timeout: float | httpx.Timeout = 30.0,
|
|
161
|
+
stream_timeout: httpx.Timeout | None = None,
|
|
162
|
+
transport: httpx.BaseTransport | None = None,
|
|
163
|
+
) -> None:
|
|
164
|
+
self._base_url = _check_base(base_url)
|
|
165
|
+
self._api_key = api_key
|
|
166
|
+
self._stream_timeout = stream_timeout or httpx.Timeout(30.0, read=None)
|
|
167
|
+
self._http = httpx.Client(
|
|
168
|
+
timeout=timeout, transport=transport, follow_redirects=False,
|
|
169
|
+
headers={"User-Agent": f"mango-sdk-python/{__version__}"},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def __enter__(self) -> BaseClient:
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
def __exit__(
|
|
176
|
+
self, exc_type: type[BaseException] | None,
|
|
177
|
+
exc_value: BaseException | None, traceback: TracebackType | None,
|
|
178
|
+
) -> None:
|
|
179
|
+
self.close()
|
|
180
|
+
|
|
181
|
+
def close(self) -> None:
|
|
182
|
+
self._http.close()
|
|
183
|
+
|
|
184
|
+
def _request(
|
|
185
|
+
self, operation: Mapping[str, Any], path: Mapping[str, Any],
|
|
186
|
+
query: Mapping[str, Any], body: Any = NOT_GIVEN,
|
|
187
|
+
) -> Any:
|
|
188
|
+
response = self._http.send(self._build(operation, path, query, body), stream=True)
|
|
189
|
+
try:
|
|
190
|
+
if not response.is_success:
|
|
191
|
+
raise read_api_error(response)
|
|
192
|
+
response.read()
|
|
193
|
+
return _decode(response, operation["mode"])
|
|
194
|
+
finally:
|
|
195
|
+
response.close()
|
|
196
|
+
|
|
197
|
+
def _stream(
|
|
198
|
+
self, operation: Mapping[str, Any], path: Mapping[str, Any], query: Mapping[str, Any],
|
|
199
|
+
) -> SSEStream:
|
|
200
|
+
return SSEStream(self._http, self._build(operation, path, query))
|
|
201
|
+
|
|
202
|
+
def _binary(
|
|
203
|
+
self, operation: Mapping[str, Any], path: Mapping[str, Any], query: Mapping[str, Any],
|
|
204
|
+
) -> BinaryStream:
|
|
205
|
+
return BinaryStream(self._http, self._build(operation, path, query))
|
|
206
|
+
|
|
207
|
+
def _paginate(
|
|
208
|
+
self, operation: Mapping[str, Any], path: Mapping[str, Any], query: dict[str, Any],
|
|
209
|
+
) -> Iterator[Any]:
|
|
210
|
+
seen: set[str] = set()
|
|
211
|
+
while True:
|
|
212
|
+
result = self._request(operation, path, query)
|
|
213
|
+
yield from result["data"]
|
|
214
|
+
if not _next_page(result, query):
|
|
215
|
+
return
|
|
216
|
+
fingerprint = repr(query)
|
|
217
|
+
if fingerprint in seen:
|
|
218
|
+
raise PaginationError("Server cycled through pagination cursors")
|
|
219
|
+
seen.add(fingerprint)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class AsyncBaseClient(_RequestBuilder):
|
|
223
|
+
"""Asynchronous equivalent of BaseClient; cancellation propagates to HTTPX."""
|
|
224
|
+
|
|
225
|
+
_http: httpx.AsyncClient
|
|
226
|
+
|
|
227
|
+
def __init__(
|
|
228
|
+
self, *, base_url: str = "http://localhost:8080", api_key: str | None = None,
|
|
229
|
+
timeout: float | httpx.Timeout = 30.0,
|
|
230
|
+
stream_timeout: httpx.Timeout | None = None,
|
|
231
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
232
|
+
) -> None:
|
|
233
|
+
self._base_url = _check_base(base_url)
|
|
234
|
+
self._api_key = api_key
|
|
235
|
+
self._stream_timeout = stream_timeout or httpx.Timeout(30.0, read=None)
|
|
236
|
+
self._http = httpx.AsyncClient(
|
|
237
|
+
timeout=timeout, transport=transport, follow_redirects=False,
|
|
238
|
+
headers={"User-Agent": f"mango-sdk-python/{__version__}"},
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
async def __aenter__(self) -> AsyncBaseClient:
|
|
242
|
+
return self
|
|
243
|
+
|
|
244
|
+
async def __aexit__(
|
|
245
|
+
self, exc_type: type[BaseException] | None,
|
|
246
|
+
exc_value: BaseException | None, traceback: TracebackType | None,
|
|
247
|
+
) -> None:
|
|
248
|
+
await self.aclose()
|
|
249
|
+
|
|
250
|
+
async def aclose(self) -> None:
|
|
251
|
+
await self._http.aclose()
|
|
252
|
+
|
|
253
|
+
async def _request(
|
|
254
|
+
self, operation: Mapping[str, Any], path: Mapping[str, Any],
|
|
255
|
+
query: Mapping[str, Any], body: Any = NOT_GIVEN,
|
|
256
|
+
) -> Any:
|
|
257
|
+
response = await self._http.send(self._build(operation, path, query, body), stream=True)
|
|
258
|
+
try:
|
|
259
|
+
if not response.is_success:
|
|
260
|
+
raise await read_async_api_error(response)
|
|
261
|
+
await response.aread()
|
|
262
|
+
return _decode(response, operation["mode"])
|
|
263
|
+
finally:
|
|
264
|
+
await response.aclose()
|
|
265
|
+
|
|
266
|
+
def _stream(
|
|
267
|
+
self, operation: Mapping[str, Any], path: Mapping[str, Any], query: Mapping[str, Any],
|
|
268
|
+
) -> AsyncSSEStream:
|
|
269
|
+
return AsyncSSEStream(self._http, self._build(operation, path, query))
|
|
270
|
+
|
|
271
|
+
def _binary(
|
|
272
|
+
self, operation: Mapping[str, Any], path: Mapping[str, Any], query: Mapping[str, Any],
|
|
273
|
+
) -> AsyncBinaryStream:
|
|
274
|
+
return AsyncBinaryStream(self._http, self._build(operation, path, query))
|
|
275
|
+
|
|
276
|
+
async def _paginate(
|
|
277
|
+
self, operation: Mapping[str, Any], path: Mapping[str, Any], query: dict[str, Any],
|
|
278
|
+
) -> AsyncIterator[Any]:
|
|
279
|
+
seen: set[str] = set()
|
|
280
|
+
while True:
|
|
281
|
+
result = await self._request(operation, path, query)
|
|
282
|
+
for item in result["data"]:
|
|
283
|
+
yield item
|
|
284
|
+
if not _next_page(result, query):
|
|
285
|
+
return
|
|
286
|
+
fingerprint = repr(query)
|
|
287
|
+
if fingerprint in seen:
|
|
288
|
+
raise PaginationError("Server cycled through pagination cursors")
|
|
289
|
+
seen.add(fingerprint)
|
mango_sdk/_errors.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Mango errors preserve the HTTP status and server correlation identifier."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
MAX_ERROR_BODY_BYTES = 64 * 1024
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MangoError(Exception):
|
|
14
|
+
"""Base class for SDK protocol and HTTP errors (not transport failures)."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class APIError(MangoError):
|
|
18
|
+
def __init__(
|
|
19
|
+
self, response: httpx.Response, *, body: bytes | None = None, truncated: bool = False,
|
|
20
|
+
) -> None:
|
|
21
|
+
self.status_code = response.status_code
|
|
22
|
+
self.response = response
|
|
23
|
+
self.request_id = response.headers.get("request-id")
|
|
24
|
+
self.type: str | None = None
|
|
25
|
+
self.body: Any = None
|
|
26
|
+
raw = response.content if body is None else body
|
|
27
|
+
self.body_truncated = truncated or len(raw) > MAX_ERROR_BODY_BYTES
|
|
28
|
+
raw = raw[:MAX_ERROR_BODY_BYTES]
|
|
29
|
+
try:
|
|
30
|
+
self.body = json.loads(raw)
|
|
31
|
+
except ValueError:
|
|
32
|
+
pass
|
|
33
|
+
message = raw.decode("utf-8", errors="replace")[:4096] or response.reason_phrase
|
|
34
|
+
if isinstance(self.body, dict):
|
|
35
|
+
self.request_id = self.body.get("request_id") or self.request_id
|
|
36
|
+
error = self.body.get("error")
|
|
37
|
+
if isinstance(error, dict):
|
|
38
|
+
self.type = error.get("type")
|
|
39
|
+
message = error.get("message") or message
|
|
40
|
+
if self.body_truncated:
|
|
41
|
+
message += " [error body truncated]"
|
|
42
|
+
super().__init__(f"HTTP {self.status_code}: {message}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def read_api_error(response: httpx.Response) -> APIError:
|
|
46
|
+
"""Bound diagnostics even if a reverse proxy returns a huge response body."""
|
|
47
|
+
data = bytearray()
|
|
48
|
+
try:
|
|
49
|
+
for chunk in response.iter_bytes():
|
|
50
|
+
data.extend(chunk[:MAX_ERROR_BODY_BYTES - len(data)])
|
|
51
|
+
if len(data) >= MAX_ERROR_BODY_BYTES:
|
|
52
|
+
break
|
|
53
|
+
# Do not wait for one extra byte to prove truncation: streaming routes
|
|
54
|
+
# have no read deadline and an error response can stall at the bound.
|
|
55
|
+
return APIError(response, body=bytes(data), truncated=len(data) >= MAX_ERROR_BODY_BYTES)
|
|
56
|
+
finally:
|
|
57
|
+
response.close()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def read_async_api_error(response: httpx.Response) -> APIError:
|
|
61
|
+
data = bytearray()
|
|
62
|
+
try:
|
|
63
|
+
async for chunk in response.aiter_bytes():
|
|
64
|
+
data.extend(chunk[:MAX_ERROR_BODY_BYTES - len(data)])
|
|
65
|
+
if len(data) >= MAX_ERROR_BODY_BYTES:
|
|
66
|
+
break
|
|
67
|
+
return APIError(response, body=bytes(data), truncated=len(data) >= MAX_ERROR_BODY_BYTES)
|
|
68
|
+
finally:
|
|
69
|
+
await response.aclose()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ResponseDecodeError(MangoError):
|
|
73
|
+
"""A successful response did not contain the documented JSON payload."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class PaginationError(MangoError):
|
|
77
|
+
"""A server pagination response would otherwise cause an infinite loop."""
|