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 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."""