basecamp-sdk 0.7.0__tar.gz

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 (109) hide show
  1. basecamp_sdk-0.7.0/.gitignore +51 -0
  2. basecamp_sdk-0.7.0/PKG-INFO +14 -0
  3. basecamp_sdk-0.7.0/README.md +563 -0
  4. basecamp_sdk-0.7.0/pyproject.toml +79 -0
  5. basecamp_sdk-0.7.0/scripts/generate_metadata.py +68 -0
  6. basecamp_sdk-0.7.0/scripts/generate_services.py +797 -0
  7. basecamp_sdk-0.7.0/scripts/generate_types.py +115 -0
  8. basecamp_sdk-0.7.0/src/basecamp/__init__.py +67 -0
  9. basecamp_sdk-0.7.0/src/basecamp/_async_http.py +261 -0
  10. basecamp_sdk-0.7.0/src/basecamp/_http.py +260 -0
  11. basecamp_sdk-0.7.0/src/basecamp/_pagination.py +48 -0
  12. basecamp_sdk-0.7.0/src/basecamp/_security.py +89 -0
  13. basecamp_sdk-0.7.0/src/basecamp/_version.py +2 -0
  14. basecamp_sdk-0.7.0/src/basecamp/async_auth.py +142 -0
  15. basecamp_sdk-0.7.0/src/basecamp/async_client.py +392 -0
  16. basecamp_sdk-0.7.0/src/basecamp/auth.py +138 -0
  17. basecamp_sdk-0.7.0/src/basecamp/client.py +393 -0
  18. basecamp_sdk-0.7.0/src/basecamp/config.py +54 -0
  19. basecamp_sdk-0.7.0/src/basecamp/download.py +140 -0
  20. basecamp_sdk-0.7.0/src/basecamp/errors.py +191 -0
  21. basecamp_sdk-0.7.0/src/basecamp/generated/__init__.py +0 -0
  22. basecamp_sdk-0.7.0/src/basecamp/generated/metadata.json +2300 -0
  23. basecamp_sdk-0.7.0/src/basecamp/generated/services/__init__.py +140 -0
  24. basecamp_sdk-0.7.0/src/basecamp/generated/services/_async_base.py +294 -0
  25. basecamp_sdk-0.7.0/src/basecamp/generated/services/_base.py +297 -0
  26. basecamp_sdk-0.7.0/src/basecamp/generated/services/account.py +74 -0
  27. basecamp_sdk-0.7.0/src/basecamp/generated/services/attachments.py +34 -0
  28. basecamp_sdk-0.7.0/src/basecamp/generated/services/automation.py +28 -0
  29. basecamp_sdk-0.7.0/src/basecamp/generated/services/boosts.py +112 -0
  30. basecamp_sdk-0.7.0/src/basecamp/generated/services/campfires.py +232 -0
  31. basecamp_sdk-0.7.0/src/basecamp/generated/services/card_columns.py +178 -0
  32. basecamp_sdk-0.7.0/src/basecamp/generated/services/card_steps.py +108 -0
  33. basecamp_sdk-0.7.0/src/basecamp/generated/services/card_tables.py +28 -0
  34. basecamp_sdk-0.7.0/src/basecamp/generated/services/cards.py +126 -0
  35. basecamp_sdk-0.7.0/src/basecamp/generated/services/checkins.py +284 -0
  36. basecamp_sdk-0.7.0/src/basecamp/generated/services/client_approvals.py +42 -0
  37. basecamp_sdk-0.7.0/src/basecamp/generated/services/client_correspondences.py +46 -0
  38. basecamp_sdk-0.7.0/src/basecamp/generated/services/client_replies.py +40 -0
  39. basecamp_sdk-0.7.0/src/basecamp/generated/services/client_visibility.py +36 -0
  40. basecamp_sdk-0.7.0/src/basecamp/generated/services/comments.py +76 -0
  41. basecamp_sdk-0.7.0/src/basecamp/generated/services/documents.py +94 -0
  42. basecamp_sdk-0.7.0/src/basecamp/generated/services/events.py +26 -0
  43. basecamp_sdk-0.7.0/src/basecamp/generated/services/forwards.py +100 -0
  44. basecamp_sdk-0.7.0/src/basecamp/generated/services/gauges.py +128 -0
  45. basecamp_sdk-0.7.0/src/basecamp/generated/services/hill_charts.py +50 -0
  46. basecamp_sdk-0.7.0/src/basecamp/generated/services/lineup.py +66 -0
  47. basecamp_sdk-0.7.0/src/basecamp/generated/services/message_boards.py +28 -0
  48. basecamp_sdk-0.7.0/src/basecamp/generated/services/message_types.py +90 -0
  49. basecamp_sdk-0.7.0/src/basecamp/generated/services/messages.py +148 -0
  50. basecamp_sdk-0.7.0/src/basecamp/generated/services/my_assignments.py +58 -0
  51. basecamp_sdk-0.7.0/src/basecamp/generated/services/my_notifications.py +48 -0
  52. basecamp_sdk-0.7.0/src/basecamp/generated/services/people.py +254 -0
  53. basecamp_sdk-0.7.0/src/basecamp/generated/services/projects.py +114 -0
  54. basecamp_sdk-0.7.0/src/basecamp/generated/services/recordings.py +106 -0
  55. basecamp_sdk-0.7.0/src/basecamp/generated/services/reports.py +86 -0
  56. basecamp_sdk-0.7.0/src/basecamp/generated/services/schedules.py +204 -0
  57. basecamp_sdk-0.7.0/src/basecamp/generated/services/search.py +38 -0
  58. basecamp_sdk-0.7.0/src/basecamp/generated/services/subscriptions.py +82 -0
  59. basecamp_sdk-0.7.0/src/basecamp/generated/services/templates.py +136 -0
  60. basecamp_sdk-0.7.0/src/basecamp/generated/services/timeline.py +30 -0
  61. basecamp_sdk-0.7.0/src/basecamp/generated/services/timesheets.py +152 -0
  62. basecamp_sdk-0.7.0/src/basecamp/generated/services/todolist_groups.py +62 -0
  63. basecamp_sdk-0.7.0/src/basecamp/generated/services/todolists.py +78 -0
  64. basecamp_sdk-0.7.0/src/basecamp/generated/services/todos.py +222 -0
  65. basecamp_sdk-0.7.0/src/basecamp/generated/services/todosets.py +28 -0
  66. basecamp_sdk-0.7.0/src/basecamp/generated/services/tools.py +130 -0
  67. basecamp_sdk-0.7.0/src/basecamp/generated/services/uploads.py +118 -0
  68. basecamp_sdk-0.7.0/src/basecamp/generated/services/vaults.py +76 -0
  69. basecamp_sdk-0.7.0/src/basecamp/generated/services/webhooks_service.py +110 -0
  70. basecamp_sdk-0.7.0/src/basecamp/generated/types.py +1755 -0
  71. basecamp_sdk-0.7.0/src/basecamp/hooks.py +135 -0
  72. basecamp_sdk-0.7.0/src/basecamp/oauth/__init__.py +24 -0
  73. basecamp_sdk-0.7.0/src/basecamp/oauth/authorize.py +49 -0
  74. basecamp_sdk-0.7.0/src/basecamp/oauth/config.py +14 -0
  75. basecamp_sdk-0.7.0/src/basecamp/oauth/discovery.py +77 -0
  76. basecamp_sdk-0.7.0/src/basecamp/oauth/errors.py +22 -0
  77. basecamp_sdk-0.7.0/src/basecamp/oauth/exchange.py +162 -0
  78. basecamp_sdk-0.7.0/src/basecamp/oauth/pkce.py +39 -0
  79. basecamp_sdk-0.7.0/src/basecamp/oauth/token.py +27 -0
  80. basecamp_sdk-0.7.0/src/basecamp/py.typed +0 -0
  81. basecamp_sdk-0.7.0/src/basecamp/services/__init__.py +3 -0
  82. basecamp_sdk-0.7.0/src/basecamp/services/authorization.py +27 -0
  83. basecamp_sdk-0.7.0/src/basecamp/webhooks/__init__.py +15 -0
  84. basecamp_sdk-0.7.0/src/basecamp/webhooks/errors.py +8 -0
  85. basecamp_sdk-0.7.0/src/basecamp/webhooks/events.py +52 -0
  86. basecamp_sdk-0.7.0/src/basecamp/webhooks/receiver.py +165 -0
  87. basecamp_sdk-0.7.0/src/basecamp/webhooks/verify.py +22 -0
  88. basecamp_sdk-0.7.0/tests/__init__.py +0 -0
  89. basecamp_sdk-0.7.0/tests/conftest.py +45 -0
  90. basecamp_sdk-0.7.0/tests/oauth/__init__.py +0 -0
  91. basecamp_sdk-0.7.0/tests/oauth/test_authorize.py +64 -0
  92. basecamp_sdk-0.7.0/tests/oauth/test_discovery.py +82 -0
  93. basecamp_sdk-0.7.0/tests/oauth/test_exchange.py +123 -0
  94. basecamp_sdk-0.7.0/tests/oauth/test_pkce.py +42 -0
  95. basecamp_sdk-0.7.0/tests/services/__init__.py +0 -0
  96. basecamp_sdk-0.7.0/tests/test_auth.py +175 -0
  97. basecamp_sdk-0.7.0/tests/test_client.py +74 -0
  98. basecamp_sdk-0.7.0/tests/test_config.py +91 -0
  99. basecamp_sdk-0.7.0/tests/test_download.py +109 -0
  100. basecamp_sdk-0.7.0/tests/test_errors.py +164 -0
  101. basecamp_sdk-0.7.0/tests/test_hooks.py +92 -0
  102. basecamp_sdk-0.7.0/tests/test_http.py +183 -0
  103. basecamp_sdk-0.7.0/tests/test_pagination.py +77 -0
  104. basecamp_sdk-0.7.0/tests/test_security.py +131 -0
  105. basecamp_sdk-0.7.0/tests/webhooks/__init__.py +0 -0
  106. basecamp_sdk-0.7.0/tests/webhooks/test_events.py +23 -0
  107. basecamp_sdk-0.7.0/tests/webhooks/test_receiver.py +130 -0
  108. basecamp_sdk-0.7.0/tests/webhooks/test_verify.py +40 -0
  109. basecamp_sdk-0.7.0/uv.lock +524 -0
@@ -0,0 +1,51 @@
1
+ # Internal documentation (not for public consumption)
2
+ docs/
3
+
4
+ # Smithy build output (regenerate with: cd spec && smithy build)
5
+ spec/build/
6
+
7
+ # Conformance test runner artifacts
8
+ conformance/runner/go/conformance-runner
9
+ conformance/runner/ruby/Gemfile.lock
10
+ conformance/runner/typescript/node_modules/
11
+
12
+ # Claude Code session files (except skills, which are committed)
13
+ .claude/*
14
+ !.claude/skills/
15
+
16
+ # Ruby SDK build artifacts
17
+ ruby/.yardoc/
18
+ ruby/doc/
19
+ ruby/coverage/
20
+ ruby/.bundle/
21
+ ruby/vendor/
22
+ ruby/pkg/
23
+ ruby/*.gem
24
+ review-session.md
25
+
26
+
27
+ # Swift SDK build artifacts
28
+ swift/.build/
29
+ swift/.swiftpm/
30
+ swift/Package.resolved
31
+
32
+ # Python SDK build artifacts
33
+ python/.venv/
34
+ python/**/__pycache__/
35
+ python/**/*.pyc
36
+ python/dist/
37
+ python/.pytest_cache/
38
+ python/.coverage
39
+ python/src/*.egg-info/
40
+ conformance/runner/python/.venv/
41
+ conformance/runner/python/uv.lock
42
+
43
+ # TypeScript SDK build artifacts
44
+ typescript/coverage/
45
+
46
+ # Kotlin SDK build artifacts
47
+ kotlin/.gradle/
48
+ kotlin/build/
49
+ kotlin/sdk/build/
50
+ kotlin/generator/build/
51
+ kotlin/conformance/build/
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: basecamp-sdk
3
+ Version: 0.7.0
4
+ Summary: Official Python SDK for the Basecamp API
5
+ Project-URL: Homepage, https://github.com/basecamp/basecamp-sdk
6
+ Project-URL: Repository, https://github.com/basecamp/basecamp-sdk
7
+ Project-URL: Documentation, https://github.com/basecamp/basecamp-sdk/tree/main/python
8
+ Author: 37signals
9
+ License-Expression: MIT
10
+ Keywords: 37signals,api,basecamp,sdk
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: httpx<1,>=0.27
@@ -0,0 +1,563 @@
1
+ # Basecamp Python SDK
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/basecamp-sdk)](https://pypi.org/project/basecamp-sdk/)
4
+ [![Test](https://github.com/basecamp/basecamp-sdk/actions/workflows/test.yml/badge.svg)](https://github.com/basecamp/basecamp-sdk/actions/workflows/test.yml)
5
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
6
+
7
+ Official Python SDK for the [Basecamp API](https://github.com/basecamp/bc3-api).
8
+
9
+ ## Features
10
+
11
+ - **Full API coverage** — 40 generated services covering projects, todos, messages, schedules, campfires, card tables, and more
12
+ - **OAuth 2.0 authentication** — PKCE support, token refresh, Launchpad discovery
13
+ - **Static token authentication** — Simple setup for personal integrations
14
+ - **Automatic retry with backoff** — Exponential backoff with jitter, respects `Retry-After` headers
15
+ - **Pagination handling** — Automatic Link header-based pagination with `ListResult`
16
+ - **Structured errors** — Typed exceptions with error codes, hints, and CLI-friendly exit codes
17
+ - **Observability hooks** — Integration points for logging, metrics, and tracing
18
+ - **Webhook verification** — HMAC signature verification, deduplication, glob-based routing
19
+ - **Async support** — Full async/await API via `AsyncClient` backed by httpx
20
+ - **File downloads** — Authenticated downloads with redirect following
21
+ - **Type hints** — Full type annotations for IDE support
22
+
23
+ ## Requirements
24
+
25
+ - Python 3.11 or later
26
+ - [httpx](https://www.python-httpx.org/) (installed automatically)
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install basecamp-sdk
32
+ ```
33
+
34
+ Or with [uv](https://docs.astral.sh/uv/):
35
+
36
+ ```bash
37
+ uv add basecamp-sdk
38
+ ```
39
+
40
+ ## Quick Start
41
+
42
+ ```python
43
+ import os
44
+ from basecamp import Client
45
+
46
+ client = Client(access_token=os.environ["BASECAMP_TOKEN"])
47
+ account = client.for_account(os.environ["BASECAMP_ACCOUNT_ID"])
48
+
49
+ projects = account.projects.list()
50
+ for project in projects:
51
+ print(f"{project['id']}: {project['name']}")
52
+ ```
53
+
54
+ ### Async
55
+
56
+ ```python
57
+ import asyncio
58
+ import os
59
+ from basecamp import AsyncClient
60
+
61
+ async def main():
62
+ async with AsyncClient(access_token=os.environ["BASECAMP_TOKEN"]) as client:
63
+ account = client.for_account(os.environ["BASECAMP_ACCOUNT_ID"])
64
+ projects = await account.projects.list()
65
+ for project in projects:
66
+ print(f"{project['id']}: {project['name']}")
67
+
68
+ asyncio.run(main())
69
+ ```
70
+
71
+ ## Configuration
72
+
73
+ ### Environment Variables
74
+
75
+ | Variable | Description | Default |
76
+ |----------|-------------|---------|
77
+ | `BASECAMP_BASE_URL` | API base URL | `https://3.basecampapi.com` |
78
+ | `BASECAMP_TIMEOUT` | Request timeout (seconds) | `30` |
79
+ | `BASECAMP_MAX_RETRIES` | Maximum retries (up to N+1 total attempts) | `3` |
80
+
81
+ ### Programmatic Configuration
82
+
83
+ ```python
84
+ from basecamp import Config
85
+
86
+ # Load from environment variables
87
+ config = Config.from_env()
88
+
89
+ # Or configure programmatically
90
+ config = Config(
91
+ base_url="https://3.basecampapi.com",
92
+ timeout=30.0,
93
+ max_retries=3,
94
+ base_delay=1.0,
95
+ max_jitter=0.1,
96
+ max_pages=10_000,
97
+ )
98
+
99
+ client = Client(access_token="...", config=config)
100
+ ```
101
+
102
+ Configuration is immutable (frozen dataclass). Create a new `Config` to change settings.
103
+
104
+ ## Authentication
105
+
106
+ ### Static Token
107
+
108
+ ```python
109
+ from basecamp import Client
110
+
111
+ client = Client(access_token="your-token")
112
+ ```
113
+
114
+ ### OAuth Token Provider
115
+
116
+ ```python
117
+ from basecamp import Client, OAuthTokenProvider
118
+
119
+ provider = OAuthTokenProvider(
120
+ access_token="...",
121
+ client_id="your-client-id",
122
+ client_secret="your-client-secret",
123
+ refresh_token="...",
124
+ expires_at=1234567890.0,
125
+ on_refresh=lambda access, refresh, expires_at: save_tokens(access, refresh, expires_at),
126
+ )
127
+
128
+ client = Client(token_provider=provider)
129
+ ```
130
+
131
+ The `OAuthTokenProvider` automatically refreshes expired tokens before each request.
132
+
133
+ ### Custom Auth Strategy
134
+
135
+ Implement the `AuthStrategy` protocol for custom authentication:
136
+
137
+ ```python
138
+ from basecamp import Client, AuthStrategy
139
+
140
+ class MyAuth:
141
+ def authenticate(self, headers: dict[str, str]) -> None:
142
+ headers["Authorization"] = "Bearer " + get_token()
143
+
144
+ client = Client(auth=MyAuth())
145
+ ```
146
+
147
+ ## OAuth 2.0
148
+
149
+ The SDK provides helpers for the full OAuth 2.0 authorization code flow with PKCE.
150
+
151
+ ### Discovery
152
+
153
+ ```python
154
+ from basecamp.oauth import discover_launchpad
155
+
156
+ config = discover_launchpad()
157
+ # config.authorization_endpoint
158
+ # config.token_endpoint
159
+ ```
160
+
161
+ ### PKCE and Authorization URL
162
+
163
+ ```python
164
+ from basecamp.oauth import generate_pkce, generate_state, build_authorization_url
165
+
166
+ pkce = generate_pkce()
167
+ state = generate_state()
168
+
169
+ url = build_authorization_url(
170
+ endpoint=config.authorization_endpoint,
171
+ client_id="your-client-id",
172
+ redirect_uri="https://yourapp.com/callback",
173
+ state=state,
174
+ pkce=pkce,
175
+ )
176
+ # Redirect user to url
177
+ ```
178
+
179
+ ### Token Exchange
180
+
181
+ ```python
182
+ from basecamp.oauth import exchange_code
183
+
184
+ token = exchange_code(
185
+ token_endpoint=config.token_endpoint,
186
+ code="authorization-code-from-callback",
187
+ redirect_uri="https://yourapp.com/callback",
188
+ client_id="your-client-id",
189
+ client_secret="your-client-secret",
190
+ code_verifier=pkce.verifier,
191
+ )
192
+ # token.access_token, token.refresh_token, token.expires_at
193
+ ```
194
+
195
+ ### Token Refresh
196
+
197
+ ```python
198
+ from basecamp.oauth import refresh_token
199
+
200
+ new_token = refresh_token(
201
+ token_endpoint=config.token_endpoint,
202
+ refresh_tok=token.refresh_token,
203
+ client_id="your-client-id",
204
+ client_secret="your-client-secret",
205
+ )
206
+ ```
207
+
208
+ ### Launchpad Legacy Format
209
+
210
+ Basecamp's Launchpad uses a non-standard token format. Pass `use_legacy_format=True` for compatibility:
211
+
212
+ ```python
213
+ token = exchange_code(
214
+ token_endpoint=config.token_endpoint,
215
+ code=code,
216
+ redirect_uri=redirect_uri,
217
+ client_id=client_id,
218
+ client_secret=client_secret,
219
+ code_verifier=pkce.verifier,
220
+ use_legacy_format=True,
221
+ )
222
+ ```
223
+
224
+ ### Token Expiry
225
+
226
+ ```python
227
+ from basecamp.oauth import OAuthToken
228
+
229
+ token = OAuthToken(access_token="...", expires_in=7200)
230
+ token.is_expired() # False
231
+ token.is_expired(buffer_seconds=60) # True if expiring within 60s
232
+ ```
233
+
234
+ ## Services
235
+
236
+ All services are accessed through an `AccountClient`, obtained via `client.for_account(account_id)`.
237
+
238
+ | Category | Service | Accessor |
239
+ |----------|---------|----------|
240
+ | **Projects** | Projects | `account.projects` |
241
+ | | Templates | `account.templates` |
242
+ | | Tools | `account.tools` |
243
+ | | People | `account.people` |
244
+ | **To-dos** | Todos | `account.todos` |
245
+ | | Todolists | `account.todolists` |
246
+ | | Todosets | `account.todosets` |
247
+ | | TodolistGroups | `account.todolist_groups` |
248
+ | | HillCharts | `account.hill_charts` |
249
+ | **Messages** | Messages | `account.messages` |
250
+ | | MessageBoards | `account.message_boards` |
251
+ | | MessageTypes | `account.message_types` |
252
+ | | Comments | `account.comments` |
253
+ | **Chat** | Campfires | `account.campfires` |
254
+ | **Scheduling** | Schedules | `account.schedules` |
255
+ | | Timeline | `account.timeline` |
256
+ | | Lineup | `account.lineup` |
257
+ | | Checkins | `account.checkins` |
258
+ | **Files** | Vaults | `account.vaults` |
259
+ | | Documents | `account.documents` |
260
+ | | Uploads | `account.uploads` |
261
+ | | Attachments | `account.attachments` |
262
+ | **Card Tables** | CardTables | `account.card_tables` |
263
+ | | Cards | `account.cards` |
264
+ | | CardColumns | `account.card_columns` |
265
+ | | CardSteps | `account.card_steps` |
266
+ | **Client Portal** | ClientApprovals | `account.client_approvals` |
267
+ | | ClientCorrespondences | `account.client_correspondences` |
268
+ | | ClientReplies | `account.client_replies` |
269
+ | | ClientVisibility | `account.client_visibility` |
270
+ | **Automation** | Webhooks | `account.webhooks` |
271
+ | | Subscriptions | `account.subscriptions` |
272
+ | | Events | `account.events` |
273
+ | | Automation | `account.automation` |
274
+ | | Boosts | `account.boosts` |
275
+ | **Reporting** | Search | `account.search` |
276
+ | | Reports | `account.reports` |
277
+ | | Timesheets | `account.timesheets` |
278
+ | | Recordings | `account.recordings` |
279
+ | **Email** | Forwards | `account.forwards` |
280
+
281
+ The `authorization` service is on the top-level `Client`:
282
+
283
+ ```python
284
+ auth = client.authorization.get()
285
+ ```
286
+
287
+ All service methods use keyword-only arguments:
288
+
289
+ ```python
290
+ # All parameters after * are keyword-only
291
+ todo = account.todos.get(todo_id=123)
292
+ project = account.projects.create(name="My Project", description="A new project")
293
+ todos = account.todos.list(todolist_id=456, status="active")
294
+ ```
295
+
296
+ ## Pagination
297
+
298
+ Paginated methods return a `ListResult`, which is a `list` subclass with a `.meta` attribute:
299
+
300
+ ```python
301
+ projects = account.projects.list()
302
+
303
+ # ListResult is a list - iterate directly
304
+ for project in projects:
305
+ print(project["name"])
306
+
307
+ # Access pagination metadata
308
+ print(projects.meta.total_count) # total items across all pages
309
+ print(projects.meta.truncated) # True if max_pages was reached
310
+
311
+ # Standard list operations work
312
+ print(len(projects))
313
+ first = projects[0]
314
+ sliced = projects[:5]
315
+ ```
316
+
317
+ Pagination is automatic. The SDK follows Link headers and collects all pages up to `config.max_pages` (default: 10,000).
318
+
319
+ ## Error Handling
320
+
321
+ ```python
322
+ from basecamp import Client, NotFoundError, RateLimitError, AuthError, BasecampError
323
+
324
+ client = Client(access_token="...")
325
+ account = client.for_account("12345")
326
+
327
+ try:
328
+ project = account.projects.get(project_id=999)
329
+ except NotFoundError as e:
330
+ print(f"Not found: {e}")
331
+ print(f"HTTP status: {e.http_status}")
332
+ print(f"Request ID: {e.request_id}")
333
+ except RateLimitError as e:
334
+ print(f"Rate limited, retry after: {e.retry_after}s")
335
+ except AuthError as e:
336
+ print(f"Authentication failed: {e.hint}")
337
+ except BasecampError as e:
338
+ print(f"API error [{e.code}]: {e}")
339
+ ```
340
+
341
+ ### Error Hierarchy
342
+
343
+ All exceptions inherit from `BasecampError`:
344
+
345
+ | Exception | `ErrorCode` value | HTTP Status | Retryable |
346
+ |-----------|-------------------|-------------|-----------|
347
+ | `UsageError` | `usage` | - | No |
348
+ | `NotFoundError` | `not_found` | 404 | No |
349
+ | `AuthError` | `auth_required` | 401 | No |
350
+ | `ForbiddenError` | `forbidden` | 403 | No |
351
+ | `RateLimitError` | `rate_limit` | 429 | Yes |
352
+ | `NetworkError` | `network` | - | Yes |
353
+ | `ApiError` | `api_error` | 5xx, other | Yes for 500/502/503/504; No otherwise |
354
+ | `AmbiguousError` | `ambiguous` | - | No |
355
+ | `ValidationError` | `validation` | 400, 422 | No |
356
+
357
+ Every `BasecampError` provides:
358
+ - `code` - `ErrorCode` enum value
359
+ - `hint` - Human-readable suggestion
360
+ - `http_status` - HTTP status code (if applicable)
361
+ - `retryable` - Whether the error is safe to retry
362
+ - `retry_after` - Seconds to wait before retry (for rate limits)
363
+ - `request_id` - Server request ID (if available)
364
+ - `exit_code` - CLI-friendly exit code (`ExitCode` enum)
365
+
366
+ ## Retry Behavior
367
+
368
+ The SDK automatically retries failed requests with exponential backoff:
369
+
370
+ - **GET requests** - Retried on `RateLimitError` (429), `NetworkError`, and retryable `ApiError` (500, 502, 503, 504)
371
+ - **Idempotent mutations** - Operations marked idempotent in the OpenAPI metadata also retry through the same path
372
+ - **Non-idempotent mutations** - NOT retried to prevent duplicate operations
373
+ - **401 responses** - Token refresh attempted, then single retry for all methods (regardless of idempotency)
374
+ - **Backoff** - Exponential with jitter (`base_delay * 2^(attempt-1) + random() * max_jitter`)
375
+ - **Retry-After** - Respected for 429 responses (overrides calculated backoff)
376
+ - **Max retries** - Controlled by `config.max_retries` (default: 3 retries, up to 4 total attempts including the initial request)
377
+
378
+ ## Observability
379
+
380
+ ### Console Hooks
381
+
382
+ ```python
383
+ from basecamp import Client
384
+ from basecamp.hooks import console_hooks
385
+
386
+ client = Client(access_token="...", hooks=console_hooks())
387
+ # Logs all operations and requests to stderr
388
+ ```
389
+
390
+ ### Custom Hooks
391
+
392
+ Subclass `BasecampHooks` and override the methods you need:
393
+
394
+ ```python
395
+ from basecamp import Client
396
+ from basecamp.hooks import BasecampHooks, OperationInfo, OperationResult, RequestInfo, RequestResult
397
+
398
+ class MyHooks(BasecampHooks):
399
+ def on_operation_start(self, info: OperationInfo):
400
+ print(f"-> {info.service}.{info.operation}")
401
+
402
+ def on_operation_end(self, info: OperationInfo, result: OperationResult):
403
+ status = "ok" if result.error is None else "error"
404
+ print(f"<- {info.service}.{info.operation} {status} ({result.duration_ms}ms)")
405
+
406
+ def on_request_start(self, info: RequestInfo):
407
+ print(f" {info.method} {info.url} (attempt {info.attempt})")
408
+
409
+ def on_request_end(self, info: RequestInfo, result: RequestResult):
410
+ print(f" {result.status_code} ({result.duration:.3f}s)")
411
+
412
+ def on_retry(self, info: RequestInfo, attempt: int, error: BaseException, delay: float):
413
+ print(f" retry {attempt} in {delay:.1f}s: {error}")
414
+
415
+ def on_paginate(self, url: str, page: int):
416
+ print(f" page {page}: {url}")
417
+
418
+ client = Client(access_token="...", hooks=MyHooks())
419
+ ```
420
+
421
+ ### Chaining Hooks
422
+
423
+ ```python
424
+ from basecamp.hooks import chain_hooks, console_hooks
425
+
426
+ combined = chain_hooks(console_hooks(), MyHooks())
427
+ client = Client(access_token="...", hooks=combined)
428
+ ```
429
+
430
+ `chain_hooks` composes multiple hooks. `on_end` callbacks fire in reverse order (LIFO).
431
+
432
+ ### Hook Safety
433
+
434
+ Hook exceptions are caught and logged to stderr. A failing hook never interrupts SDK operations.
435
+
436
+ ## Webhooks
437
+
438
+ ### Receiver
439
+
440
+ ```python
441
+ from basecamp.webhooks import WebhookReceiver
442
+
443
+ receiver = WebhookReceiver(secret="your-webhook-secret")
444
+
445
+ def handle_todos(event):
446
+ print(f"Todo event: {event['kind']}")
447
+
448
+ def handle_message(event):
449
+ print(f"New message: {event['recording']['title']}")
450
+
451
+ def handle_all(event):
452
+ print(f"Event: {event['kind']}")
453
+
454
+ receiver.on("todo_*", handle_todos)
455
+ receiver.on("message_created", handle_message)
456
+ receiver.on_any(handle_all)
457
+
458
+ # In your web framework handler:
459
+ result = receiver.handle_request(
460
+ raw_body=request.body,
461
+ headers=dict(request.headers),
462
+ )
463
+ ```
464
+
465
+ ### Signature Verification
466
+
467
+ ```python
468
+ from basecamp.webhooks import verify_signature, compute_signature
469
+
470
+ # Verify a webhook signature (returns bool)
471
+ if not verify_signature(
472
+ request.body,
473
+ "your-webhook-secret",
474
+ request.headers["X-Basecamp-Signature"],
475
+ ):
476
+ raise ValueError("Invalid webhook signature")
477
+
478
+ # Compute a signature
479
+ sig = compute_signature(request.body, "your-webhook-secret")
480
+ ```
481
+
482
+ ### Middleware
483
+
484
+ ```python
485
+ def log_events(event, next_fn):
486
+ print(f"Processing: {event['kind']}")
487
+ return next_fn()
488
+
489
+ receiver.use(log_events)
490
+ ```
491
+
492
+ ### Deduplication
493
+
494
+ The receiver automatically deduplicates events by `event["id"]` using an LRU window (default: 1,000 events). Configure with `dedup_window_size`:
495
+
496
+ ```python
497
+ receiver = WebhookReceiver(secret="...", dedup_window_size=5000)
498
+ ```
499
+
500
+ ## Async Support
501
+
502
+ Every service method has a sync and async variant. The async client mirrors the sync API:
503
+
504
+ ```python
505
+ from basecamp import AsyncClient
506
+
507
+ async with AsyncClient(access_token="...") as client:
508
+ account = client.for_account("12345")
509
+
510
+ # All service methods are awaitable
511
+ projects = await account.projects.list()
512
+ todo = await account.todos.get(todo_id=123)
513
+
514
+ # Downloads are async too
515
+ result = await account.download_url("https://...")
516
+ ```
517
+
518
+ Use `AsyncClient` with `async with` for automatic cleanup, or call `await client.close()` manually.
519
+
520
+ ## Downloads
521
+
522
+ Download files from Basecamp with authentication and redirect handling:
523
+
524
+ ```python
525
+ # Sync
526
+ result = account.download_url("https://3.basecampapi.com/.../download/file.pdf")
527
+ print(result.filename) # "file.pdf"
528
+ print(result.content_type) # "application/pdf"
529
+ print(result.content_length) # 12345
530
+ with open(result.filename, "wb") as f:
531
+ f.write(result.body)
532
+
533
+ # Async
534
+ result = await account.download_url("https://...")
535
+ ```
536
+
537
+ Downloads resolve signed URLs with an authenticated request, then fetch file content via a second unauthenticated request so credentials are never sent to the signed URL.
538
+
539
+ ## Development
540
+
541
+ ```bash
542
+ # Install dependencies (from repo root)
543
+ cd python && uv sync && cd ..
544
+
545
+ # Run all checks (tests, types, lint, format, drift)
546
+ make py-check
547
+
548
+ # Run tests only
549
+ make py-test
550
+
551
+ # Type checking
552
+ make py-typecheck
553
+
554
+ # Regenerate services from OpenAPI spec
555
+ make py-generate
556
+
557
+ # Check for service drift
558
+ make py-check-drift
559
+ ```
560
+
561
+ ## License
562
+
563
+ MIT
@@ -0,0 +1,79 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "basecamp-sdk"
7
+ version = "0.7.0"
8
+ description = "Official Python SDK for the Basecamp API"
9
+ requires-python = ">=3.11"
10
+ license = "MIT"
11
+ authors = [{ name = "37signals" }]
12
+ keywords = ["basecamp", "api", "sdk", "37signals"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Typing :: Typed",
16
+ ]
17
+ dependencies = ["httpx>=0.27,<1"]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/basecamp/basecamp-sdk"
21
+ Repository = "https://github.com/basecamp/basecamp-sdk"
22
+ Documentation = "https://github.com/basecamp/basecamp-sdk/tree/main/python"
23
+
24
+ [dependency-groups]
25
+ dev = [
26
+ "pytest>=8",
27
+ "pytest-asyncio>=0.24",
28
+ "pytest-cov>=6",
29
+ "respx>=0.22",
30
+ "ruff>=0.4",
31
+ "mypy>=1.10",
32
+ ]
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/basecamp"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
39
+ pythonpath = ["src"]
40
+
41
+ [tool.ruff]
42
+ target-version = "py311"
43
+ line-length = 120
44
+
45
+ [tool.ruff.format]
46
+ quote-style = "double"
47
+ docstring-code-format = true
48
+
49
+ [tool.ruff.lint]
50
+ select = ["E", "F", "I", "UP", "B", "SIM"]
51
+
52
+ [tool.ruff.lint.per-file-ignores]
53
+ "src/basecamp/generated/**" = ["E501", "I001", "F401"]
54
+ "src/basecamp/generated/types.py" = ["E501", "UP", "I001", "F401"]
55
+
56
+ [tool.coverage.run]
57
+ source = ["basecamp"]
58
+ omit = ["*/generated/*"]
59
+
60
+ [tool.coverage.report]
61
+ fail_under = 60
62
+ show_missing = true
63
+ exclude_lines = [
64
+ "pragma: no cover",
65
+ "if TYPE_CHECKING:",
66
+ "^\\s*pass\\s*$",
67
+ ]
68
+
69
+ [tool.mypy]
70
+ python_version = "3.11"
71
+ warn_return_any = false
72
+ warn_unused_configs = true
73
+ disallow_untyped_defs = false
74
+ check_untyped_defs = true
75
+ exclude = ["generated/"]
76
+
77
+ [[tool.mypy.overrides]]
78
+ module = "basecamp.generated.*"
79
+ ignore_errors = true