sweatstack 0.68.0__tar.gz → 0.70.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 (61) hide show
  1. {sweatstack-0.68.0 → sweatstack-0.70.0}/.claude/settings.local.json +2 -1
  2. sweatstack-0.70.0/.claude/skills/sweatstack-python/SKILL.md +49 -0
  3. sweatstack-0.70.0/.claude/skills/sweatstack-python/client.md +259 -0
  4. sweatstack-0.70.0/.claude/skills/sweatstack-python/data-models.md +68 -0
  5. sweatstack-0.70.0/.claude/skills/sweatstack-python/fastapi.md +209 -0
  6. sweatstack-0.70.0/.claude/skills/sweatstack-python/streamlit.md +116 -0
  7. {sweatstack-0.68.0 → sweatstack-0.70.0}/.gitignore +5 -1
  8. {sweatstack-0.68.0 → sweatstack-0.70.0}/CHANGELOG.md +16 -0
  9. sweatstack-0.70.0/CLIENT_LIBRARY_SKILL.md +156 -0
  10. {sweatstack-0.68.0 → sweatstack-0.70.0}/PKG-INFO +1 -1
  11. {sweatstack-0.68.0 → sweatstack-0.70.0}/pyproject.toml +1 -1
  12. {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/client.py +64 -34
  13. {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/fastapi/config.py +9 -2
  14. {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/fastapi/routes.py +12 -4
  15. {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/streamlit.py +6 -2
  16. {sweatstack-0.68.0 → sweatstack-0.70.0}/.python-version +0 -0
  17. {sweatstack-0.68.0 → sweatstack-0.70.0}/CLIENT_DTYPE_CONVERSION.md +0 -0
  18. {sweatstack-0.68.0 → sweatstack-0.70.0}/DEVELOPMENT.md +0 -0
  19. {sweatstack-0.68.0 → sweatstack-0.70.0}/FASTAPI_DOCS.md +0 -0
  20. {sweatstack-0.68.0 → sweatstack-0.70.0}/FASTAPI_PLUGIN.md +0 -0
  21. {sweatstack-0.68.0 → sweatstack-0.70.0}/FASTAPI_USER_SWITCHING.md +0 -0
  22. {sweatstack-0.68.0 → sweatstack-0.70.0}/FASTAPI_WEBHOOKS.md +0 -0
  23. {sweatstack-0.68.0 → sweatstack-0.70.0}/LOCAL_AUTH.md +0 -0
  24. {sweatstack-0.68.0 → sweatstack-0.70.0}/Makefile +0 -0
  25. {sweatstack-0.68.0 → sweatstack-0.70.0}/README.md +0 -0
  26. {sweatstack-0.68.0 → sweatstack-0.70.0}/docs/conf.py +0 -0
  27. {sweatstack-0.68.0 → sweatstack-0.70.0}/docs/everything.rst +0 -0
  28. {sweatstack-0.68.0 → sweatstack-0.70.0}/docs/index.rst +0 -0
  29. {sweatstack-0.68.0 → sweatstack-0.70.0}/examples/fastapi_webhooks_example.py +0 -0
  30. {sweatstack-0.68.0 → sweatstack-0.70.0}/examples/send_webhook.py +0 -0
  31. {sweatstack-0.68.0 → sweatstack-0.70.0}/examples/tokens.db +0 -0
  32. {sweatstack-0.68.0 → sweatstack-0.70.0}/fastapi_coaching_example.py +0 -0
  33. {sweatstack-0.68.0 → sweatstack-0.70.0}/fastapi_example.py +0 -0
  34. {sweatstack-0.68.0 → sweatstack-0.70.0}/fastapi_sweatstack.py +0 -0
  35. {sweatstack-0.68.0 → sweatstack-0.70.0}/playground/.ipynb_checkpoints/Untitled-checkpoint.ipynb +0 -0
  36. {sweatstack-0.68.0 → sweatstack-0.70.0}/playground/README.md +0 -0
  37. {sweatstack-0.68.0 → sweatstack-0.70.0}/playground/Sweat Stack examples/Getting started.ipynb +0 -0
  38. {sweatstack-0.68.0 → sweatstack-0.70.0}/playground/Untitled.ipynb +0 -0
  39. {sweatstack-0.68.0 → sweatstack-0.70.0}/playground/hello.py +0 -0
  40. {sweatstack-0.68.0 → sweatstack-0.70.0}/playground/pyproject.toml +0 -0
  41. {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
  42. {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/__init__.py +0 -0
  43. {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/cli.py +0 -0
  44. {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/constants.py +0 -0
  45. {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/fastapi/__init__.py +0 -0
  46. {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/fastapi/dependencies.py +0 -0
  47. {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/fastapi/models.py +0 -0
  48. {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/fastapi/session.py +0 -0
  49. {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/fastapi/token_stores.py +0 -0
  50. {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/fastapi/webhooks.py +0 -0
  51. {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/ipython_init.py +0 -0
  52. {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
  53. {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/openapi_schemas.py +0 -0
  54. {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/py.typed +0 -0
  55. {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/schemas.py +0 -0
  56. {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/sweatshell.py +0 -0
  57. {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/utils.py +0 -0
  58. {sweatstack-0.68.0 → sweatstack-0.70.0}/tests/__init__.py +0 -0
  59. {sweatstack-0.68.0 → sweatstack-0.70.0}/tests/test_dtype_conversion.py +0 -0
  60. {sweatstack-0.68.0 → sweatstack-0.70.0}/tests/test_webhooks.py +0 -0
  61. {sweatstack-0.68.0 → sweatstack-0.70.0}/uv.lock +0 -0
@@ -13,7 +13,8 @@
13
13
  "WebFetch(domain:developer.sweatstack.no)",
14
14
  "Bash(uv run ruff:*)",
15
15
  "Bash(chmod:*)",
16
- "mcp__acp__Bash"
16
+ "mcp__acp__Bash",
17
+ "Bash(wc:*)"
17
18
  ],
18
19
  "deny": []
19
20
  }
@@ -0,0 +1,49 @@
1
+ ---
2
+ name: sweatstack-python
3
+ description: >
4
+ Builds Python applications using the SweatStack client library (pip install sweatstack).
5
+ Covers authentication, activity and trace data retrieval, pandas DataFrames, Streamlit
6
+ dashboards, FastAPI backends, user delegation, teams, and file uploads. Use when writing
7
+ Python scripts, notebooks, Streamlit apps, or FastAPI services that access SweatStack
8
+ sports data — even if the user just says "Python" and "SweatStack" without naming the
9
+ library explicitly.
10
+ ---
11
+
12
+ # SweatStack Python Client
13
+
14
+ Python client library for the SweatStack sports data platform.
15
+
16
+ **Install:** `pip install sweatstack`
17
+
18
+ **Extras:** `pip install sweatstack[streamlit]` · `pip install sweatstack[fastapi]`
19
+
20
+ ## Quick Start
21
+
22
+ ```python
23
+ import sweatstack
24
+
25
+ sweatstack.authenticate() # Opens browser, stores tokens locally
26
+ activities = sweatstack.get_activities(limit=5)
27
+ for a in activities:
28
+ print(f"{a.start_local:%Y-%m-%d} {a.sport.display_name()} {a.duration}")
29
+ ```
30
+
31
+ Or with an explicit client:
32
+
33
+ ```python
34
+ from sweatstack import Client
35
+
36
+ client = Client()
37
+ client.authenticate()
38
+ df = client.get_activities(as_dataframe=True)
39
+ ```
40
+
41
+ ## Reference
42
+
43
+ **Client API** — methods for activities, traces, longitudinal data, users, uploads. Read [client.md](client.md)
44
+
45
+ **Streamlit** — StreamlitAuth, selector components, behind-proxy mode. Read [streamlit.md](streamlit.md)
46
+
47
+ **FastAPI** — configure/instrument, dependency injection, webhooks, token stores. Read [fastapi.md](fastapi.md)
48
+
49
+ **Data Models** — Sport, Metric, Scope enums and response model fields. Read [data-models.md](data-models.md)
@@ -0,0 +1,259 @@
1
+ # Client API
2
+
3
+ ## Contents
4
+
5
+ - [Authentication](#authentication)
6
+ - [Activities](#activities)
7
+ - [Time-Series Data](#time-series-data)
8
+ - [Mean-Max and AWD](#mean-max-and-awd)
9
+ - [Longitudinal Data](#longitudinal-data)
10
+ - [Traces](#traces)
11
+ - [Profile](#profile)
12
+ - [Users and Teams](#users-and-teams)
13
+ - [User Delegation](#user-delegation)
14
+ - [File Uploads](#file-uploads)
15
+ - [Singleton vs Instance](#singleton-vs-instance)
16
+ - [Gotchas](#gotchas)
17
+
18
+ ---
19
+
20
+ ## Authentication
21
+
22
+ Three modes — pick one:
23
+
24
+ **Interactive (scripts, notebooks):**
25
+ ```python
26
+ import sweatstack
27
+ sweatstack.authenticate() # Opens browser, persists tokens to disk
28
+ ```
29
+ Tokens stored at `~/.local/share/SweatStack/SweatStack/tokens.json`. Subsequent runs reuse stored tokens automatically. Pass `force=True` to re-authenticate.
30
+
31
+ **Environment variables (CI, containers):**
32
+ ```
33
+ SWEATSTACK_API_KEY=<access_token>
34
+ SWEATSTACK_REFRESH_TOKEN=<refresh_token>
35
+ ```
36
+ No `authenticate()` call needed — the client picks up env vars automatically.
37
+
38
+ **Direct token (advanced):**
39
+ ```python
40
+ client = Client(api_key="...", refresh_token="...")
41
+ ```
42
+
43
+ Token refresh is automatic in all modes. The library handles expiry checks and refreshes transparently.
44
+
45
+ ## Activities
46
+
47
+ ```python
48
+ # List activities (returns list[ActivitySummary])
49
+ activities = client.get_activities(
50
+ start=date(2025, 1, 1), # optional
51
+ end=date(2025, 12, 31), # optional
52
+ sports=[Sport.cycling_road], # optional, list of Sport enum or strings
53
+ tags=["race"], # optional
54
+ limit=100, # default 100
55
+ offset=0, # for pagination
56
+ )
57
+
58
+ # As DataFrame instead
59
+ df = client.get_activities(as_dataframe=True)
60
+
61
+ # Single activity by ID (returns ActivityDetails)
62
+ activity = client.get_activity("activity_id")
63
+
64
+ # Most recent activity (returns ActivityDetails)
65
+ latest = client.get_latest_activity()
66
+ latest = client.get_latest_activity(sport=Sport.running)
67
+ ```
68
+
69
+ ## Time-Series Data
70
+
71
+ Returns pandas DataFrame with 1-second sampled data.
72
+
73
+ ```python
74
+ # All available metrics
75
+ df = client.get_activity_data("activity_id")
76
+
77
+ # Specific metrics only
78
+ df = client.get_activity_data("activity_id", metrics=[Metric.power, Metric.heart_rate])
79
+
80
+ # Latest activity shortcut
81
+ df = client.get_latest_activity_data(sport=Sport.cycling)
82
+ ```
83
+
84
+ Columns match requested metrics. The `duration` column is included by default.
85
+
86
+ ## Mean-Max and AWD
87
+
88
+ ```python
89
+ # Mean-max curve (max average power/speed at each duration)
90
+ df = client.get_activity_mean_max("activity_id", metric="power")
91
+
92
+ # Accumulated Work Duration (time spent at each intensity level)
93
+ df = client.get_activity_awd("activity_id", metric="power")
94
+
95
+ # Latest activity shortcuts
96
+ df = client.get_latest_activity_mean_max(metric="power", sport=Sport.cycling)
97
+ ```
98
+
99
+ ## Longitudinal Data
100
+
101
+ Aggregated time-series across multiple activities. One request instead of looping.
102
+
103
+ ```python
104
+ df = client.get_longitudinal_data(
105
+ sport=Sport.cycling, # single sport
106
+ start=date(2025, 1, 1), # required
107
+ end=date(2025, 12, 31), # optional (defaults to today)
108
+ metrics=[Metric.power, Metric.heart_rate], # optional
109
+ )
110
+
111
+ # Or filter by multiple sports
112
+ df = client.get_longitudinal_data(
113
+ sports=[Sport.cycling_road, Sport.cycling_gravel],
114
+ start=date(2025, 1, 1),
115
+ )
116
+
117
+ # Longitudinal mean-max (best efforts across time range)
118
+ df = client.get_longitudinal_mean_max(sport=Sport.cycling, start=date(2025, 1, 1))
119
+
120
+ # Longitudinal AWD
121
+ df = client.get_longitudinal_awd(sport=Sport.cycling, start=date(2025, 1, 1))
122
+ ```
123
+
124
+ The DataFrame has a timezone-aware datetime index and includes an `activity_id` column — group by it for per-activity aggregation.
125
+
126
+ **Local caching** for reproducible analysis (avoids re-fetching on reruns):
127
+ ```python
128
+ import os
129
+ os.environ["SWEATSTACK_LOCAL_CACHE"] = "true"
130
+ # Use fixed end dates (not "today") to get stable cache hits
131
+ df = client.get_longitudinal_data(sport=Sport.cycling, start=date(2025, 1, 1), end=date(2025, 3, 31))
132
+ ```
133
+
134
+ ## Traces
135
+
136
+ Custom data points with measurements (e.g., lactate tests, RPE entries).
137
+
138
+ ```python
139
+ # List traces
140
+ traces = client.get_traces(start=date(2025, 1, 1), as_dataframe=True)
141
+
142
+ # Create a trace
143
+ trace = client.create_trace(
144
+ timestamp=datetime(2025, 6, 1, 10, 0),
145
+ lactate=2.5,
146
+ rpe=7,
147
+ heart_rate=155,
148
+ sport=Sport.cycling,
149
+ tags=["test"],
150
+ notes="Lactate threshold test",
151
+ )
152
+ ```
153
+
154
+ ## Profile
155
+
156
+ ```python
157
+ sports = client.get_sports() # list[Sport] — sports with data
158
+ root_sports = client.get_sports(only_root=True) # top-level only
159
+ tags = client.get_tags() # list[str]
160
+ user = client.get_userinfo() # UserInfoResponse (sub, name, email)
161
+ who = client.whoami() # UserSummary (from JWT, no API call)
162
+ ```
163
+
164
+ ## Users and Teams
165
+
166
+ ```python
167
+ # List accessible users
168
+ users = client.get_users()
169
+
170
+ # Find a specific user (by ID or name)
171
+ user = client.get_user("john") # auto-detects ID vs name
172
+ user = client.get_user("abc123", search_mode="id")
173
+
174
+ # Create a managed user (no login credentials)
175
+ user = client.create_user(first_name="John", last_name="Doe")
176
+
177
+ # Team management
178
+ team_users = client.get_team_users(team_id="team_abc")
179
+ athlete = client.get_team_user(team_id="team_abc", user="john") # by name or ID
180
+ client.authorize_team(team_id="team_abc", scopes=[Scope.data_read])
181
+ ```
182
+
183
+ ## User Delegation
184
+
185
+ Operate on behalf of another user (requires appropriate permissions).
186
+
187
+ **Prefer `delegated_client()`** — creates a separate client, keeping the scope explicit:
188
+
189
+ ```python
190
+ other = client.delegated_client("john")
191
+ other_activities = other.get_activities()
192
+ # original client is unchanged
193
+
194
+ # Via team membership
195
+ athlete = client.get_team_user(team_id="team_abc", user="john")
196
+ other = client.delegated_client(athlete, team_id="team_abc")
197
+ ```
198
+
199
+ `switch_user()` modifies the client in-place — useful in interactive/notebook contexts but avoid in scripts:
200
+
201
+ ```python
202
+ client.switch_user("john") # mutates client
203
+ client.switch_user("john", team_id="team_abc")
204
+ client.switch_back() # revert to principal
205
+ ```
206
+
207
+ ## File Uploads
208
+
209
+ ```python
210
+ # Upload FIT files (sport detected automatically)
211
+ client.upload("activity.fit")
212
+ client.upload(["file1.fit", "file2.fit"])
213
+
214
+ # Upload CSV (sport required)
215
+ client.upload("data.csv", sport=Sport.cycling_road)
216
+ ```
217
+
218
+ CSV files must contain a `timestamp` column with ISO 8601 datetimes.
219
+
220
+ ## Singleton vs Instance
221
+
222
+ **Singleton** (module-level functions) — uses a shared default `Client()`:
223
+ ```python
224
+ import sweatstack
225
+ sweatstack.authenticate()
226
+ sweatstack.get_activities()
227
+ ```
228
+
229
+ **Instance** — create your own client when you need multiple clients, custom URLs, or explicit control:
230
+ ```python
231
+ from sweatstack import Client
232
+ client = Client(api_key="...", url="https://custom.sweatstack.no")
233
+ ```
234
+
235
+ Use singleton for scripts and notebooks. Use instances for servers, multi-user apps, or tests.
236
+
237
+ ## One-Off Scripts with `uv run`
238
+
239
+ For standalone analysis scripts, use PEP 723 inline metadata so `uv run script.py` handles dependencies automatically:
240
+
241
+ ```python
242
+ # /// script
243
+ # requires-python = ">=3.12"
244
+ # dependencies = ["sweatstack", "matplotlib"]
245
+ # ///
246
+ import sweatstack
247
+ sweatstack.authenticate()
248
+ df = sweatstack.get_latest_activity_data()
249
+ ```
250
+
251
+ ## Gotchas
252
+
253
+ - **Sport enum uses underscores:** `Sport.cycling_road`, not `Sport("road")` or `Sport.cycling.road`. String values use dots: `"cycling.road"`.
254
+ - **`start` is required for longitudinal endpoints.** Unlike `get_activities()` where all filters are optional.
255
+ - **`sport` (singular) vs `sports` (list):** `get_latest_activity(sport=...)` takes one. `get_activities(sports=[...])` and `get_longitudinal_data(sports=[...])` take a list.
256
+ - **DataFrames have standard dtypes.** The library converts API-optimized types (Int16, float16) to float64/datetime64[ns] automatically.
257
+ - **`as_dataframe=True`** is available on `get_activities()` and `get_traces()`. Time-series methods (`get_activity_data`, `get_longitudinal_data`, etc.) always return DataFrames.
258
+ - **`summary` fields are optional.** Always null-check: `activity.summary.power.mean if activity.summary and activity.summary.power else None`.
259
+ - **`metrics` on ActivitySummary** lists available data streams, not the data itself. Use to check availability before calling `get_activity_data()`.
@@ -0,0 +1,68 @@
1
+ # Data Models
2
+
3
+ All models are importable from `sweatstack.schemas`.
4
+
5
+ ## Sport Enum
6
+
7
+ Hierarchical values — root sports have sub-sports:
8
+
9
+ | Root | Sub-sports |
10
+ |------|-----------|
11
+ | `cycling` | `road`, `tt`, `cyclocross`, `gravel`, `mountainbike`, `track`, `trainer` |
12
+ | `running` | `road`, `track`, `trail`, `treadmill` |
13
+ | `swimming` | `pool`, `pool.25m`, `pool.50m`, `open_water`, `flume` |
14
+ | `cross_country_skiing` | `classic`, `skating` |
15
+ | `rowing` | _(none)_ |
16
+ | `walking` | `hiking` |
17
+ | `generic` | _(none)_ |
18
+
19
+ **Usage:** `Sport.cycling_road` (underscore, not dot). String values use dots: `"cycling.road"`.
20
+
21
+ **Utility methods:**
22
+ - `sport.display_name()` → `"cycling (road)"`
23
+ - `sport.root_sport()` → `Sport.cycling`
24
+ - `sport.is_root_sport()` → `True`/`False`
25
+ - `sport.is_sub_sport_of(Sport.cycling)` → `True`/`False`
26
+
27
+ **Unknown sports:** The enum handles unknown values from newer API versions gracefully — no crashes on new sports.
28
+
29
+ ## Metric Enum
30
+
31
+ Available data stream names: `duration`, `power`, `speed`, `heart_rate`, `cadence`, `altitude`, `elevation`, `temperature`, `core_temperature`, `smo2`, `distance`, `latitude`, `longitude`, `lactate`, `rpe`, `respiration_rate`, `notes`
32
+
33
+ `metric.display_name()` → human-readable form.
34
+
35
+ ## Scope Enum
36
+
37
+ | Enum member | Value |
38
+ |---|---|
39
+ | `Scope.data_read` | `data:read` |
40
+ | `Scope.data_write` | `data:write` |
41
+ | `Scope.profile` | `profile` |
42
+ | `Scope.openid` | `openid` |
43
+ | `Scope.admin` | `admin` |
44
+
45
+ ## Response Models
46
+
47
+ **ActivitySummary** — returned by `get_activities()`:
48
+ `id`, `sport: Sport`, `start: datetime`, `end: datetime`, `start_local: datetime`, `end_local: datetime`, `duration: timedelta`, `distance: float?`, `name: str?`, `description: str?`, `metrics: list[Metric]`, `summary: ActivitySummarySummary?`, `tags: list[str]?`
49
+
50
+ The `summary` object contains per-metric aggregates: `summary.power.mean`, `summary.power.max`, `summary.heart_rate.mean`, `summary.distance.sum`, `summary.altitude.gain`, etc. All fields optional.
51
+
52
+ The `metrics` list indicates which data streams are available (e.g., `[Metric.power, Metric.heart_rate]`). Use to check availability without fetching data.
53
+
54
+ **ActivityDetails** — returned by `get_activity()`, `get_latest_activity()`:
55
+ Extends ActivitySummary with `traces: list[TraceDetails]?`, `devices: list[str]?`, `laps: list[Lap]?`
56
+
57
+ **TraceDetails** — returned by `get_traces()`, `create_trace()`:
58
+ `id`, `timestamp: datetime`, `timestamp_local: datetime`, `lactate: float?`, `rpe: int?`, `notes: str?`, `power: int?`, `speed: float?`, `heart_rate: int?`, `tags: list[str]?`, `sport: Sport?`, `activity: ActivitySummary?`
59
+
60
+ **UserSummary:** `id`, `first_name: str?`, `last_name: str?`, `display_name: str`, `admin: bool`
61
+
62
+ **UserInfoResponse:** `sub: str`, `name: str`, `given_name: str?`, `family_name: str?`, `email: str?`, `registered_at: datetime`
63
+
64
+ **UserResponse:** `id`, `first_name: str?`, `last_name: str?`, `display_name: str`, `admin: bool`, `is_managed: bool`, `registered_at: datetime`
65
+
66
+ **TokenResponse:** `access_token: str`, `token_type: str`, `expires_in: int`, `refresh_token: str`, `scope: str?`, `id_token: str?`
67
+
68
+ **BackfillStatus:** `backfill_loaded_until: datetime?`, `backfill_errors: list?`
@@ -0,0 +1,209 @@
1
+ # FastAPI Integration
2
+
3
+ Requires: `pip install sweatstack[fastapi]`
4
+
5
+ ## Contents
6
+
7
+ - [Setup](#setup)
8
+ - [Configuration](#configuration)
9
+ - [User Dependencies](#user-dependencies)
10
+ - [URL Helpers](#url-helpers)
11
+ - [User Delegation](#user-delegation)
12
+ - [Webhooks](#webhooks)
13
+ - [Token Stores](#token-stores)
14
+
15
+ ---
16
+
17
+ ## Setup
18
+
19
+ Two steps: `configure()` then `instrument()`.
20
+
21
+ ```python
22
+ from fastapi import FastAPI
23
+ from sweatstack.fastapi import configure, instrument, AuthenticatedUser
24
+
25
+ app = FastAPI()
26
+
27
+ configure(
28
+ client_id="YOUR_CLIENT_ID", # or SWEATSTACK_CLIENT_ID env var
29
+ client_secret="YOUR_SECRET", # or SWEATSTACK_CLIENT_SECRET env var
30
+ app_url="http://localhost:8000", # or APP_URL env var
31
+ session_secret="your-fernet-key", # or SWEATSTACK_SESSION_SECRET env var
32
+ )
33
+
34
+ instrument(app) # Registers auth routes
35
+
36
+ @app.get("/activities")
37
+ def list_activities(user: AuthenticatedUser):
38
+ return user.client.get_activities(limit=10)
39
+ ```
40
+
41
+ `instrument()` registers routes at `{auth_route_prefix}/login`, `/callback`, `/logout`, `/select-user/{user_id}`, `/select-self`. Default prefix: `/auth/sweatstack`.
42
+
43
+ ## Configuration
44
+
45
+ ```python
46
+ configure(
47
+ client_id: str = None, # SWEATSTACK_CLIENT_ID
48
+ client_secret: str = None, # SWEATSTACK_CLIENT_SECRET
49
+ app_url: str = None, # APP_URL
50
+ session_secret: str = None, # SWEATSTACK_SESSION_SECRET (Fernet key)
51
+ scopes: list[str] = ["profile", "data:read"],
52
+ cookie_secure: bool = None, # auto-detected from app_url scheme
53
+ cookie_max_age: int = 86400, # session lifetime in seconds
54
+ auth_route_prefix: str = "/auth/sweatstack",
55
+ redirect_unauthenticated: bool = True, # True = redirect to login, False = return 401
56
+ webhook_secret: str = None, # SWEATSTACK_WEBHOOK_SECRET
57
+ token_store: TokenStore = None, # for webhook user token persistence
58
+ )
59
+ ```
60
+
61
+ **Redirect URI** is computed automatically: `{app_url}{auth_route_prefix}/callback`
62
+
63
+ ## User Dependencies
64
+
65
+ Inject into endpoint handlers via type annotations:
66
+
67
+ ```python
68
+ from sweatstack.fastapi import AuthenticatedUser, SelectedUser, OptionalUser, OptionalSelectedUser
69
+
70
+ @app.get("/me")
71
+ def get_me(user: AuthenticatedUser):
72
+ # Always the principal (logged-in) user. Returns 401 if not authenticated.
73
+ return user.client.get_userinfo()
74
+
75
+ @app.get("/dashboard")
76
+ def dashboard(user: SelectedUser):
77
+ # Delegated user if one is selected, otherwise principal.
78
+ # Returns 401 if not authenticated.
79
+ return user.client.get_activities()
80
+
81
+ @app.get("/public")
82
+ def public(user: OptionalUser):
83
+ # Principal user or None. Never raises 401.
84
+ if user:
85
+ return user.client.get_activities()
86
+ return {"message": "Log in to see activities"}
87
+
88
+ @app.get("/feed")
89
+ def feed(user: OptionalSelectedUser):
90
+ # Selected user or None. Never raises 401.
91
+ ...
92
+ ```
93
+
94
+ **`SweatStackUser`** — the object all dependencies return:
95
+ - `user.client` — authenticated `Client` instance
96
+ - `user.user_id` — user ID extracted from JWT
97
+
98
+ ## URL Helpers
99
+
100
+ Generate URLs for auth actions in templates and redirects:
101
+
102
+ ```python
103
+ from sweatstack.fastapi import urls
104
+
105
+ urls.login() # /auth/sweatstack/login
106
+ urls.login(next="/dashboard") # /auth/sweatstack/login?next=/dashboard
107
+ urls.logout() # /auth/sweatstack/logout
108
+ urls.select_user("user_id") # /auth/sweatstack/select-user/user_id
109
+ urls.select_user("user_id", next="/app") # ...?next=/app
110
+ urls.select_user("user_id", team_id="t") # ...?team_id=t (delegate via team)
111
+ urls.select_self() # /auth/sweatstack/select-self
112
+ ```
113
+
114
+ ## User Delegation
115
+
116
+ Switch the session to operate as another user:
117
+
118
+ ```python
119
+ @app.get("/users")
120
+ def list_users(user: AuthenticatedUser):
121
+ # List users accessible to the principal
122
+ users = user.client.get_users()
123
+ # Generate switch links
124
+ return [
125
+ {"name": u.display_name, "switch_url": urls.select_user(u.id, next="/dashboard")}
126
+ for u in users
127
+ ]
128
+ ```
129
+
130
+ The built-in `/select-user/{user_id}` and `/select-self` routes handle token delegation. After switching, `SelectedUser` returns the delegated user's client.
131
+
132
+ ## Webhooks
133
+
134
+ Receive and verify webhook events from SweatStack.
135
+
136
+ ```python
137
+ from sweatstack.fastapi import configure, WebhookPayload, AuthenticatedUser
138
+
139
+ configure(
140
+ webhook_secret="your-webhook-secret", # or SWEATSTACK_WEBHOOK_SECRET
141
+ token_store=SQLiteTokenStore(), # required for webhook user lookups
142
+ )
143
+
144
+ @app.post("/webhooks/sweatstack")
145
+ def handle_webhook(payload: WebhookPayload, user: AuthenticatedUser):
146
+ # payload.user_id, payload.event_type, payload.resource_id, payload.timestamp
147
+ # user.client is authenticated as the webhook's user (loaded from token store)
148
+ activity = user.client.get_activity(payload.resource_id)
149
+ process(activity)
150
+ ```
151
+
152
+ **`WebhookPayload`** dependency verifies the `X-Sweatstack-Signature` header automatically. Returns 400 if invalid.
153
+
154
+ **Signature verification** uses HMAC-SHA256 with a 5-minute timestamp tolerance.
155
+
156
+ **Manual verification** (without the dependency):
157
+
158
+ ```python
159
+ from sweatstack.fastapi import verify_signature
160
+
161
+ verify_signature(
162
+ payload=request_body_bytes,
163
+ signature_header=request.headers["X-Sweatstack-Signature"],
164
+ secret="your-webhook-secret",
165
+ )
166
+ ```
167
+
168
+ ### Webhook Exceptions
169
+
170
+ | Exception | When |
171
+ |---|---|
172
+ | `WebhookVerificationError` | Invalid signature or expired timestamp |
173
+ | `WebhookTokenStoreError` | TokenStore not configured |
174
+ | `WebhookUserNotFoundError` | No stored tokens for the webhook's user |
175
+ | `WebhookTokenRefreshError` | Stored tokens expired and refresh failed |
176
+
177
+ ## Token Stores
178
+
179
+ Required for webhooks — persists user tokens so webhooks can authenticate as the user.
180
+
181
+ Tokens are saved automatically when users log in through the OAuth flow.
182
+
183
+ **Built-in stores (development only):**
184
+
185
+ ```python
186
+ from sweatstack.fastapi import SQLiteTokenStore, EncryptedSQLiteTokenStore
187
+
188
+ # Plain SQLite
189
+ store = SQLiteTokenStore(db_path="tokens.db")
190
+
191
+ # Encrypted SQLite (Fernet AES-128-CBC)
192
+ store = EncryptedSQLiteTokenStore(
193
+ encryption_key="your-key",
194
+ db_path="tokens_encrypted.db",
195
+ )
196
+ ```
197
+
198
+ **Production:** Implement the `TokenStore` protocol:
199
+
200
+ ```python
201
+ from sweatstack.fastapi import TokenStore, StoredTokens
202
+
203
+ class RedisTokenStore(TokenStore):
204
+ def save(self, tokens: StoredTokens) -> None: ...
205
+ def load(self, user_id: str) -> StoredTokens | None: ...
206
+ def delete(self, user_id: str) -> None: ...
207
+ ```
208
+
209
+ `StoredTokens` fields: `user_id`, `access_token`, `refresh_token`, `expires_at: datetime`.