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.
- basecamp_sdk-0.7.0/.gitignore +51 -0
- basecamp_sdk-0.7.0/PKG-INFO +14 -0
- basecamp_sdk-0.7.0/README.md +563 -0
- basecamp_sdk-0.7.0/pyproject.toml +79 -0
- basecamp_sdk-0.7.0/scripts/generate_metadata.py +68 -0
- basecamp_sdk-0.7.0/scripts/generate_services.py +797 -0
- basecamp_sdk-0.7.0/scripts/generate_types.py +115 -0
- basecamp_sdk-0.7.0/src/basecamp/__init__.py +67 -0
- basecamp_sdk-0.7.0/src/basecamp/_async_http.py +261 -0
- basecamp_sdk-0.7.0/src/basecamp/_http.py +260 -0
- basecamp_sdk-0.7.0/src/basecamp/_pagination.py +48 -0
- basecamp_sdk-0.7.0/src/basecamp/_security.py +89 -0
- basecamp_sdk-0.7.0/src/basecamp/_version.py +2 -0
- basecamp_sdk-0.7.0/src/basecamp/async_auth.py +142 -0
- basecamp_sdk-0.7.0/src/basecamp/async_client.py +392 -0
- basecamp_sdk-0.7.0/src/basecamp/auth.py +138 -0
- basecamp_sdk-0.7.0/src/basecamp/client.py +393 -0
- basecamp_sdk-0.7.0/src/basecamp/config.py +54 -0
- basecamp_sdk-0.7.0/src/basecamp/download.py +140 -0
- basecamp_sdk-0.7.0/src/basecamp/errors.py +191 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/__init__.py +0 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/metadata.json +2300 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/__init__.py +140 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/_async_base.py +294 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/_base.py +297 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/account.py +74 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/attachments.py +34 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/automation.py +28 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/boosts.py +112 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/campfires.py +232 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/card_columns.py +178 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/card_steps.py +108 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/card_tables.py +28 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/cards.py +126 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/checkins.py +284 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/client_approvals.py +42 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/client_correspondences.py +46 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/client_replies.py +40 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/client_visibility.py +36 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/comments.py +76 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/documents.py +94 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/events.py +26 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/forwards.py +100 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/gauges.py +128 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/hill_charts.py +50 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/lineup.py +66 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/message_boards.py +28 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/message_types.py +90 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/messages.py +148 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/my_assignments.py +58 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/my_notifications.py +48 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/people.py +254 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/projects.py +114 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/recordings.py +106 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/reports.py +86 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/schedules.py +204 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/search.py +38 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/subscriptions.py +82 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/templates.py +136 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/timeline.py +30 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/timesheets.py +152 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/todolist_groups.py +62 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/todolists.py +78 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/todos.py +222 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/todosets.py +28 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/tools.py +130 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/uploads.py +118 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/vaults.py +76 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/services/webhooks_service.py +110 -0
- basecamp_sdk-0.7.0/src/basecamp/generated/types.py +1755 -0
- basecamp_sdk-0.7.0/src/basecamp/hooks.py +135 -0
- basecamp_sdk-0.7.0/src/basecamp/oauth/__init__.py +24 -0
- basecamp_sdk-0.7.0/src/basecamp/oauth/authorize.py +49 -0
- basecamp_sdk-0.7.0/src/basecamp/oauth/config.py +14 -0
- basecamp_sdk-0.7.0/src/basecamp/oauth/discovery.py +77 -0
- basecamp_sdk-0.7.0/src/basecamp/oauth/errors.py +22 -0
- basecamp_sdk-0.7.0/src/basecamp/oauth/exchange.py +162 -0
- basecamp_sdk-0.7.0/src/basecamp/oauth/pkce.py +39 -0
- basecamp_sdk-0.7.0/src/basecamp/oauth/token.py +27 -0
- basecamp_sdk-0.7.0/src/basecamp/py.typed +0 -0
- basecamp_sdk-0.7.0/src/basecamp/services/__init__.py +3 -0
- basecamp_sdk-0.7.0/src/basecamp/services/authorization.py +27 -0
- basecamp_sdk-0.7.0/src/basecamp/webhooks/__init__.py +15 -0
- basecamp_sdk-0.7.0/src/basecamp/webhooks/errors.py +8 -0
- basecamp_sdk-0.7.0/src/basecamp/webhooks/events.py +52 -0
- basecamp_sdk-0.7.0/src/basecamp/webhooks/receiver.py +165 -0
- basecamp_sdk-0.7.0/src/basecamp/webhooks/verify.py +22 -0
- basecamp_sdk-0.7.0/tests/__init__.py +0 -0
- basecamp_sdk-0.7.0/tests/conftest.py +45 -0
- basecamp_sdk-0.7.0/tests/oauth/__init__.py +0 -0
- basecamp_sdk-0.7.0/tests/oauth/test_authorize.py +64 -0
- basecamp_sdk-0.7.0/tests/oauth/test_discovery.py +82 -0
- basecamp_sdk-0.7.0/tests/oauth/test_exchange.py +123 -0
- basecamp_sdk-0.7.0/tests/oauth/test_pkce.py +42 -0
- basecamp_sdk-0.7.0/tests/services/__init__.py +0 -0
- basecamp_sdk-0.7.0/tests/test_auth.py +175 -0
- basecamp_sdk-0.7.0/tests/test_client.py +74 -0
- basecamp_sdk-0.7.0/tests/test_config.py +91 -0
- basecamp_sdk-0.7.0/tests/test_download.py +109 -0
- basecamp_sdk-0.7.0/tests/test_errors.py +164 -0
- basecamp_sdk-0.7.0/tests/test_hooks.py +92 -0
- basecamp_sdk-0.7.0/tests/test_http.py +183 -0
- basecamp_sdk-0.7.0/tests/test_pagination.py +77 -0
- basecamp_sdk-0.7.0/tests/test_security.py +131 -0
- basecamp_sdk-0.7.0/tests/webhooks/__init__.py +0 -0
- basecamp_sdk-0.7.0/tests/webhooks/test_events.py +23 -0
- basecamp_sdk-0.7.0/tests/webhooks/test_receiver.py +130 -0
- basecamp_sdk-0.7.0/tests/webhooks/test_verify.py +40 -0
- 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
|
+
[](https://pypi.org/project/basecamp-sdk/)
|
|
4
|
+
[](https://github.com/basecamp/basecamp-sdk/actions/workflows/test.yml)
|
|
5
|
+
[](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
|