basecamp-sdk 0.7.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 (83) hide show
  1. basecamp/__init__.py +67 -0
  2. basecamp/_async_http.py +261 -0
  3. basecamp/_http.py +260 -0
  4. basecamp/_pagination.py +48 -0
  5. basecamp/_security.py +89 -0
  6. basecamp/_version.py +2 -0
  7. basecamp/async_auth.py +142 -0
  8. basecamp/async_client.py +392 -0
  9. basecamp/auth.py +138 -0
  10. basecamp/client.py +393 -0
  11. basecamp/config.py +54 -0
  12. basecamp/download.py +140 -0
  13. basecamp/errors.py +191 -0
  14. basecamp/generated/__init__.py +0 -0
  15. basecamp/generated/metadata.json +2300 -0
  16. basecamp/generated/services/__init__.py +140 -0
  17. basecamp/generated/services/_async_base.py +294 -0
  18. basecamp/generated/services/_base.py +297 -0
  19. basecamp/generated/services/account.py +74 -0
  20. basecamp/generated/services/attachments.py +34 -0
  21. basecamp/generated/services/automation.py +28 -0
  22. basecamp/generated/services/boosts.py +112 -0
  23. basecamp/generated/services/campfires.py +232 -0
  24. basecamp/generated/services/card_columns.py +178 -0
  25. basecamp/generated/services/card_steps.py +108 -0
  26. basecamp/generated/services/card_tables.py +28 -0
  27. basecamp/generated/services/cards.py +126 -0
  28. basecamp/generated/services/checkins.py +284 -0
  29. basecamp/generated/services/client_approvals.py +42 -0
  30. basecamp/generated/services/client_correspondences.py +46 -0
  31. basecamp/generated/services/client_replies.py +40 -0
  32. basecamp/generated/services/client_visibility.py +36 -0
  33. basecamp/generated/services/comments.py +76 -0
  34. basecamp/generated/services/documents.py +94 -0
  35. basecamp/generated/services/events.py +26 -0
  36. basecamp/generated/services/forwards.py +100 -0
  37. basecamp/generated/services/gauges.py +128 -0
  38. basecamp/generated/services/hill_charts.py +50 -0
  39. basecamp/generated/services/lineup.py +66 -0
  40. basecamp/generated/services/message_boards.py +28 -0
  41. basecamp/generated/services/message_types.py +90 -0
  42. basecamp/generated/services/messages.py +148 -0
  43. basecamp/generated/services/my_assignments.py +58 -0
  44. basecamp/generated/services/my_notifications.py +48 -0
  45. basecamp/generated/services/people.py +254 -0
  46. basecamp/generated/services/projects.py +114 -0
  47. basecamp/generated/services/recordings.py +106 -0
  48. basecamp/generated/services/reports.py +86 -0
  49. basecamp/generated/services/schedules.py +204 -0
  50. basecamp/generated/services/search.py +38 -0
  51. basecamp/generated/services/subscriptions.py +82 -0
  52. basecamp/generated/services/templates.py +136 -0
  53. basecamp/generated/services/timeline.py +30 -0
  54. basecamp/generated/services/timesheets.py +152 -0
  55. basecamp/generated/services/todolist_groups.py +62 -0
  56. basecamp/generated/services/todolists.py +78 -0
  57. basecamp/generated/services/todos.py +222 -0
  58. basecamp/generated/services/todosets.py +28 -0
  59. basecamp/generated/services/tools.py +130 -0
  60. basecamp/generated/services/uploads.py +118 -0
  61. basecamp/generated/services/vaults.py +76 -0
  62. basecamp/generated/services/webhooks_service.py +110 -0
  63. basecamp/generated/types.py +1755 -0
  64. basecamp/hooks.py +135 -0
  65. basecamp/oauth/__init__.py +24 -0
  66. basecamp/oauth/authorize.py +49 -0
  67. basecamp/oauth/config.py +14 -0
  68. basecamp/oauth/discovery.py +77 -0
  69. basecamp/oauth/errors.py +22 -0
  70. basecamp/oauth/exchange.py +162 -0
  71. basecamp/oauth/pkce.py +39 -0
  72. basecamp/oauth/token.py +27 -0
  73. basecamp/py.typed +0 -0
  74. basecamp/services/__init__.py +3 -0
  75. basecamp/services/authorization.py +27 -0
  76. basecamp/webhooks/__init__.py +15 -0
  77. basecamp/webhooks/errors.py +8 -0
  78. basecamp/webhooks/events.py +52 -0
  79. basecamp/webhooks/receiver.py +165 -0
  80. basecamp/webhooks/verify.py +22 -0
  81. basecamp_sdk-0.7.0.dist-info/METADATA +14 -0
  82. basecamp_sdk-0.7.0.dist-info/RECORD +83 -0
  83. basecamp_sdk-0.7.0.dist-info/WHEEL +4 -0
basecamp/__init__.py ADDED
@@ -0,0 +1,67 @@
1
+ from basecamp._pagination import ListMeta, ListResult
2
+ from basecamp._version import API_VERSION, VERSION
3
+ from basecamp.async_auth import (
4
+ AsyncAuthStrategy,
5
+ AsyncBearerAuth,
6
+ )
7
+ from basecamp.async_auth import (
8
+ AsyncTokenProvider as AsyncTokenProvider,
9
+ )
10
+ from basecamp.async_client import AsyncAccountClient, AsyncClient
11
+ from basecamp.auth import AuthStrategy, BearerAuth, OAuthTokenProvider, StaticTokenProvider, TokenProvider
12
+ from basecamp.client import AccountClient, Client
13
+ from basecamp.config import Config
14
+ from basecamp.download import DownloadResult
15
+ from basecamp.errors import (
16
+ AmbiguousError,
17
+ ApiError,
18
+ AuthError,
19
+ BasecampError,
20
+ ErrorCode,
21
+ ExitCode,
22
+ ForbiddenError,
23
+ NetworkError,
24
+ NotFoundError,
25
+ RateLimitError,
26
+ UsageError,
27
+ ValidationError,
28
+ )
29
+ from basecamp.hooks import BasecampHooks, OperationInfo, OperationResult, RequestInfo, RequestResult
30
+
31
+ __all__ = [
32
+ "Client",
33
+ "AccountClient",
34
+ "AsyncClient",
35
+ "AsyncAccountClient",
36
+ "Config",
37
+ "BasecampError",
38
+ "AuthError",
39
+ "ForbiddenError",
40
+ "NotFoundError",
41
+ "RateLimitError",
42
+ "ValidationError",
43
+ "NetworkError",
44
+ "ApiError",
45
+ "AmbiguousError",
46
+ "UsageError",
47
+ "ErrorCode",
48
+ "ExitCode",
49
+ "BasecampHooks",
50
+ "OperationInfo",
51
+ "OperationResult",
52
+ "RequestInfo",
53
+ "RequestResult",
54
+ "AuthStrategy",
55
+ "BearerAuth",
56
+ "TokenProvider",
57
+ "StaticTokenProvider",
58
+ "OAuthTokenProvider",
59
+ "AsyncAuthStrategy",
60
+ "AsyncBearerAuth",
61
+ "AsyncTokenProvider",
62
+ "ListResult",
63
+ "ListMeta",
64
+ "DownloadResult",
65
+ "VERSION",
66
+ "API_VERSION",
67
+ ]
@@ -0,0 +1,261 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import random
5
+ import time
6
+
7
+ import httpx
8
+
9
+ from basecamp import _security
10
+ from basecamp._version import API_VERSION, VERSION
11
+ from basecamp.errors import (
12
+ ApiError,
13
+ AuthError,
14
+ BasecampError,
15
+ NetworkError,
16
+ RateLimitError,
17
+ UsageError,
18
+ error_from_response,
19
+ )
20
+ from basecamp.hooks import BasecampHooks, RequestInfo, RequestResult, safe_hook
21
+
22
+
23
+ class AsyncHttpClient:
24
+ """Async HTTP client with retry, auth injection, and error mapping."""
25
+
26
+ USER_AGENT = f"basecamp-sdk-python/{VERSION} (api:{API_VERSION})"
27
+
28
+ def __init__(
29
+ self,
30
+ config,
31
+ auth,
32
+ hooks: BasecampHooks | None = None,
33
+ *,
34
+ user_agent: str | None = None,
35
+ metadata: dict | None = None,
36
+ ):
37
+ self._config = config
38
+ self._auth = auth
39
+ self._hooks = hooks or BasecampHooks()
40
+ self._metadata = metadata or {}
41
+ self._user_agent = user_agent or self.USER_AGENT
42
+ self._client = httpx.AsyncClient(
43
+ timeout=httpx.Timeout(config.timeout, connect=10.0),
44
+ follow_redirects=False,
45
+ )
46
+
47
+ @property
48
+ def base_url(self) -> str:
49
+ return self._config.base_url
50
+
51
+ async def get(self, url: str, *, params: dict | None = None) -> httpx.Response:
52
+ url = self._build_url(url)
53
+ return await self._request_with_retry("GET", url, params=params)
54
+
55
+ async def get_absolute(self, url: str, *, params: dict | None = None) -> httpx.Response:
56
+ if not _security.is_localhost(url):
57
+ _security.require_https(url, "URL")
58
+ return await self._request_with_retry("GET", url, params=params)
59
+
60
+ async def post(self, url: str, *, json_body: dict | None = None, operation: str | None = None) -> httpx.Response:
61
+ url = self._build_url(url)
62
+ return await self._mutation("POST", url, json_body=json_body, operation=operation)
63
+
64
+ async def put(self, url: str, *, json_body: dict | None = None, operation: str | None = None) -> httpx.Response:
65
+ url = self._build_url(url)
66
+ return await self._mutation("PUT", url, json_body=json_body, operation=operation)
67
+
68
+ async def delete(self, url: str, *, operation: str | None = None) -> httpx.Response:
69
+ url = self._build_url(url)
70
+ return await self._mutation("DELETE", url, operation=operation)
71
+
72
+ async def post_raw(
73
+ self,
74
+ url: str,
75
+ *,
76
+ content: bytes,
77
+ content_type: str,
78
+ params: dict | None = None,
79
+ operation: str | None = None,
80
+ ) -> httpx.Response:
81
+ url = self._build_url(url)
82
+ if operation and self._is_retryable_operation(operation):
83
+ return await self._request_with_retry(
84
+ "POST",
85
+ url,
86
+ params=params,
87
+ content=content,
88
+ content_type=content_type,
89
+ )
90
+ return await self._single_request(
91
+ "POST",
92
+ url,
93
+ params=params,
94
+ content=content,
95
+ content_type=content_type,
96
+ )
97
+
98
+ async def get_no_retry(self, url: str) -> httpx.Response:
99
+ url = self._build_url(url)
100
+ return await self._single_request("GET", url)
101
+
102
+ async def close(self) -> None:
103
+ await self._client.aclose()
104
+
105
+ # -- internal --
106
+
107
+ async def _mutation(
108
+ self, method: str, url: str, *, json_body: dict | None = None, operation: str | None = None
109
+ ) -> httpx.Response:
110
+ if operation and self._is_retryable_operation(operation):
111
+ return await self._request_with_retry(method, url, json_body=json_body)
112
+ return await self._single_request(method, url, json_body=json_body)
113
+
114
+ async def _request_with_retry(
115
+ self,
116
+ method: str,
117
+ url: str,
118
+ *,
119
+ params: dict | None = None,
120
+ json_body: dict | None = None,
121
+ content: bytes | None = None,
122
+ content_type: str | None = None,
123
+ ) -> httpx.Response:
124
+ attempt = 0
125
+ last_error: BasecampError | None = None
126
+
127
+ while True:
128
+ attempt += 1
129
+ if attempt > self._config.max_retries + 1:
130
+ break
131
+
132
+ try:
133
+ return await self._single_request(
134
+ method,
135
+ url,
136
+ params=params,
137
+ json_body=json_body,
138
+ content=content,
139
+ content_type=content_type,
140
+ attempt=attempt,
141
+ )
142
+ except (RateLimitError, NetworkError, ApiError) as e:
143
+ if not e.retryable:
144
+ raise
145
+ last_error = e
146
+ if attempt > self._config.max_retries:
147
+ break
148
+ delay = self._calculate_delay(attempt, e.retry_after)
149
+ safe_hook(
150
+ self._hooks.on_retry,
151
+ RequestInfo(method=method, url=url, attempt=attempt),
152
+ attempt + 1,
153
+ e,
154
+ delay,
155
+ )
156
+ await asyncio.sleep(delay)
157
+
158
+ if last_error:
159
+ raise last_error
160
+ raise ApiError(f"Request failed after {self._config.max_retries} retries")
161
+
162
+ async def _single_request(
163
+ self,
164
+ method: str,
165
+ url: str,
166
+ *,
167
+ params: dict | None = None,
168
+ json_body: dict | None = None,
169
+ content: bytes | None = None,
170
+ content_type: str | None = None,
171
+ attempt: int = 1,
172
+ _retry_count: int = 0,
173
+ ) -> httpx.Response:
174
+ info = RequestInfo(method=method, url=url, attempt=attempt)
175
+ safe_hook(self._hooks.on_request_start, info)
176
+ start = time.monotonic()
177
+
178
+ try:
179
+ headers = self._request_headers()
180
+ if content_type:
181
+ headers["Content-Type"] = content_type
182
+ await self._auth.authenticate(headers)
183
+
184
+ response = await self._client.request(
185
+ method,
186
+ url,
187
+ headers=headers,
188
+ params=params,
189
+ json=json_body,
190
+ content=content,
191
+ )
192
+
193
+ if response.status_code >= 400:
194
+ error = self._handle_error(response)
195
+ # 401 retry with async token refresh
196
+ if isinstance(error, AuthError) and error.http_status == 401 and _retry_count < 1:
197
+ tp = getattr(self._auth, "token_provider", None)
198
+ if tp and getattr(tp, "refreshable", False) and await tp.refresh():
199
+ return await self._single_request(
200
+ method,
201
+ url,
202
+ params=params,
203
+ json_body=json_body,
204
+ content=content,
205
+ content_type=content_type,
206
+ attempt=attempt,
207
+ _retry_count=_retry_count + 1,
208
+ )
209
+ raise error
210
+
211
+ duration = time.monotonic() - start
212
+ safe_hook(
213
+ self._hooks.on_request_end, info, RequestResult(status_code=response.status_code, duration=duration)
214
+ )
215
+ return response
216
+
217
+ except BasecampError as e:
218
+ duration = time.monotonic() - start
219
+ safe_hook(self._hooks.on_request_end, info, RequestResult(duration=duration, error=e))
220
+ raise
221
+ except httpx.HTTPError as e:
222
+ duration = time.monotonic() - start
223
+ error = NetworkError(f"Connection failed: {e}")
224
+ safe_hook(self._hooks.on_request_end, info, RequestResult(duration=duration, error=error))
225
+ raise error from e
226
+
227
+ def _handle_error(self, response: httpx.Response) -> BasecampError:
228
+ body = response.content[: _security.MAX_ERROR_BODY_BYTES] if response.content else None
229
+ return error_from_response(
230
+ response.status_code,
231
+ body,
232
+ dict(response.headers),
233
+ )
234
+
235
+ def _request_headers(self) -> dict[str, str]:
236
+ return {
237
+ "User-Agent": self._user_agent,
238
+ "Accept": "application/json",
239
+ }
240
+
241
+ def _build_url(self, path: str) -> str:
242
+ if path.startswith("https://"):
243
+ return path
244
+ if path.startswith("http://"):
245
+ if not _security.is_localhost(path):
246
+ raise UsageError(f"URL must use HTTPS: {path}")
247
+ return path
248
+ if not path.startswith("/"):
249
+ path = f"/{path}"
250
+ return f"{self._config.base_url}{path}"
251
+
252
+ def _calculate_delay(self, attempt: int, server_retry_after: int | None = None) -> float:
253
+ if server_retry_after and server_retry_after > 0:
254
+ return float(server_retry_after)
255
+ base = self._config.base_delay * (2 ** (attempt - 1))
256
+ jitter = random.random() * self._config.max_jitter
257
+ return base + jitter
258
+
259
+ def _is_retryable_operation(self, operation: str) -> bool:
260
+ op_meta = self._metadata.get(operation, {})
261
+ return op_meta.get("idempotent", False)
basecamp/_http.py ADDED
@@ -0,0 +1,260 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ import time
5
+
6
+ import httpx
7
+
8
+ from basecamp import _security
9
+ from basecamp._version import API_VERSION, VERSION
10
+ from basecamp.errors import (
11
+ ApiError,
12
+ AuthError,
13
+ BasecampError,
14
+ NetworkError,
15
+ RateLimitError,
16
+ UsageError,
17
+ error_from_response,
18
+ )
19
+ from basecamp.hooks import BasecampHooks, RequestInfo, RequestResult, safe_hook
20
+
21
+
22
+ class HttpClient:
23
+ """Sync HTTP client with retry, auth injection, and error mapping."""
24
+
25
+ USER_AGENT = f"basecamp-sdk-python/{VERSION} (api:{API_VERSION})"
26
+
27
+ def __init__(
28
+ self,
29
+ config,
30
+ auth,
31
+ hooks: BasecampHooks | None = None,
32
+ *,
33
+ user_agent: str | None = None,
34
+ metadata: dict | None = None,
35
+ ):
36
+ self._config = config
37
+ self._auth = auth
38
+ self._hooks = hooks or BasecampHooks()
39
+ self._metadata = metadata or {}
40
+ self._user_agent = user_agent or self.USER_AGENT
41
+ self._client = httpx.Client(
42
+ timeout=httpx.Timeout(config.timeout, connect=10.0),
43
+ follow_redirects=False,
44
+ )
45
+
46
+ @property
47
+ def base_url(self) -> str:
48
+ return self._config.base_url
49
+
50
+ def get(self, url: str, *, params: dict | None = None) -> httpx.Response:
51
+ url = self._build_url(url)
52
+ return self._request_with_retry("GET", url, params=params)
53
+
54
+ def get_absolute(self, url: str, *, params: dict | None = None) -> httpx.Response:
55
+ if not _security.is_localhost(url):
56
+ _security.require_https(url, "URL")
57
+ return self._request_with_retry("GET", url, params=params)
58
+
59
+ def post(self, url: str, *, json_body: dict | None = None, operation: str | None = None) -> httpx.Response:
60
+ url = self._build_url(url)
61
+ return self._mutation("POST", url, json_body=json_body, operation=operation)
62
+
63
+ def put(self, url: str, *, json_body: dict | None = None, operation: str | None = None) -> httpx.Response:
64
+ url = self._build_url(url)
65
+ return self._mutation("PUT", url, json_body=json_body, operation=operation)
66
+
67
+ def delete(self, url: str, *, operation: str | None = None) -> httpx.Response:
68
+ url = self._build_url(url)
69
+ return self._mutation("DELETE", url, operation=operation)
70
+
71
+ def post_raw(
72
+ self,
73
+ url: str,
74
+ *,
75
+ content: bytes,
76
+ content_type: str,
77
+ params: dict | None = None,
78
+ operation: str | None = None,
79
+ ) -> httpx.Response:
80
+ url = self._build_url(url)
81
+ if operation and self._is_retryable_operation(operation):
82
+ return self._request_with_retry(
83
+ "POST",
84
+ url,
85
+ params=params,
86
+ content=content,
87
+ content_type=content_type,
88
+ )
89
+ return self._single_request(
90
+ "POST",
91
+ url,
92
+ params=params,
93
+ content=content,
94
+ content_type=content_type,
95
+ )
96
+
97
+ def get_no_retry(self, url: str) -> httpx.Response:
98
+ url = self._build_url(url)
99
+ return self._single_request("GET", url)
100
+
101
+ def close(self) -> None:
102
+ self._client.close()
103
+
104
+ # -- internal --
105
+
106
+ def _mutation(
107
+ self, method: str, url: str, *, json_body: dict | None = None, operation: str | None = None
108
+ ) -> httpx.Response:
109
+ if operation and self._is_retryable_operation(operation):
110
+ return self._request_with_retry(method, url, json_body=json_body)
111
+ return self._single_request(method, url, json_body=json_body)
112
+
113
+ def _request_with_retry(
114
+ self,
115
+ method: str,
116
+ url: str,
117
+ *,
118
+ params: dict | None = None,
119
+ json_body: dict | None = None,
120
+ content: bytes | None = None,
121
+ content_type: str | None = None,
122
+ ) -> httpx.Response:
123
+ attempt = 0
124
+ last_error: BasecampError | None = None
125
+
126
+ while True:
127
+ attempt += 1
128
+ if attempt > self._config.max_retries + 1:
129
+ break
130
+
131
+ try:
132
+ return self._single_request(
133
+ method,
134
+ url,
135
+ params=params,
136
+ json_body=json_body,
137
+ content=content,
138
+ content_type=content_type,
139
+ attempt=attempt,
140
+ )
141
+ except (RateLimitError, NetworkError, ApiError) as e:
142
+ if not e.retryable:
143
+ raise
144
+ last_error = e
145
+ if attempt > self._config.max_retries:
146
+ break
147
+ delay = self._calculate_delay(attempt, e.retry_after)
148
+ safe_hook(
149
+ self._hooks.on_retry,
150
+ RequestInfo(method=method, url=url, attempt=attempt),
151
+ attempt + 1,
152
+ e,
153
+ delay,
154
+ )
155
+ time.sleep(delay)
156
+
157
+ if last_error:
158
+ raise last_error
159
+ raise ApiError(f"Request failed after {self._config.max_retries} retries")
160
+
161
+ def _single_request(
162
+ self,
163
+ method: str,
164
+ url: str,
165
+ *,
166
+ params: dict | None = None,
167
+ json_body: dict | None = None,
168
+ content: bytes | None = None,
169
+ content_type: str | None = None,
170
+ attempt: int = 1,
171
+ _retry_count: int = 0,
172
+ ) -> httpx.Response:
173
+ info = RequestInfo(method=method, url=url, attempt=attempt)
174
+ safe_hook(self._hooks.on_request_start, info)
175
+ start = time.monotonic()
176
+
177
+ try:
178
+ headers = self._request_headers()
179
+ if content_type:
180
+ headers["Content-Type"] = content_type
181
+ self._auth.authenticate(headers)
182
+
183
+ response = self._client.request(
184
+ method,
185
+ url,
186
+ headers=headers,
187
+ params=params,
188
+ json=json_body,
189
+ content=content,
190
+ )
191
+
192
+ if response.status_code >= 400:
193
+ error = self._handle_error(response)
194
+ # 401 retry with token refresh
195
+ if isinstance(error, AuthError) and error.http_status == 401 and _retry_count < 1:
196
+ tp = getattr(self._auth, "token_provider", None)
197
+ if tp and getattr(tp, "refreshable", False) and tp.refresh():
198
+ return self._single_request(
199
+ method,
200
+ url,
201
+ params=params,
202
+ json_body=json_body,
203
+ content=content,
204
+ content_type=content_type,
205
+ attempt=attempt,
206
+ _retry_count=_retry_count + 1,
207
+ )
208
+ raise error
209
+
210
+ duration = time.monotonic() - start
211
+ safe_hook(
212
+ self._hooks.on_request_end, info, RequestResult(status_code=response.status_code, duration=duration)
213
+ )
214
+ return response
215
+
216
+ except BasecampError as e:
217
+ duration = time.monotonic() - start
218
+ safe_hook(self._hooks.on_request_end, info, RequestResult(duration=duration, error=e))
219
+ raise
220
+ except httpx.HTTPError as e:
221
+ duration = time.monotonic() - start
222
+ error = NetworkError(f"Connection failed: {e}")
223
+ safe_hook(self._hooks.on_request_end, info, RequestResult(duration=duration, error=error))
224
+ raise error from e
225
+
226
+ def _handle_error(self, response: httpx.Response) -> BasecampError:
227
+ body = response.content[: _security.MAX_ERROR_BODY_BYTES] if response.content else None
228
+ return error_from_response(
229
+ response.status_code,
230
+ body,
231
+ dict(response.headers),
232
+ )
233
+
234
+ def _request_headers(self) -> dict[str, str]:
235
+ return {
236
+ "User-Agent": self._user_agent,
237
+ "Accept": "application/json",
238
+ }
239
+
240
+ def _build_url(self, path: str) -> str:
241
+ if path.startswith("https://"):
242
+ return path
243
+ if path.startswith("http://"):
244
+ if not _security.is_localhost(path):
245
+ raise UsageError(f"URL must use HTTPS: {path}")
246
+ return path
247
+ if not path.startswith("/"):
248
+ path = f"/{path}"
249
+ return f"{self._config.base_url}{path}"
250
+
251
+ def _calculate_delay(self, attempt: int, server_retry_after: int | None = None) -> float:
252
+ if server_retry_after and server_retry_after > 0:
253
+ return float(server_retry_after)
254
+ base = self._config.base_delay * (2 ** (attempt - 1))
255
+ jitter = random.random() * self._config.max_jitter
256
+ return base + jitter
257
+
258
+ def _is_retryable_operation(self, operation: str) -> bool:
259
+ op_meta = self._metadata.get(operation, {})
260
+ return op_meta.get("idempotent", False)
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import TypeVar
6
+
7
+ T = TypeVar("T")
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class ListMeta:
12
+ total_count: int = 0
13
+ truncated: bool = False
14
+
15
+
16
+ class ListResult(list[T]):
17
+ """A list with pagination metadata."""
18
+
19
+ meta: ListMeta
20
+
21
+ def __init__(self, items: list[T], meta: ListMeta | None = None):
22
+ super().__init__(items)
23
+ self.meta = meta or ListMeta()
24
+
25
+ def __repr__(self) -> str:
26
+ return f"ListResult({list.__repr__(self)}, meta={self.meta!r})"
27
+
28
+
29
+ def parse_next_link(link_header: str | None) -> str | None:
30
+ """Parse the next page URL from a Link header."""
31
+ if not link_header:
32
+ return None
33
+ for part in link_header.split(","):
34
+ part = part.strip()
35
+ if 'rel="next"' in part:
36
+ match = re.search(r"<([^>]+)>", part)
37
+ if match:
38
+ return match.group(1)
39
+ return None
40
+
41
+
42
+ def parse_total_count(headers: dict[str, str]) -> int:
43
+ """Parse X-Total-Count header, returning 0 if missing."""
44
+ value = headers.get("X-Total-Count") or headers.get("x-total-count") or ""
45
+ try:
46
+ return int(value)
47
+ except (ValueError, TypeError):
48
+ return 0