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.
- basecamp/__init__.py +67 -0
- basecamp/_async_http.py +261 -0
- basecamp/_http.py +260 -0
- basecamp/_pagination.py +48 -0
- basecamp/_security.py +89 -0
- basecamp/_version.py +2 -0
- basecamp/async_auth.py +142 -0
- basecamp/async_client.py +392 -0
- basecamp/auth.py +138 -0
- basecamp/client.py +393 -0
- basecamp/config.py +54 -0
- basecamp/download.py +140 -0
- basecamp/errors.py +191 -0
- basecamp/generated/__init__.py +0 -0
- basecamp/generated/metadata.json +2300 -0
- basecamp/generated/services/__init__.py +140 -0
- basecamp/generated/services/_async_base.py +294 -0
- basecamp/generated/services/_base.py +297 -0
- basecamp/generated/services/account.py +74 -0
- basecamp/generated/services/attachments.py +34 -0
- basecamp/generated/services/automation.py +28 -0
- basecamp/generated/services/boosts.py +112 -0
- basecamp/generated/services/campfires.py +232 -0
- basecamp/generated/services/card_columns.py +178 -0
- basecamp/generated/services/card_steps.py +108 -0
- basecamp/generated/services/card_tables.py +28 -0
- basecamp/generated/services/cards.py +126 -0
- basecamp/generated/services/checkins.py +284 -0
- basecamp/generated/services/client_approvals.py +42 -0
- basecamp/generated/services/client_correspondences.py +46 -0
- basecamp/generated/services/client_replies.py +40 -0
- basecamp/generated/services/client_visibility.py +36 -0
- basecamp/generated/services/comments.py +76 -0
- basecamp/generated/services/documents.py +94 -0
- basecamp/generated/services/events.py +26 -0
- basecamp/generated/services/forwards.py +100 -0
- basecamp/generated/services/gauges.py +128 -0
- basecamp/generated/services/hill_charts.py +50 -0
- basecamp/generated/services/lineup.py +66 -0
- basecamp/generated/services/message_boards.py +28 -0
- basecamp/generated/services/message_types.py +90 -0
- basecamp/generated/services/messages.py +148 -0
- basecamp/generated/services/my_assignments.py +58 -0
- basecamp/generated/services/my_notifications.py +48 -0
- basecamp/generated/services/people.py +254 -0
- basecamp/generated/services/projects.py +114 -0
- basecamp/generated/services/recordings.py +106 -0
- basecamp/generated/services/reports.py +86 -0
- basecamp/generated/services/schedules.py +204 -0
- basecamp/generated/services/search.py +38 -0
- basecamp/generated/services/subscriptions.py +82 -0
- basecamp/generated/services/templates.py +136 -0
- basecamp/generated/services/timeline.py +30 -0
- basecamp/generated/services/timesheets.py +152 -0
- basecamp/generated/services/todolist_groups.py +62 -0
- basecamp/generated/services/todolists.py +78 -0
- basecamp/generated/services/todos.py +222 -0
- basecamp/generated/services/todosets.py +28 -0
- basecamp/generated/services/tools.py +130 -0
- basecamp/generated/services/uploads.py +118 -0
- basecamp/generated/services/vaults.py +76 -0
- basecamp/generated/services/webhooks_service.py +110 -0
- basecamp/generated/types.py +1755 -0
- basecamp/hooks.py +135 -0
- basecamp/oauth/__init__.py +24 -0
- basecamp/oauth/authorize.py +49 -0
- basecamp/oauth/config.py +14 -0
- basecamp/oauth/discovery.py +77 -0
- basecamp/oauth/errors.py +22 -0
- basecamp/oauth/exchange.py +162 -0
- basecamp/oauth/pkce.py +39 -0
- basecamp/oauth/token.py +27 -0
- basecamp/py.typed +0 -0
- basecamp/services/__init__.py +3 -0
- basecamp/services/authorization.py +27 -0
- basecamp/webhooks/__init__.py +15 -0
- basecamp/webhooks/errors.py +8 -0
- basecamp/webhooks/events.py +52 -0
- basecamp/webhooks/receiver.py +165 -0
- basecamp/webhooks/verify.py +22 -0
- basecamp_sdk-0.7.0.dist-info/METADATA +14 -0
- basecamp_sdk-0.7.0.dist-info/RECORD +83 -0
- 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
|
+
]
|
basecamp/_async_http.py
ADDED
|
@@ -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)
|
basecamp/_pagination.py
ADDED
|
@@ -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
|