devrev-Python-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.
- devrev/__init__.py +47 -0
- devrev/client.py +343 -0
- devrev/config.py +180 -0
- devrev/exceptions.py +205 -0
- devrev/models/__init__.py +499 -0
- devrev/models/accounts.py +187 -0
- devrev/models/articles.py +109 -0
- devrev/models/base.py +147 -0
- devrev/models/code_changes.py +103 -0
- devrev/models/conversations.py +115 -0
- devrev/models/dev_users.py +258 -0
- devrev/models/groups.py +140 -0
- devrev/models/links.py +107 -0
- devrev/models/parts.py +110 -0
- devrev/models/rev_users.py +177 -0
- devrev/models/slas.py +112 -0
- devrev/models/tags.py +90 -0
- devrev/models/timeline_entries.py +100 -0
- devrev/models/webhooks.py +109 -0
- devrev/models/works.py +280 -0
- devrev/py.typed +1 -0
- devrev/services/__init__.py +74 -0
- devrev/services/accounts.py +325 -0
- devrev/services/articles.py +80 -0
- devrev/services/base.py +234 -0
- devrev/services/code_changes.py +80 -0
- devrev/services/conversations.py +98 -0
- devrev/services/dev_users.py +401 -0
- devrev/services/groups.py +103 -0
- devrev/services/links.py +68 -0
- devrev/services/parts.py +100 -0
- devrev/services/rev_users.py +235 -0
- devrev/services/slas.py +82 -0
- devrev/services/tags.py +80 -0
- devrev/services/timeline_entries.py +80 -0
- devrev/services/webhooks.py +80 -0
- devrev/services/works.py +363 -0
- devrev/utils/__init__.py +14 -0
- devrev/utils/deprecation.py +49 -0
- devrev/utils/http.py +521 -0
- devrev/utils/logging.py +139 -0
- devrev/utils/pagination.py +155 -0
- devrev_python_sdk-1.0.0.dist-info/METADATA +774 -0
- devrev_python_sdk-1.0.0.dist-info/RECORD +45 -0
- devrev_python_sdk-1.0.0.dist-info/WHEEL +4 -0
devrev/utils/http.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""HTTP client utilities for DevRev SDK.
|
|
2
|
+
|
|
3
|
+
This module provides HTTP client implementations with retry logic,
|
|
4
|
+
rate limiting support, and proper error handling.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from devrev.exceptions import (
|
|
17
|
+
STATUS_CODE_TO_EXCEPTION,
|
|
18
|
+
DevRevError,
|
|
19
|
+
NetworkError,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
TimeoutError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from pydantic import SecretStr
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
T = TypeVar("T")
|
|
30
|
+
|
|
31
|
+
# Default retry configuration
|
|
32
|
+
DEFAULT_MAX_RETRIES = 3
|
|
33
|
+
DEFAULT_RETRY_BACKOFF_FACTOR = 0.5
|
|
34
|
+
DEFAULT_RETRY_STATUS_CODES = frozenset({429, 500, 502, 503, 504})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _calculate_backoff(attempt: int, backoff_factor: float = DEFAULT_RETRY_BACKOFF_FACTOR) -> float:
|
|
38
|
+
"""Calculate exponential backoff delay.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
attempt: Current retry attempt (0-indexed)
|
|
42
|
+
backoff_factor: Base factor for exponential calculation
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Delay in seconds before next retry
|
|
46
|
+
"""
|
|
47
|
+
return float(backoff_factor * (2**attempt))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _extract_error_message(response: httpx.Response) -> tuple[str, dict[str, Any] | None]:
|
|
51
|
+
"""Extract error message from API response.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
response: HTTP response object
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Tuple of (error message, response body dict or None)
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
body = response.json()
|
|
61
|
+
message = body.get("message") or body.get("error") or f"HTTP {response.status_code}"
|
|
62
|
+
return message, body
|
|
63
|
+
except Exception:
|
|
64
|
+
return f"HTTP {response.status_code}: {response.text[:200]}", None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
68
|
+
"""Raise appropriate DevRev exception based on response status code.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
response: HTTP response object
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
DevRevError: Appropriate subclass based on status code
|
|
75
|
+
"""
|
|
76
|
+
if response.is_success:
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
message, body = _extract_error_message(response)
|
|
80
|
+
request_id = response.headers.get("x-request-id")
|
|
81
|
+
|
|
82
|
+
exception_class = STATUS_CODE_TO_EXCEPTION.get(response.status_code, DevRevError)
|
|
83
|
+
|
|
84
|
+
# Handle rate limiting specially
|
|
85
|
+
if response.status_code == 429:
|
|
86
|
+
retry_after = None
|
|
87
|
+
retry_header = response.headers.get("retry-after")
|
|
88
|
+
if retry_header:
|
|
89
|
+
with contextlib.suppress(ValueError):
|
|
90
|
+
retry_after = int(retry_header)
|
|
91
|
+
raise RateLimitError(
|
|
92
|
+
message,
|
|
93
|
+
status_code=response.status_code,
|
|
94
|
+
request_id=request_id,
|
|
95
|
+
response_body=body,
|
|
96
|
+
retry_after=retry_after,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
raise exception_class(
|
|
100
|
+
message,
|
|
101
|
+
status_code=response.status_code,
|
|
102
|
+
request_id=request_id,
|
|
103
|
+
response_body=body,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class HTTPClient:
|
|
108
|
+
"""Synchronous HTTP client with retry logic and rate limiting support.
|
|
109
|
+
|
|
110
|
+
This client wraps httpx and provides:
|
|
111
|
+
- Automatic retry with exponential backoff
|
|
112
|
+
- Rate limiting support with Retry-After header
|
|
113
|
+
- Proper timeout handling
|
|
114
|
+
- Request/response logging
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
base_url: Base URL for all requests
|
|
118
|
+
api_token: API authentication token
|
|
119
|
+
timeout: Request timeout in seconds
|
|
120
|
+
max_retries: Maximum number of retry attempts
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
base_url: str,
|
|
126
|
+
api_token: SecretStr,
|
|
127
|
+
timeout: int = 30,
|
|
128
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Initialize the HTTP client.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
base_url: Base URL for all requests
|
|
134
|
+
api_token: API authentication token (SecretStr)
|
|
135
|
+
timeout: Request timeout in seconds
|
|
136
|
+
max_retries: Maximum number of retry attempts
|
|
137
|
+
"""
|
|
138
|
+
self._base_url = base_url.rstrip("/")
|
|
139
|
+
self._api_token = api_token
|
|
140
|
+
self._timeout = timeout
|
|
141
|
+
self._max_retries = max_retries
|
|
142
|
+
self._client = httpx.Client(
|
|
143
|
+
base_url=self._base_url,
|
|
144
|
+
timeout=httpx.Timeout(timeout),
|
|
145
|
+
headers=self._build_headers(),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def _build_headers(self) -> dict[str, str]:
|
|
149
|
+
"""Build default headers for requests."""
|
|
150
|
+
return {
|
|
151
|
+
"Authorization": f"Bearer {self._api_token.get_secret_value()}",
|
|
152
|
+
"Content-Type": "application/json",
|
|
153
|
+
"Accept": "application/json",
|
|
154
|
+
"User-Agent": "devrev-python-sdk/0.1.0",
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
def close(self) -> None:
|
|
158
|
+
"""Close the HTTP client and release resources."""
|
|
159
|
+
self._client.close()
|
|
160
|
+
|
|
161
|
+
def __enter__(self) -> HTTPClient:
|
|
162
|
+
"""Enter context manager."""
|
|
163
|
+
return self
|
|
164
|
+
|
|
165
|
+
def __exit__(self, *args: object) -> None:
|
|
166
|
+
"""Exit context manager."""
|
|
167
|
+
self.close()
|
|
168
|
+
|
|
169
|
+
def _should_retry(self, response: httpx.Response) -> bool:
|
|
170
|
+
"""Determine if request should be retried based on response.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
response: HTTP response object
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
True if request should be retried
|
|
177
|
+
"""
|
|
178
|
+
return response.status_code in DEFAULT_RETRY_STATUS_CODES
|
|
179
|
+
|
|
180
|
+
def _handle_retry(
|
|
181
|
+
self,
|
|
182
|
+
attempt: int,
|
|
183
|
+
response: httpx.Response | None = None,
|
|
184
|
+
_exception: Exception | None = None,
|
|
185
|
+
) -> float:
|
|
186
|
+
"""Handle retry logic and return wait time.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
attempt: Current retry attempt
|
|
190
|
+
response: HTTP response (if available)
|
|
191
|
+
_exception: Exception that occurred (if any, unused but kept for interface)
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Seconds to wait before next retry
|
|
195
|
+
"""
|
|
196
|
+
if response is not None and response.status_code == 429:
|
|
197
|
+
retry_after = response.headers.get("retry-after")
|
|
198
|
+
if retry_after:
|
|
199
|
+
try:
|
|
200
|
+
return float(retry_after)
|
|
201
|
+
except ValueError:
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
return _calculate_backoff(attempt)
|
|
205
|
+
|
|
206
|
+
def request(
|
|
207
|
+
self,
|
|
208
|
+
method: str,
|
|
209
|
+
endpoint: str,
|
|
210
|
+
*,
|
|
211
|
+
json: dict[str, Any] | None = None,
|
|
212
|
+
params: dict[str, Any] | None = None,
|
|
213
|
+
) -> httpx.Response:
|
|
214
|
+
"""Make an HTTP request with retry logic.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
method: HTTP method (GET, POST, etc.)
|
|
218
|
+
endpoint: API endpoint path
|
|
219
|
+
json: JSON body for the request
|
|
220
|
+
params: Query parameters
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
HTTP response object
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
DevRevError: On API errors
|
|
227
|
+
TimeoutError: On request timeout
|
|
228
|
+
NetworkError: On network failures
|
|
229
|
+
"""
|
|
230
|
+
url = f"{self._base_url}{endpoint}"
|
|
231
|
+
last_exception: Exception | None = None
|
|
232
|
+
|
|
233
|
+
for attempt in range(self._max_retries + 1):
|
|
234
|
+
try:
|
|
235
|
+
logger.debug(
|
|
236
|
+
"Making %s request to %s (attempt %d/%d)",
|
|
237
|
+
method,
|
|
238
|
+
endpoint,
|
|
239
|
+
attempt + 1,
|
|
240
|
+
self._max_retries + 1,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
response = self._client.request(
|
|
244
|
+
method=method,
|
|
245
|
+
url=endpoint,
|
|
246
|
+
json=json,
|
|
247
|
+
params=params,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if response.is_success:
|
|
251
|
+
return response
|
|
252
|
+
|
|
253
|
+
if not self._should_retry(response) or attempt >= self._max_retries:
|
|
254
|
+
_raise_for_status(response)
|
|
255
|
+
|
|
256
|
+
wait_time = self._handle_retry(attempt, response=response)
|
|
257
|
+
logger.warning(
|
|
258
|
+
"Request to %s failed with status %d, retrying in %.2fs",
|
|
259
|
+
endpoint,
|
|
260
|
+
response.status_code,
|
|
261
|
+
wait_time,
|
|
262
|
+
)
|
|
263
|
+
time.sleep(wait_time)
|
|
264
|
+
|
|
265
|
+
except httpx.TimeoutException as e:
|
|
266
|
+
last_exception = e
|
|
267
|
+
if attempt >= self._max_retries:
|
|
268
|
+
raise TimeoutError(
|
|
269
|
+
f"Request to {endpoint} timed out after {self._timeout}s"
|
|
270
|
+
) from e
|
|
271
|
+
wait_time = self._handle_retry(attempt, _exception=e)
|
|
272
|
+
logger.warning("Request timeout, retrying in %.2fs", wait_time)
|
|
273
|
+
time.sleep(wait_time)
|
|
274
|
+
|
|
275
|
+
except httpx.RequestError as e:
|
|
276
|
+
last_exception = e
|
|
277
|
+
if attempt >= self._max_retries:
|
|
278
|
+
raise NetworkError(f"Network error connecting to {url}: {e}") from e
|
|
279
|
+
wait_time = self._handle_retry(attempt, _exception=e)
|
|
280
|
+
logger.warning("Network error, retrying in %.2fs", wait_time)
|
|
281
|
+
time.sleep(wait_time)
|
|
282
|
+
|
|
283
|
+
# Should not reach here, but just in case
|
|
284
|
+
if last_exception:
|
|
285
|
+
raise NetworkError(
|
|
286
|
+
f"Request failed after {self._max_retries + 1} attempts"
|
|
287
|
+
) from last_exception
|
|
288
|
+
raise DevRevError("Request failed unexpectedly")
|
|
289
|
+
|
|
290
|
+
def post(
|
|
291
|
+
self,
|
|
292
|
+
endpoint: str,
|
|
293
|
+
data: dict[str, Any] | None = None,
|
|
294
|
+
) -> httpx.Response:
|
|
295
|
+
"""Make a POST request.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
endpoint: API endpoint path
|
|
299
|
+
data: JSON body for the request
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
HTTP response object
|
|
303
|
+
"""
|
|
304
|
+
return self.request("POST", endpoint, json=data)
|
|
305
|
+
|
|
306
|
+
def get(
|
|
307
|
+
self,
|
|
308
|
+
endpoint: str,
|
|
309
|
+
params: dict[str, Any] | None = None,
|
|
310
|
+
) -> httpx.Response:
|
|
311
|
+
"""Make a GET request.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
endpoint: API endpoint path
|
|
315
|
+
params: Query parameters
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
HTTP response object
|
|
319
|
+
"""
|
|
320
|
+
return self.request("GET", endpoint, params=params)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class AsyncHTTPClient:
|
|
324
|
+
"""Asynchronous HTTP client with retry logic and rate limiting support.
|
|
325
|
+
|
|
326
|
+
This client wraps httpx and provides:
|
|
327
|
+
- Automatic retry with exponential backoff
|
|
328
|
+
- Rate limiting support with Retry-After header
|
|
329
|
+
- Proper timeout handling
|
|
330
|
+
- Request/response logging
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
base_url: Base URL for all requests
|
|
334
|
+
api_token: API authentication token
|
|
335
|
+
timeout: Request timeout in seconds
|
|
336
|
+
max_retries: Maximum number of retry attempts
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
def __init__(
|
|
340
|
+
self,
|
|
341
|
+
base_url: str,
|
|
342
|
+
api_token: SecretStr,
|
|
343
|
+
timeout: int = 30,
|
|
344
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
345
|
+
) -> None:
|
|
346
|
+
"""Initialize the async HTTP client.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
base_url: Base URL for all requests
|
|
350
|
+
api_token: API authentication token (SecretStr)
|
|
351
|
+
timeout: Request timeout in seconds
|
|
352
|
+
max_retries: Maximum number of retry attempts
|
|
353
|
+
"""
|
|
354
|
+
self._base_url = base_url.rstrip("/")
|
|
355
|
+
self._api_token = api_token
|
|
356
|
+
self._timeout = timeout
|
|
357
|
+
self._max_retries = max_retries
|
|
358
|
+
self._client = httpx.AsyncClient(
|
|
359
|
+
base_url=self._base_url,
|
|
360
|
+
timeout=httpx.Timeout(timeout),
|
|
361
|
+
headers=self._build_headers(),
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
def _build_headers(self) -> dict[str, str]:
|
|
365
|
+
"""Build default headers for requests."""
|
|
366
|
+
return {
|
|
367
|
+
"Authorization": f"Bearer {self._api_token.get_secret_value()}",
|
|
368
|
+
"Content-Type": "application/json",
|
|
369
|
+
"Accept": "application/json",
|
|
370
|
+
"User-Agent": "devrev-python-sdk/0.1.0",
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async def close(self) -> None:
|
|
374
|
+
"""Close the HTTP client and release resources."""
|
|
375
|
+
await self._client.aclose()
|
|
376
|
+
|
|
377
|
+
async def __aenter__(self) -> AsyncHTTPClient:
|
|
378
|
+
"""Enter async context manager."""
|
|
379
|
+
return self
|
|
380
|
+
|
|
381
|
+
async def __aexit__(self, *args: object) -> None:
|
|
382
|
+
"""Exit async context manager."""
|
|
383
|
+
await self.close()
|
|
384
|
+
|
|
385
|
+
def _should_retry(self, response: httpx.Response) -> bool:
|
|
386
|
+
"""Determine if request should be retried based on response."""
|
|
387
|
+
return response.status_code in DEFAULT_RETRY_STATUS_CODES
|
|
388
|
+
|
|
389
|
+
def _handle_retry(
|
|
390
|
+
self,
|
|
391
|
+
attempt: int,
|
|
392
|
+
response: httpx.Response | None = None,
|
|
393
|
+
_exception: Exception | None = None,
|
|
394
|
+
) -> float:
|
|
395
|
+
"""Handle retry logic and return wait time."""
|
|
396
|
+
if response is not None and response.status_code == 429:
|
|
397
|
+
retry_after = response.headers.get("retry-after")
|
|
398
|
+
if retry_after:
|
|
399
|
+
try:
|
|
400
|
+
return float(retry_after)
|
|
401
|
+
except ValueError:
|
|
402
|
+
pass
|
|
403
|
+
|
|
404
|
+
return _calculate_backoff(attempt)
|
|
405
|
+
|
|
406
|
+
async def request(
|
|
407
|
+
self,
|
|
408
|
+
method: str,
|
|
409
|
+
endpoint: str,
|
|
410
|
+
*,
|
|
411
|
+
json: dict[str, Any] | None = None,
|
|
412
|
+
params: dict[str, Any] | None = None,
|
|
413
|
+
) -> httpx.Response:
|
|
414
|
+
"""Make an async HTTP request with retry logic.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
method: HTTP method (GET, POST, etc.)
|
|
418
|
+
endpoint: API endpoint path
|
|
419
|
+
json: JSON body for the request
|
|
420
|
+
params: Query parameters
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
HTTP response object
|
|
424
|
+
|
|
425
|
+
Raises:
|
|
426
|
+
DevRevError: On API errors
|
|
427
|
+
TimeoutError: On request timeout
|
|
428
|
+
NetworkError: On network failures
|
|
429
|
+
"""
|
|
430
|
+
import asyncio
|
|
431
|
+
|
|
432
|
+
url = f"{self._base_url}{endpoint}"
|
|
433
|
+
last_exception: Exception | None = None
|
|
434
|
+
|
|
435
|
+
for attempt in range(self._max_retries + 1):
|
|
436
|
+
try:
|
|
437
|
+
logger.debug(
|
|
438
|
+
"Making async %s request to %s (attempt %d/%d)",
|
|
439
|
+
method,
|
|
440
|
+
endpoint,
|
|
441
|
+
attempt + 1,
|
|
442
|
+
self._max_retries + 1,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
response = await self._client.request(
|
|
446
|
+
method=method,
|
|
447
|
+
url=endpoint,
|
|
448
|
+
json=json,
|
|
449
|
+
params=params,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
if response.is_success:
|
|
453
|
+
return response
|
|
454
|
+
|
|
455
|
+
if not self._should_retry(response) or attempt >= self._max_retries:
|
|
456
|
+
_raise_for_status(response)
|
|
457
|
+
|
|
458
|
+
wait_time = self._handle_retry(attempt, response=response)
|
|
459
|
+
logger.warning(
|
|
460
|
+
"Request to %s failed with status %d, retrying in %.2fs",
|
|
461
|
+
endpoint,
|
|
462
|
+
response.status_code,
|
|
463
|
+
wait_time,
|
|
464
|
+
)
|
|
465
|
+
await asyncio.sleep(wait_time)
|
|
466
|
+
|
|
467
|
+
except httpx.TimeoutException as e:
|
|
468
|
+
last_exception = e
|
|
469
|
+
if attempt >= self._max_retries:
|
|
470
|
+
raise TimeoutError(
|
|
471
|
+
f"Request to {endpoint} timed out after {self._timeout}s"
|
|
472
|
+
) from e
|
|
473
|
+
wait_time = self._handle_retry(attempt, _exception=e)
|
|
474
|
+
logger.warning("Request timeout, retrying in %.2fs", wait_time)
|
|
475
|
+
await asyncio.sleep(wait_time)
|
|
476
|
+
|
|
477
|
+
except httpx.RequestError as e:
|
|
478
|
+
last_exception = e
|
|
479
|
+
if attempt >= self._max_retries:
|
|
480
|
+
raise NetworkError(f"Network error connecting to {url}: {e}") from e
|
|
481
|
+
wait_time = self._handle_retry(attempt, _exception=e)
|
|
482
|
+
logger.warning("Network error, retrying in %.2fs", wait_time)
|
|
483
|
+
await asyncio.sleep(wait_time)
|
|
484
|
+
|
|
485
|
+
if last_exception:
|
|
486
|
+
raise NetworkError(
|
|
487
|
+
f"Request failed after {self._max_retries + 1} attempts"
|
|
488
|
+
) from last_exception
|
|
489
|
+
raise DevRevError("Request failed unexpectedly")
|
|
490
|
+
|
|
491
|
+
async def post(
|
|
492
|
+
self,
|
|
493
|
+
endpoint: str,
|
|
494
|
+
data: dict[str, Any] | None = None,
|
|
495
|
+
) -> httpx.Response:
|
|
496
|
+
"""Make an async POST request.
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
endpoint: API endpoint path
|
|
500
|
+
data: JSON body for the request
|
|
501
|
+
|
|
502
|
+
Returns:
|
|
503
|
+
HTTP response object
|
|
504
|
+
"""
|
|
505
|
+
return await self.request("POST", endpoint, json=data)
|
|
506
|
+
|
|
507
|
+
async def get(
|
|
508
|
+
self,
|
|
509
|
+
endpoint: str,
|
|
510
|
+
params: dict[str, Any] | None = None,
|
|
511
|
+
) -> httpx.Response:
|
|
512
|
+
"""Make an async GET request.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
endpoint: API endpoint path
|
|
516
|
+
params: Query parameters
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
HTTP response object
|
|
520
|
+
"""
|
|
521
|
+
return await self.request("GET", endpoint, params=params)
|
devrev/utils/logging.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Logging configuration for DevRev SDK.
|
|
2
|
+
|
|
3
|
+
This module provides structured logging with optional color support
|
|
4
|
+
for development environments.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
LogLevel = Literal["DEBUG", "INFO", "WARN", "WARNING", "ERROR"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ColoredFormatter(logging.Formatter):
|
|
15
|
+
"""Formatter that adds colors to log output.
|
|
16
|
+
|
|
17
|
+
Colors are only applied when outputting to a TTY terminal.
|
|
18
|
+
Falls back to plain text when colors aren't supported.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
COLORS = {
|
|
22
|
+
"DEBUG": "\033[36m", # Cyan
|
|
23
|
+
"INFO": "\033[32m", # Green
|
|
24
|
+
"WARNING": "\033[33m", # Yellow
|
|
25
|
+
"WARN": "\033[33m", # Yellow
|
|
26
|
+
"ERROR": "\033[31m", # Red
|
|
27
|
+
"CRITICAL": "\033[35m", # Magenta
|
|
28
|
+
}
|
|
29
|
+
RESET = "\033[0m"
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
fmt: str | None = None,
|
|
34
|
+
datefmt: str | None = None,
|
|
35
|
+
use_colors: bool = True,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Initialize the colored formatter.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
fmt: Log message format string
|
|
41
|
+
datefmt: Date format string
|
|
42
|
+
use_colors: Whether to use colors (auto-detected if True)
|
|
43
|
+
"""
|
|
44
|
+
super().__init__(fmt or self._default_format(), datefmt or "%Y-%m-%d %H:%M:%S")
|
|
45
|
+
self.use_colors = use_colors and self._supports_color()
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _default_format() -> str:
|
|
49
|
+
"""Get the default log format string."""
|
|
50
|
+
return "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _supports_color() -> bool:
|
|
54
|
+
"""Check if terminal supports colors."""
|
|
55
|
+
if not hasattr(sys.stdout, "isatty"):
|
|
56
|
+
return False
|
|
57
|
+
return sys.stdout.isatty()
|
|
58
|
+
|
|
59
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
60
|
+
"""Format the log record with optional color."""
|
|
61
|
+
if self.use_colors:
|
|
62
|
+
color = self.COLORS.get(record.levelname, "")
|
|
63
|
+
# Store original levelname
|
|
64
|
+
original_levelname = record.levelname
|
|
65
|
+
record.levelname = f"{color}{record.levelname}{self.RESET}"
|
|
66
|
+
result = super().format(record)
|
|
67
|
+
# Restore original levelname
|
|
68
|
+
record.levelname = original_levelname
|
|
69
|
+
return result
|
|
70
|
+
return super().format(record)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_logger(
|
|
74
|
+
name: str = "devrev",
|
|
75
|
+
level: LogLevel = "WARN",
|
|
76
|
+
) -> logging.Logger:
|
|
77
|
+
"""Get a configured logger instance.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
name: Logger name (default: "devrev")
|
|
81
|
+
level: Logging level (default: "WARN")
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Configured logger instance
|
|
85
|
+
|
|
86
|
+
Example:
|
|
87
|
+
```python
|
|
88
|
+
from devrev.utils.logging import get_logger
|
|
89
|
+
|
|
90
|
+
logger = get_logger("devrev.http", level="DEBUG")
|
|
91
|
+
logger.debug("Making request to /accounts.list")
|
|
92
|
+
```
|
|
93
|
+
"""
|
|
94
|
+
logger = logging.getLogger(name)
|
|
95
|
+
|
|
96
|
+
# Normalize level
|
|
97
|
+
normalized_level = "WARNING" if level == "WARN" else level
|
|
98
|
+
logger.setLevel(getattr(logging, normalized_level))
|
|
99
|
+
|
|
100
|
+
# Only add handler if logger doesn't have one
|
|
101
|
+
if not logger.handlers:
|
|
102
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
103
|
+
handler.setFormatter(ColoredFormatter())
|
|
104
|
+
logger.addHandler(handler)
|
|
105
|
+
|
|
106
|
+
return logger
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def configure_logging(
|
|
110
|
+
level: LogLevel = "WARN",
|
|
111
|
+
use_colors: bool = True,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Configure SDK-wide logging.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
level: Logging level
|
|
117
|
+
use_colors: Whether to use colored output
|
|
118
|
+
|
|
119
|
+
Example:
|
|
120
|
+
```python
|
|
121
|
+
from devrev.utils.logging import configure_logging
|
|
122
|
+
|
|
123
|
+
configure_logging(level="DEBUG", use_colors=True)
|
|
124
|
+
```
|
|
125
|
+
"""
|
|
126
|
+
normalized_level = "WARNING" if level == "WARN" else level
|
|
127
|
+
|
|
128
|
+
logger = logging.getLogger("devrev")
|
|
129
|
+
logger.setLevel(getattr(logging, normalized_level))
|
|
130
|
+
|
|
131
|
+
# Clear existing handlers
|
|
132
|
+
logger.handlers.clear()
|
|
133
|
+
|
|
134
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
135
|
+
handler.setFormatter(ColoredFormatter(use_colors=use_colors))
|
|
136
|
+
logger.addHandler(handler)
|
|
137
|
+
|
|
138
|
+
# Prevent propagation to root logger
|
|
139
|
+
logger.propagate = False
|