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.
@@ -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
@@ -0,0 +1,8 @@
1
+ """Root error for this distribution."""
2
+
3
+
4
+ class ThunderError(Exception):
5
+ """Base class for API and transport failures."""
6
+
7
+
8
+ SDKError = ThunderError
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()