amigo_sdk 0.62.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,380 @@
1
+ import asyncio
2
+ import datetime as dt
3
+ import random
4
+ import threading
5
+ import time
6
+ from collections.abc import AsyncIterator, Iterator
7
+ from dataclasses import dataclass
8
+ from email.utils import parsedate_to_datetime
9
+ from typing import Any, Optional
10
+
11
+ import httpx
12
+
13
+ from amigo_sdk.auth import sign_in_with_api_key, sign_in_with_api_key_async
14
+ from amigo_sdk.config import AmigoConfig
15
+ from amigo_sdk.errors import (
16
+ AuthenticationError,
17
+ get_error_class_for_status_code,
18
+ raise_for_status,
19
+ )
20
+ from amigo_sdk.generated.model import UserSignInWithApiKeyResponse
21
+
22
+ # -----------------------------
23
+ # Shared helpers and structures
24
+ # -----------------------------
25
+
26
+
27
+ @dataclass
28
+ class _RetryConfig:
29
+ max_attempts: int
30
+ backoff_base: float
31
+ max_delay_seconds: float
32
+ on_status: set[int]
33
+ on_methods: set[str]
34
+
35
+ def is_retryable_method(self, method: str) -> bool:
36
+ return method.upper() in self.on_methods
37
+
38
+ def is_retryable_response(self, method: str, resp: httpx.Response) -> bool:
39
+ status = resp.status_code
40
+ if (
41
+ method.upper() == "POST"
42
+ and status == 429
43
+ and resp.headers.get("Retry-After")
44
+ ):
45
+ return True
46
+ return self.is_retryable_method(method) and status in self.on_status
47
+
48
+ def parse_retry_after_seconds(self, resp: httpx.Response) -> float | None:
49
+ retry_after = resp.headers.get("Retry-After")
50
+ if not retry_after:
51
+ return None
52
+ # Numeric seconds
53
+ try:
54
+ seconds = float(retry_after)
55
+ return max(0.0, seconds)
56
+ except ValueError:
57
+ pass
58
+ # HTTP-date format
59
+ try:
60
+ target_dt = parsedate_to_datetime(retry_after)
61
+ if target_dt is None:
62
+ return None
63
+ if target_dt.tzinfo is None:
64
+ target_dt = target_dt.replace(tzinfo=dt.UTC)
65
+ now = dt.datetime.now(dt.UTC)
66
+ delta_seconds = (target_dt - now).total_seconds()
67
+ # Round to milliseconds to avoid borderline off-by-epsilon in tests
68
+ delta_seconds = round(delta_seconds, 3)
69
+ return max(0.0, delta_seconds)
70
+ except Exception:
71
+ return None
72
+
73
+ def retry_delay_seconds(self, attempt: int, resp: httpx.Response | None) -> float:
74
+ # Honor Retry-After when present (numeric or HTTP-date), clamped by max delay
75
+ if resp is not None:
76
+ ra_seconds = self.parse_retry_after_seconds(resp)
77
+ if ra_seconds is not None:
78
+ return min(self.max_delay_seconds, ra_seconds)
79
+ # Exponential backoff with full jitter: U(0, min(cap, base * 2^(attempt-1)))
80
+ window = self.backoff_base * (2 ** (attempt - 1))
81
+ window = min(window, self.max_delay_seconds)
82
+ return random.uniform(0.0, window)
83
+
84
+
85
+ def _should_refresh_token(token: Optional[UserSignInWithApiKeyResponse]) -> bool:
86
+ if not token:
87
+ return True
88
+ return dt.datetime.now(dt.UTC) > token.expires_at - dt.timedelta(minutes=5)
89
+
90
+
91
+ async def _raise_status_with_body_async(resp: httpx.Response) -> None:
92
+ if 200 <= resp.status_code < 300:
93
+ return
94
+ try:
95
+ await resp.aread()
96
+ except Exception:
97
+ pass
98
+ if hasattr(resp, "is_success"):
99
+ raise_for_status(resp)
100
+ error_class = get_error_class_for_status_code(getattr(resp, "status_code", 0))
101
+ raise error_class(
102
+ f"HTTP {getattr(resp, 'status_code', 'unknown')} error",
103
+ status_code=getattr(resp, "status_code", None),
104
+ )
105
+
106
+
107
+ def _raise_status_with_body_sync(resp: httpx.Response) -> None:
108
+ if 200 <= resp.status_code < 300:
109
+ return
110
+ try:
111
+ _ = resp.text
112
+ except Exception:
113
+ pass
114
+ if hasattr(resp, "is_success"):
115
+ raise_for_status(resp)
116
+ error_class = get_error_class_for_status_code(getattr(resp, "status_code", 0))
117
+ raise error_class(
118
+ f"HTTP {getattr(resp, 'status_code', 'unknown')} error",
119
+ status_code=getattr(resp, "status_code", None),
120
+ )
121
+
122
+
123
+ class AmigoAsyncHttpClient:
124
+ def __init__(
125
+ self,
126
+ cfg: AmigoConfig,
127
+ *,
128
+ retry_max_attempts: int = 3,
129
+ retry_backoff_base: float = 0.25,
130
+ retry_max_delay_seconds: float = 30.0,
131
+ retry_on_status: set[int] | None = None,
132
+ retry_on_methods: set[str] | None = None,
133
+ **httpx_kwargs: Any,
134
+ ) -> None:
135
+ self._cfg = cfg
136
+ self._token: Optional[UserSignInWithApiKeyResponse] = None
137
+ self._client = httpx.AsyncClient(
138
+ base_url=cfg.base_url,
139
+ **httpx_kwargs,
140
+ )
141
+ # Retry configuration
142
+ self._retry_cfg = _RetryConfig(
143
+ max(1, retry_max_attempts),
144
+ retry_backoff_base,
145
+ max(0.0, retry_max_delay_seconds),
146
+ retry_on_status or {408, 429, 500, 502, 503, 504},
147
+ {m.upper() for m in (retry_on_methods or {"GET"})},
148
+ )
149
+
150
+ async def _ensure_token(self) -> str:
151
+ """Fetch or refresh bearer token ~5 min before expiry."""
152
+ if _should_refresh_token(self._token):
153
+ try:
154
+ self._token = await sign_in_with_api_key_async(self._cfg)
155
+ except Exception as e:
156
+ raise AuthenticationError(
157
+ "API-key exchange failed",
158
+ ) from e
159
+
160
+ return self._token.id_token
161
+
162
+ async def request(self, method: str, path: str, **kwargs) -> httpx.Response:
163
+ kwargs.setdefault("headers", {})
164
+ attempt = 1
165
+
166
+ while True:
167
+ kwargs["headers"]["Authorization"] = f"Bearer {await self._ensure_token()}"
168
+
169
+ resp: httpx.Response | None = None
170
+ try:
171
+ resp = await self._client.request(method, path, **kwargs)
172
+
173
+ # On 401 refresh token once and retry immediately
174
+ if resp.status_code == 401:
175
+ self._token = None
176
+ kwargs["headers"]["Authorization"] = (
177
+ f"Bearer {await self._ensure_token()}"
178
+ )
179
+ resp = await self._client.request(method, path, **kwargs)
180
+
181
+ except (httpx.TimeoutException, httpx.TransportError):
182
+ # Retry only if method is allowed (e.g., GET); POST not retried for transport/timeouts
183
+ if (
184
+ not self._retry_cfg.is_retryable_method(method)
185
+ or attempt >= self._retry_cfg.max_attempts
186
+ ):
187
+ raise
188
+ await asyncio.sleep(self._retry_cfg.retry_delay_seconds(attempt, None))
189
+ attempt += 1
190
+ continue
191
+
192
+ # Retry on configured HTTP status codes
193
+ if (
194
+ self._retry_cfg.is_retryable_response(method, resp)
195
+ and attempt < self._retry_cfg.max_attempts
196
+ ):
197
+ await asyncio.sleep(self._retry_cfg.retry_delay_seconds(attempt, resp))
198
+ attempt += 1
199
+ continue
200
+
201
+ # Check response status and raise appropriate errors
202
+ raise_for_status(resp)
203
+ return resp
204
+
205
+ async def stream_lines(
206
+ self,
207
+ method: str,
208
+ path: str,
209
+ abort_event: asyncio.Event | None = None,
210
+ **kwargs,
211
+ ) -> AsyncIterator[str]:
212
+ """Stream response lines without buffering the full body.
213
+
214
+ - Adds Authorization and sensible streaming headers
215
+ - Retries once on 401 by refreshing the token
216
+ - Raises mapped errors for non-2xx without consuming the body
217
+ """
218
+ kwargs.setdefault("headers", {})
219
+ headers = kwargs["headers"]
220
+ headers["Authorization"] = f"Bearer {await self._ensure_token()}"
221
+ headers.setdefault("Accept", "application/x-ndjson")
222
+
223
+ async def _yield_from_response(resp: httpx.Response) -> AsyncIterator[str]:
224
+ await _raise_status_with_body_async(resp)
225
+ if abort_event and abort_event.is_set():
226
+ return
227
+ async for line in resp.aiter_lines():
228
+ if abort_event and abort_event.is_set():
229
+ return
230
+ line_stripped = line.strip()
231
+ if not line_stripped:
232
+ continue
233
+ yield line_stripped
234
+
235
+ # First attempt
236
+ if abort_event and abort_event.is_set():
237
+ return
238
+ async with self._client.stream(method, path, **kwargs) as resp:
239
+ if resp.status_code == 401:
240
+ # Refresh token and retry once
241
+ self._token = None
242
+ headers["Authorization"] = f"Bearer {await self._ensure_token()}"
243
+ if abort_event and abort_event.is_set():
244
+ return
245
+ async with self._client.stream(method, path, **kwargs) as retry_resp:
246
+ async for ln in _yield_from_response(retry_resp):
247
+ yield ln
248
+ return
249
+
250
+ async for ln in _yield_from_response(resp):
251
+ yield ln
252
+
253
+ async def aclose(self) -> None:
254
+ await self._client.aclose()
255
+
256
+ # async-context-manager sugar
257
+ async def __aenter__(self): # → async with AmigoAsyncHttpClient(...) as http:
258
+ return self
259
+
260
+ async def __aexit__(self, *_):
261
+ await self.aclose()
262
+
263
+
264
+ class AmigoHttpClient:
265
+ def __init__(
266
+ self,
267
+ cfg: AmigoConfig,
268
+ *,
269
+ retry_max_attempts: int = 3,
270
+ retry_backoff_base: float = 0.25,
271
+ retry_max_delay_seconds: float = 30.0,
272
+ retry_on_status: set[int] | None = None,
273
+ retry_on_methods: set[str] | None = None,
274
+ **httpx_kwargs: Any,
275
+ ) -> None:
276
+ self._cfg = cfg
277
+ self._token: Optional[UserSignInWithApiKeyResponse] = None
278
+ self._client = httpx.Client(base_url=cfg.base_url, **httpx_kwargs)
279
+ # Retry configuration
280
+ self._retry_cfg = _RetryConfig(
281
+ max(1, retry_max_attempts),
282
+ retry_backoff_base,
283
+ max(0.0, retry_max_delay_seconds),
284
+ retry_on_status or {408, 429, 500, 502, 503, 504},
285
+ {m.upper() for m in (retry_on_methods or {"GET"})},
286
+ )
287
+
288
+ def _ensure_token(self) -> str:
289
+ if _should_refresh_token(self._token):
290
+ try:
291
+ self._token = sign_in_with_api_key(self._cfg)
292
+ except Exception as e:
293
+ raise AuthenticationError("API-key exchange failed") from e
294
+ return self._token.id_token
295
+
296
+ def request(self, method: str, path: str, **kwargs) -> httpx.Response:
297
+ kwargs.setdefault("headers", {})
298
+ attempt = 1
299
+
300
+ while True:
301
+ kwargs["headers"]["Authorization"] = f"Bearer {self._ensure_token()}"
302
+
303
+ resp: httpx.Response | None = None
304
+ try:
305
+ resp = self._client.request(method, path, **kwargs)
306
+ if resp.status_code == 401:
307
+ self._token = None
308
+ kwargs["headers"]["Authorization"] = (
309
+ f"Bearer {self._ensure_token()}"
310
+ )
311
+ resp = self._client.request(method, path, **kwargs)
312
+
313
+ except (httpx.TimeoutException, httpx.TransportError):
314
+ if (
315
+ not self._retry_cfg.is_retryable_method(method)
316
+ ) or attempt >= self._retry_cfg.max_attempts:
317
+ raise
318
+ time.sleep(self._retry_cfg.retry_delay_seconds(attempt, None))
319
+ attempt += 1
320
+ continue
321
+
322
+ if (
323
+ self._retry_cfg.is_retryable_response(method, resp)
324
+ and attempt < self._retry_cfg.max_attempts
325
+ ):
326
+ time.sleep(self._retry_cfg.retry_delay_seconds(attempt, resp))
327
+ attempt += 1
328
+ continue
329
+
330
+ raise_for_status(resp)
331
+ return resp
332
+
333
+ def stream_lines(
334
+ self,
335
+ method: str,
336
+ path: str,
337
+ abort_event: threading.Event | None = None,
338
+ **kwargs,
339
+ ) -> Iterator[str]:
340
+ kwargs.setdefault("headers", {})
341
+ headers = kwargs["headers"]
342
+ headers["Authorization"] = f"Bearer {self._ensure_token()}"
343
+ headers.setdefault("Accept", "application/x-ndjson")
344
+
345
+ def _yield_from_response(resp: httpx.Response) -> Iterator[str]:
346
+ _raise_status_with_body_sync(resp)
347
+ if abort_event and abort_event.is_set():
348
+ return
349
+ for line in resp.iter_lines():
350
+ if abort_event and abort_event.is_set():
351
+ return
352
+ line_stripped = (line or "").strip()
353
+ if not line_stripped:
354
+ continue
355
+ yield line_stripped
356
+
357
+ if abort_event and abort_event.is_set():
358
+ return iter(())
359
+ with self._client.stream(method, path, **kwargs) as resp:
360
+ if resp.status_code == 401:
361
+ self._token = None
362
+ headers["Authorization"] = f"Bearer {self._ensure_token()}"
363
+ if abort_event and abort_event.is_set():
364
+ return iter(())
365
+ with self._client.stream(method, path, **kwargs) as retry_resp:
366
+ for ln in _yield_from_response(retry_resp):
367
+ yield ln
368
+ return
369
+
370
+ for ln in _yield_from_response(resp):
371
+ yield ln
372
+
373
+ def aclose(self) -> None:
374
+ self._client.close()
375
+
376
+ def __enter__(self):
377
+ return self
378
+
379
+ def __exit__(self, *_):
380
+ self.aclose()
amigo_sdk/models.py ADDED
@@ -0,0 +1 @@
1
+ from .generated.model import * # noqa: F403