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.
Files changed (45) hide show
  1. devrev/__init__.py +47 -0
  2. devrev/client.py +343 -0
  3. devrev/config.py +180 -0
  4. devrev/exceptions.py +205 -0
  5. devrev/models/__init__.py +499 -0
  6. devrev/models/accounts.py +187 -0
  7. devrev/models/articles.py +109 -0
  8. devrev/models/base.py +147 -0
  9. devrev/models/code_changes.py +103 -0
  10. devrev/models/conversations.py +115 -0
  11. devrev/models/dev_users.py +258 -0
  12. devrev/models/groups.py +140 -0
  13. devrev/models/links.py +107 -0
  14. devrev/models/parts.py +110 -0
  15. devrev/models/rev_users.py +177 -0
  16. devrev/models/slas.py +112 -0
  17. devrev/models/tags.py +90 -0
  18. devrev/models/timeline_entries.py +100 -0
  19. devrev/models/webhooks.py +109 -0
  20. devrev/models/works.py +280 -0
  21. devrev/py.typed +1 -0
  22. devrev/services/__init__.py +74 -0
  23. devrev/services/accounts.py +325 -0
  24. devrev/services/articles.py +80 -0
  25. devrev/services/base.py +234 -0
  26. devrev/services/code_changes.py +80 -0
  27. devrev/services/conversations.py +98 -0
  28. devrev/services/dev_users.py +401 -0
  29. devrev/services/groups.py +103 -0
  30. devrev/services/links.py +68 -0
  31. devrev/services/parts.py +100 -0
  32. devrev/services/rev_users.py +235 -0
  33. devrev/services/slas.py +82 -0
  34. devrev/services/tags.py +80 -0
  35. devrev/services/timeline_entries.py +80 -0
  36. devrev/services/webhooks.py +80 -0
  37. devrev/services/works.py +363 -0
  38. devrev/utils/__init__.py +14 -0
  39. devrev/utils/deprecation.py +49 -0
  40. devrev/utils/http.py +521 -0
  41. devrev/utils/logging.py +139 -0
  42. devrev/utils/pagination.py +155 -0
  43. devrev_python_sdk-1.0.0.dist-info/METADATA +774 -0
  44. devrev_python_sdk-1.0.0.dist-info/RECORD +45 -0
  45. 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)
@@ -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