thunder-solution-sdk 1.0.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.
- thunder_slip/__init__.py +12 -0
- thunder_slip/_base_error.py +8 -0
- thunder_slip/_codec.py +102 -0
- thunder_slip/_transport.py +282 -0
- thunder_slip/client.py +860 -0
- thunder_slip/errors.py +367 -0
- thunder_slip/files.py +59 -0
- thunder_slip/models.py +600 -0
- thunder_slip/py.typed +0 -0
- thunder_slip/webhook.py +26 -0
- thunder_slip/webhooks.py +25 -0
- thunder_solution_sdk-1.0.0.dist-info/METADATA +452 -0
- thunder_solution_sdk-1.0.0.dist-info/RECORD +16 -0
- thunder_solution_sdk-1.0.0.dist-info/WHEEL +5 -0
- thunder_solution_sdk-1.0.0.dist-info/licenses/LICENSE +21 -0
- thunder_solution_sdk-1.0.0.dist-info/top_level.txt +1 -0
thunder_slip/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Typed sync and async clients for the public slip API."""
|
|
2
|
+
|
|
3
|
+
from .client import Client as Client, AsyncClient as AsyncClient
|
|
4
|
+
from .models import AccountType as AccountType, QrType as QrType
|
|
5
|
+
from ._codec import UNSET as UNSET
|
|
6
|
+
from ._base_error import SDKError as SDKError
|
|
7
|
+
|
|
8
|
+
__version__ = "1.0.0" # x-release-please-version
|
|
9
|
+
|
|
10
|
+
from ._base_error import ThunderError as ThunderError
|
|
11
|
+
|
|
12
|
+
Thunder = Client
|
thunder_slip/_codec.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Dataclass wire conversion with separate absent and null values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import MISSING, fields, is_dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
import types
|
|
9
|
+
from typing import Any, Literal, TypeVar, Union, cast, get_args, get_origin, get_type_hints
|
|
10
|
+
|
|
11
|
+
from ._base_error import SDKError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UnsetType(Enum):
|
|
15
|
+
"""An absent JSON field, distinct from an explicit null."""
|
|
16
|
+
|
|
17
|
+
UNSET = 0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
UNSET = UnsetType.UNSET
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DecodeError(SDKError):
|
|
24
|
+
"""The response does not match the expected model."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
T = TypeVar("T")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def decode(model: type[T], value: object) -> T:
|
|
31
|
+
"""Decode a JSON object into a dataclass without flattening nested models."""
|
|
32
|
+
return cast(T, _decode(model, value))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _decode(annotation: Any, value: Any) -> Any:
|
|
36
|
+
origin = get_origin(annotation)
|
|
37
|
+
args = get_args(annotation)
|
|
38
|
+
if annotation is Any:
|
|
39
|
+
return value
|
|
40
|
+
if origin in (Union, types.UnionType):
|
|
41
|
+
for candidate in args:
|
|
42
|
+
if candidate is UnsetType:
|
|
43
|
+
continue
|
|
44
|
+
try:
|
|
45
|
+
return _decode(candidate, value)
|
|
46
|
+
except DecodeError:
|
|
47
|
+
pass
|
|
48
|
+
raise DecodeError("Response field has an unexpected type")
|
|
49
|
+
if origin is Literal:
|
|
50
|
+
if any(type(value) is type(option) and value == option for option in args):
|
|
51
|
+
return value
|
|
52
|
+
raise DecodeError("Response field has an unexpected value")
|
|
53
|
+
if annotation is type(None):
|
|
54
|
+
if value is None:
|
|
55
|
+
return None
|
|
56
|
+
raise DecodeError("Expected a null response field")
|
|
57
|
+
if is_dataclass(annotation):
|
|
58
|
+
if not isinstance(value, dict):
|
|
59
|
+
raise DecodeError("Expected a response object")
|
|
60
|
+
hints = get_type_hints(annotation)
|
|
61
|
+
kwargs: dict[str, Any] = {}
|
|
62
|
+
for item in fields(annotation):
|
|
63
|
+
if not item.init:
|
|
64
|
+
continue
|
|
65
|
+
key = item.metadata.get("wire", item.name)
|
|
66
|
+
if key in value:
|
|
67
|
+
kwargs[item.name] = _decode(hints[item.name], value[key])
|
|
68
|
+
elif item.default is MISSING and item.default_factory is MISSING:
|
|
69
|
+
raise DecodeError(f"Missing response field: {item.name}")
|
|
70
|
+
return cast(Callable[..., Any], annotation)(**kwargs)
|
|
71
|
+
if origin is list:
|
|
72
|
+
if not isinstance(value, list):
|
|
73
|
+
raise DecodeError("Expected a response array")
|
|
74
|
+
return [_decode(args[0], item) for item in value]
|
|
75
|
+
if origin is dict:
|
|
76
|
+
if not isinstance(value, dict):
|
|
77
|
+
raise DecodeError("Expected a response object")
|
|
78
|
+
return {_decode(args[0], key): _decode(args[1], item) for key, item in value.items()}
|
|
79
|
+
if annotation in (str, int, bool):
|
|
80
|
+
if type(value) is not annotation:
|
|
81
|
+
raise DecodeError("Response field has an unexpected type")
|
|
82
|
+
return value
|
|
83
|
+
if annotation is float and type(value) in (int, float):
|
|
84
|
+
return float(value)
|
|
85
|
+
raise DecodeError("Unsupported response field type")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def encode(value: object) -> Any:
|
|
89
|
+
"""Encode dataclasses, omitting absent fields and preserving explicit nulls."""
|
|
90
|
+
if is_dataclass(value) and not isinstance(value, type):
|
|
91
|
+
return {
|
|
92
|
+
item.metadata.get("wire", item.name): encode(getattr(value, item.name))
|
|
93
|
+
for item in fields(value)
|
|
94
|
+
if not item.metadata.get("ignore") and getattr(value, item.name) is not UNSET
|
|
95
|
+
}
|
|
96
|
+
if isinstance(value, dict):
|
|
97
|
+
return {key: encode(item) for key, item in value.items() if item is not UNSET}
|
|
98
|
+
if isinstance(value, (list, tuple)):
|
|
99
|
+
return [encode(item) for item in value]
|
|
100
|
+
if isinstance(value, Enum):
|
|
101
|
+
return value.value
|
|
102
|
+
return value
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Shared HTTP policy for synchronous and asynchronous clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import email.utils
|
|
7
|
+
import math
|
|
8
|
+
import platform
|
|
9
|
+
import random
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from .errors import NetworkError, TimeoutError, api_error, SanitizedTransportError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _url(base_url: str) -> str:
|
|
22
|
+
parsed = urlsplit(base_url)
|
|
23
|
+
if (
|
|
24
|
+
parsed.scheme not in ("https", "http")
|
|
25
|
+
or not parsed.netloc
|
|
26
|
+
or parsed.username
|
|
27
|
+
or parsed.password
|
|
28
|
+
):
|
|
29
|
+
raise ValueError("base_url must be an HTTP(S) URL without credentials")
|
|
30
|
+
if parsed.query or parsed.fragment:
|
|
31
|
+
raise ValueError("base_url cannot contain query or fragment")
|
|
32
|
+
return base_url.rstrip("/")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _delay(response: httpx.Response | None, attempt: int) -> float:
|
|
36
|
+
if response is not None:
|
|
37
|
+
value = response.headers.get("retry-after")
|
|
38
|
+
if value:
|
|
39
|
+
try:
|
|
40
|
+
seconds = float(value)
|
|
41
|
+
if math.isfinite(seconds):
|
|
42
|
+
return max(0.0, seconds)
|
|
43
|
+
except ValueError:
|
|
44
|
+
try:
|
|
45
|
+
date = email.utils.parsedate_to_datetime(value)
|
|
46
|
+
return max(0.0, (date - datetime.now(timezone.utc)).total_seconds())
|
|
47
|
+
except (ValueError, TypeError):
|
|
48
|
+
pass
|
|
49
|
+
return float(min(8.0, 0.5 * 2**attempt) * random.uniform(0.5, 1.0))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Transport:
|
|
53
|
+
"""Internal synchronous transport. Injected HTTP clients remain caller-owned."""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
api_key: str,
|
|
58
|
+
base_url: str,
|
|
59
|
+
*,
|
|
60
|
+
http: httpx.Client | None = None,
|
|
61
|
+
timeout: float = 30,
|
|
62
|
+
max_retries: int = 2,
|
|
63
|
+
user_agent: str = "sdk/1.0.0", # x-release-please-version
|
|
64
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
65
|
+
) -> None:
|
|
66
|
+
if (
|
|
67
|
+
not api_key
|
|
68
|
+
or not math.isfinite(timeout)
|
|
69
|
+
or timeout <= 0
|
|
70
|
+
or isinstance(max_retries, bool)
|
|
71
|
+
or not isinstance(max_retries, int)
|
|
72
|
+
or not 0 <= max_retries <= 20
|
|
73
|
+
):
|
|
74
|
+
raise ValueError("api_key, timeout and max_retries must be valid")
|
|
75
|
+
self._key = api_key
|
|
76
|
+
self._base = _url(base_url)
|
|
77
|
+
self._http = (
|
|
78
|
+
http if http is not None else httpx.Client(trust_env=False, follow_redirects=False)
|
|
79
|
+
)
|
|
80
|
+
self._owned = http is None
|
|
81
|
+
self._timeout = timeout
|
|
82
|
+
self._retries = max_retries
|
|
83
|
+
self._agent = f"{user_agent} python/{platform.python_version()}"
|
|
84
|
+
self._sleep = sleep
|
|
85
|
+
|
|
86
|
+
def request(
|
|
87
|
+
self, method: str, path: str, *, safe: bool = False, **kwargs: Any
|
|
88
|
+
) -> httpx.Response:
|
|
89
|
+
"""Send once, retrying only explicitly safe operations."""
|
|
90
|
+
if not path.startswith("/") or path.startswith("//"):
|
|
91
|
+
raise ValueError("path must be relative to the configured API")
|
|
92
|
+
deadline = kwargs.pop("_deadline", None)
|
|
93
|
+
headers = httpx.Headers(kwargs.pop("headers", {}))
|
|
94
|
+
headers["Accept"] = "application/json"
|
|
95
|
+
headers["Authorization"] = f"Bearer {self._key}"
|
|
96
|
+
headers["User-Agent"] = self._agent
|
|
97
|
+
for attempt in range(self._retries + 1):
|
|
98
|
+
remaining = (
|
|
99
|
+
self._timeout
|
|
100
|
+
if deadline is None
|
|
101
|
+
else min(self._timeout, deadline - time.monotonic())
|
|
102
|
+
)
|
|
103
|
+
if remaining <= 0:
|
|
104
|
+
raise TimeoutError("HTTP request timed out")
|
|
105
|
+
response = None
|
|
106
|
+
failure: NetworkError | None = None
|
|
107
|
+
connect_failure = False
|
|
108
|
+
try:
|
|
109
|
+
response = self._http.request(
|
|
110
|
+
method,
|
|
111
|
+
self._base + path,
|
|
112
|
+
headers=headers,
|
|
113
|
+
timeout=remaining,
|
|
114
|
+
follow_redirects=False,
|
|
115
|
+
**kwargs,
|
|
116
|
+
)
|
|
117
|
+
except httpx.TimeoutException as error:
|
|
118
|
+
connect_failure = isinstance(error, httpx.ConnectTimeout)
|
|
119
|
+
failure = TimeoutError(
|
|
120
|
+
"HTTP request timed out", SanitizedTransportError(error, self._key)
|
|
121
|
+
)
|
|
122
|
+
except httpx.RequestError as error:
|
|
123
|
+
connect_failure = isinstance(error, httpx.ConnectError)
|
|
124
|
+
failure = NetworkError(
|
|
125
|
+
"HTTP network request failed", SanitizedTransportError(error, self._key)
|
|
126
|
+
)
|
|
127
|
+
if failure is not None:
|
|
128
|
+
if (safe or connect_failure) and attempt < self._retries:
|
|
129
|
+
self._sleep(
|
|
130
|
+
min(_delay(None, attempt), max(0, deadline - time.monotonic()))
|
|
131
|
+
if deadline
|
|
132
|
+
else _delay(None, attempt)
|
|
133
|
+
)
|
|
134
|
+
continue
|
|
135
|
+
raise failure
|
|
136
|
+
assert response is not None
|
|
137
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
138
|
+
response.close()
|
|
139
|
+
raise TimeoutError("HTTP request timed out")
|
|
140
|
+
delay = _delay(response, attempt)
|
|
141
|
+
if (
|
|
142
|
+
(
|
|
143
|
+
response.status_code == 429
|
|
144
|
+
or (safe and response.status_code in (500, 502, 503, 504))
|
|
145
|
+
)
|
|
146
|
+
and attempt < self._retries
|
|
147
|
+
and delay <= 60
|
|
148
|
+
):
|
|
149
|
+
response.close()
|
|
150
|
+
self._sleep(min(delay, max(0, deadline - time.monotonic())) if deadline else delay)
|
|
151
|
+
continue
|
|
152
|
+
if response.status_code >= 300:
|
|
153
|
+
raise api_error(response, self._key)
|
|
154
|
+
return response
|
|
155
|
+
raise AssertionError("unreachable retry state")
|
|
156
|
+
|
|
157
|
+
def close(self) -> None:
|
|
158
|
+
"""Close an internally created HTTP client."""
|
|
159
|
+
if self._owned:
|
|
160
|
+
self._http.close()
|
|
161
|
+
|
|
162
|
+
def __enter__(self) -> Transport:
|
|
163
|
+
return self
|
|
164
|
+
|
|
165
|
+
def __exit__(self, *_args: object) -> None:
|
|
166
|
+
self.close()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class AsyncTransport:
|
|
170
|
+
"""Internal async transport with cancellation-safe retry sleeps."""
|
|
171
|
+
|
|
172
|
+
def __init__(
|
|
173
|
+
self,
|
|
174
|
+
api_key: str,
|
|
175
|
+
base_url: str,
|
|
176
|
+
*,
|
|
177
|
+
http: httpx.AsyncClient | None = None,
|
|
178
|
+
timeout: float = 30,
|
|
179
|
+
max_retries: int = 2,
|
|
180
|
+
user_agent: str = "sdk/1.0.0", # x-release-please-version
|
|
181
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
182
|
+
) -> None:
|
|
183
|
+
if (
|
|
184
|
+
not api_key
|
|
185
|
+
or not math.isfinite(timeout)
|
|
186
|
+
or timeout <= 0
|
|
187
|
+
or isinstance(max_retries, bool)
|
|
188
|
+
or not isinstance(max_retries, int)
|
|
189
|
+
or not 0 <= max_retries <= 20
|
|
190
|
+
):
|
|
191
|
+
raise ValueError("api_key, timeout and max_retries must be valid")
|
|
192
|
+
self._key = api_key
|
|
193
|
+
self._base = _url(base_url)
|
|
194
|
+
self._http = (
|
|
195
|
+
http if http is not None else httpx.AsyncClient(trust_env=False, follow_redirects=False)
|
|
196
|
+
)
|
|
197
|
+
self._owned = http is None
|
|
198
|
+
self._timeout = timeout
|
|
199
|
+
self._retries = max_retries
|
|
200
|
+
self._agent = f"{user_agent} python/{platform.python_version()}"
|
|
201
|
+
self._sleep = sleep
|
|
202
|
+
|
|
203
|
+
async def request(
|
|
204
|
+
self, method: str, path: str, *, safe: bool = False, **kwargs: Any
|
|
205
|
+
) -> httpx.Response:
|
|
206
|
+
"""Send once, retrying only explicitly safe operations."""
|
|
207
|
+
if not path.startswith("/") or path.startswith("//"):
|
|
208
|
+
raise ValueError("path must be relative to the configured API")
|
|
209
|
+
deadline = kwargs.pop("_deadline", None)
|
|
210
|
+
headers = httpx.Headers(kwargs.pop("headers", {}))
|
|
211
|
+
headers["Accept"] = "application/json"
|
|
212
|
+
headers["Authorization"] = f"Bearer {self._key}"
|
|
213
|
+
headers["User-Agent"] = self._agent
|
|
214
|
+
for attempt in range(self._retries + 1):
|
|
215
|
+
remaining = (
|
|
216
|
+
self._timeout
|
|
217
|
+
if deadline is None
|
|
218
|
+
else min(self._timeout, deadline - time.monotonic())
|
|
219
|
+
)
|
|
220
|
+
if remaining <= 0:
|
|
221
|
+
raise TimeoutError("HTTP request timed out")
|
|
222
|
+
response = None
|
|
223
|
+
failure: NetworkError | None = None
|
|
224
|
+
connect_failure = False
|
|
225
|
+
try:
|
|
226
|
+
response = await self._http.request(
|
|
227
|
+
method,
|
|
228
|
+
self._base + path,
|
|
229
|
+
headers=headers,
|
|
230
|
+
timeout=remaining,
|
|
231
|
+
follow_redirects=False,
|
|
232
|
+
**kwargs,
|
|
233
|
+
)
|
|
234
|
+
except httpx.TimeoutException as error:
|
|
235
|
+
connect_failure = isinstance(error, httpx.ConnectTimeout)
|
|
236
|
+
failure = TimeoutError(
|
|
237
|
+
"HTTP request timed out", SanitizedTransportError(error, self._key)
|
|
238
|
+
)
|
|
239
|
+
except httpx.RequestError as error:
|
|
240
|
+
connect_failure = isinstance(error, httpx.ConnectError)
|
|
241
|
+
failure = NetworkError(
|
|
242
|
+
"HTTP network request failed", SanitizedTransportError(error, self._key)
|
|
243
|
+
)
|
|
244
|
+
if failure is not None:
|
|
245
|
+
if (safe or connect_failure) and attempt < self._retries:
|
|
246
|
+
await self._sleep(
|
|
247
|
+
min(_delay(None, attempt), max(0, deadline - time.monotonic()))
|
|
248
|
+
if deadline
|
|
249
|
+
else _delay(None, attempt)
|
|
250
|
+
)
|
|
251
|
+
continue
|
|
252
|
+
raise failure
|
|
253
|
+
assert response is not None
|
|
254
|
+
delay = _delay(response, attempt)
|
|
255
|
+
if (
|
|
256
|
+
(
|
|
257
|
+
response.status_code == 429
|
|
258
|
+
or (safe and response.status_code in (500, 502, 503, 504))
|
|
259
|
+
)
|
|
260
|
+
and attempt < self._retries
|
|
261
|
+
and delay <= 60
|
|
262
|
+
):
|
|
263
|
+
await response.aclose()
|
|
264
|
+
await self._sleep(
|
|
265
|
+
min(delay, max(0, deadline - time.monotonic())) if deadline else delay
|
|
266
|
+
)
|
|
267
|
+
continue
|
|
268
|
+
if response.status_code >= 300:
|
|
269
|
+
raise api_error(response, self._key)
|
|
270
|
+
return response
|
|
271
|
+
raise AssertionError("unreachable retry state")
|
|
272
|
+
|
|
273
|
+
async def close(self) -> None:
|
|
274
|
+
"""Close an internally created HTTP client."""
|
|
275
|
+
if self._owned:
|
|
276
|
+
await self._http.aclose()
|
|
277
|
+
|
|
278
|
+
async def __aenter__(self) -> AsyncTransport:
|
|
279
|
+
return self
|
|
280
|
+
|
|
281
|
+
async def __aexit__(self, *_args: object) -> None:
|
|
282
|
+
await self.close()
|