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.
- {sweatstack-0.68.0 → sweatstack-0.70.0}/.claude/settings.local.json +2 -1
- sweatstack-0.70.0/.claude/skills/sweatstack-python/SKILL.md +49 -0
- sweatstack-0.70.0/.claude/skills/sweatstack-python/client.md +259 -0
- sweatstack-0.70.0/.claude/skills/sweatstack-python/data-models.md +68 -0
- sweatstack-0.70.0/.claude/skills/sweatstack-python/fastapi.md +209 -0
- sweatstack-0.70.0/.claude/skills/sweatstack-python/streamlit.md +116 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/.gitignore +5 -1
- {sweatstack-0.68.0 → sweatstack-0.70.0}/CHANGELOG.md +16 -0
- sweatstack-0.70.0/CLIENT_LIBRARY_SKILL.md +156 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/PKG-INFO +1 -1
- {sweatstack-0.68.0 → sweatstack-0.70.0}/pyproject.toml +1 -1
- {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/client.py +64 -34
- {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/fastapi/config.py +9 -2
- {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/fastapi/routes.py +12 -4
- {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/streamlit.py +6 -2
- {sweatstack-0.68.0 → sweatstack-0.70.0}/.python-version +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/CLIENT_DTYPE_CONVERSION.md +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/DEVELOPMENT.md +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/FASTAPI_DOCS.md +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/FASTAPI_PLUGIN.md +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/FASTAPI_USER_SWITCHING.md +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/FASTAPI_WEBHOOKS.md +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/LOCAL_AUTH.md +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/Makefile +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/README.md +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/docs/conf.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/docs/everything.rst +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/docs/index.rst +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/examples/fastapi_webhooks_example.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/examples/send_webhook.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/examples/tokens.db +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/fastapi_coaching_example.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/fastapi_example.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/fastapi_sweatstack.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/playground/.ipynb_checkpoints/Untitled-checkpoint.ipynb +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/playground/README.md +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/playground/Sweat Stack examples/Getting started.ipynb +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/playground/Untitled.ipynb +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/playground/hello.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/playground/pyproject.toml +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/__init__.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/cli.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/constants.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/fastapi/__init__.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/fastapi/dependencies.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/fastapi/models.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/fastapi/session.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/fastapi/token_stores.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/fastapi/webhooks.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/ipython_init.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/openapi_schemas.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/py.typed +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/schemas.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/sweatshell.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/src/sweatstack/utils.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/tests/__init__.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/tests/test_dtype_conversion.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/tests/test_webhooks.py +0 -0
- {sweatstack-0.68.0 → sweatstack-0.70.0}/uv.lock +0 -0
|
@@ -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`.
|