sweatstack 0.88.0__tar.gz → 0.89.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.88.0 → sweatstack-0.89.0}/.claude/skills/sweatstack-python/SKILL.md +11 -5
- {sweatstack-0.88.0 → sweatstack-0.89.0}/.claude/skills/sweatstack-python/client.md +84 -13
- {sweatstack-0.88.0 → sweatstack-0.89.0}/.claude/skills/sweatstack-python/data-models.md +9 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/.claude/skills/sweatstack-python/fastapi.md +12 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/.claude/skills/sweatstack-python/streamlit.md +14 -1
- sweatstack-0.89.0/.github/workflows/ci.yml +60 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/AGENTS.md +63 -7
- {sweatstack-0.88.0 → sweatstack-0.89.0}/CHANGELOG.md +77 -0
- sweatstack-0.89.0/PKG-INFO +146 -0
- sweatstack-0.89.0/README.md +96 -0
- sweatstack-0.89.0/plans/006_output_backends.md +380 -0
- sweatstack-0.89.0/plans/007_account_status_userinfo_portal.md +234 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/pyproject.toml +24 -5
- {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +4 -10
- sweatstack-0.89.0/src/sweatstack/_frames.py +471 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/client.py +961 -221
- {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/openapi_schemas.py +311 -653
- {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/schemas.py +32 -48
- {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/streamlit.py +1 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/utils.py +15 -4
- sweatstack-0.89.0/tests/test_account_status.py +148 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_dailies.py +10 -14
- {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_dtype_conversion.py +7 -2
- sweatstack-0.89.0/tests/test_frames.py +222 -0
- sweatstack-0.89.0/tests/test_identity.py +60 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_longitudinal_mean_max_after.py +18 -16
- sweatstack-0.89.0/tests/test_output.py +313 -0
- sweatstack-0.89.0/tests/test_portal.py +97 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_segmentation.py +1 -2
- {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_tests.py +5 -18
- {sweatstack-0.88.0 → sweatstack-0.89.0}/uv.lock +208 -148
- sweatstack-0.88.0/PKG-INFO +0 -50
- sweatstack-0.88.0/README.md +0 -9
- {sweatstack-0.88.0 → sweatstack-0.89.0}/.claude/settings.local.json +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/.gitignore +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/.python-version +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/CONTRIBUTING.md +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/DEVELOPMENT.md +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/LICENSE +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/Makefile +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/docs/conf.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/docs/everything.rst +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/docs/index.rst +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/examples/fastapi_webhooks_example.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/examples/send_webhook.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/plans/001a_tests.md +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/plans/001b_metadata.md +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/plans/001c_dailies.md +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/plans/003_trace_test_linking.md +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/plans/004_codebase_hygiene.md +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/plans/005_ost_sport_bridge.md +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/__init__.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/cli.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/constants.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/exceptions.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/fastapi/__init__.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/fastapi/config.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/fastapi/dependencies.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/fastapi/models.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/fastapi/routes.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/fastapi/session.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/fastapi/token_stores.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/fastapi/webhooks.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/ipython_init.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/py.typed +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/sweatshell.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/__init__.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_access_token_cache.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_exceptions.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_metadata.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_public_surface.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_sport_ost.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_teams.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_trace_test_linking.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_webhooks.py +0 -0
- {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_write_timezone_validation.py +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: sweatstack-python
|
|
3
3
|
description: >
|
|
4
|
-
Builds Python applications using the SweatStack client library (uv add sweatstack).
|
|
5
|
-
Covers authentication, activity and trace data retrieval, pandas
|
|
4
|
+
Builds Python applications using the SweatStack client library (uv add "sweatstack[pandas]").
|
|
5
|
+
Covers authentication, activity and trace data retrieval, pandas/Polars/Arrow output, Streamlit
|
|
6
6
|
dashboards, FastAPI backends, user delegation, teams, and file uploads. Use when writing
|
|
7
7
|
Python scripts, notebooks, Streamlit apps, or FastAPI services that access SweatStack
|
|
8
8
|
sports data — even if the user just says "Python" and "SweatStack" without naming the
|
|
@@ -13,9 +13,10 @@ description: >
|
|
|
13
13
|
|
|
14
14
|
Python client library for the SweatStack sports data platform.
|
|
15
15
|
|
|
16
|
-
**Install:** `uv add sweatstack`
|
|
16
|
+
**Install:** `uv add "sweatstack[pandas]"` for analysis (or `"sweatstack[polars]"`); plain `uv add sweatstack` for
|
|
17
|
+
services that only need the models (no frame library is installed by default).
|
|
17
18
|
|
|
18
|
-
**Extras:** `
|
|
19
|
+
**Extras:** `sweatstack[pandas]` · `sweatstack[polars]` · `sweatstack[arrow]` (pyarrow only; DuckDB users) · `sweatstack[streamlit]` (includes pandas) · `sweatstack[fastapi]`
|
|
19
20
|
|
|
20
21
|
## Quick Start
|
|
21
22
|
|
|
@@ -35,9 +36,14 @@ from sweatstack import Client
|
|
|
35
36
|
|
|
36
37
|
client = Client()
|
|
37
38
|
client.authenticate()
|
|
38
|
-
df = client.get_activities(
|
|
39
|
+
df = client.get_activities(output="pandas") # or output="polars"; default is a list of models
|
|
39
40
|
```
|
|
40
41
|
|
|
42
|
+
Every collection method takes `output=` (`"pandas"`, `"polars"`, `"arrow"`, `"bytes"`; `"models"` for
|
|
43
|
+
lists). Time series default to the installed frame library (Polars, then pandas, then Arrow), so code that assumes
|
|
44
|
+
one library should set it once: `sweatstack.set_output("pandas")` or `Client(output="polars")`. No frame has an
|
|
45
|
+
index: `timestamp`, the mean-max metric value and `date` are columns.
|
|
46
|
+
|
|
41
47
|
## Reference
|
|
42
48
|
|
|
43
49
|
**Client API** — methods for activities, traces, longitudinal data, users, uploads. Read [client.md](client.md)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
## Contents
|
|
4
4
|
|
|
5
5
|
- [Authentication](#authentication)
|
|
6
|
+
- [Output: pandas, Polars, Arrow, bytes](#output-pandas-polars-arrow-bytes)
|
|
6
7
|
- [Activities](#activities)
|
|
7
8
|
- [Time-Series Data](#time-series-data)
|
|
8
9
|
- [Mean-Max and AWD](#mean-max-and-awd)
|
|
@@ -12,6 +13,7 @@
|
|
|
12
13
|
- [Dailies](#dailies-daily-health-metrics)
|
|
13
14
|
- [App Metadata](#app-metadata)
|
|
14
15
|
- [Profile](#profile)
|
|
16
|
+
- [Account status and the Portal](#account-status-and-the-portal)
|
|
15
17
|
- [Users and Teams](#users-and-teams)
|
|
16
18
|
- [User Delegation](#user-delegation)
|
|
17
19
|
- [File Uploads](#file-uploads)
|
|
@@ -45,6 +47,35 @@ client = Client(api_key="...", refresh_token="...")
|
|
|
45
47
|
|
|
46
48
|
Token refresh is automatic in all modes. The library handles expiry checks and refreshes transparently.
|
|
47
49
|
|
|
50
|
+
## Output: pandas, Polars, Arrow, bytes
|
|
51
|
+
|
|
52
|
+
Every method that returns a collection takes `output=`:
|
|
53
|
+
|
|
54
|
+
| Endpoints | Values | Default |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| Time series, mean-max, AWD, longitudinal | `"pandas"`, `"polars"`, `"arrow"`, `"bytes"` | the installed library: Polars, then pandas, then Arrow |
|
|
57
|
+
| `get_activities`, `get_traces`, `get_tests`, `get_dailies` | `"models"`, `"pandas"`, `"polars"`, `"arrow"` | `"models"` |
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
df = client.get_activity_data("activity_id") # installed library: Polars, then pandas, then Arrow
|
|
61
|
+
pf = client.get_activity_data("activity_id", output="polars") # polars.DataFrame, wire dtypes
|
|
62
|
+
tb = client.get_activity_data("activity_id", output="arrow") # pyarrow.Table
|
|
63
|
+
raw = client.get_activity_data("activity_id", output="bytes") # parquet bytes: write to disk, query with DuckDB
|
|
64
|
+
|
|
65
|
+
sweatstack.set_output("polars") # module-wide default, or Client(output="polars")
|
|
66
|
+
client.get_activities() # now a polars frame; output="models" per call to get the list back
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Resolution: per-call > `Client(output=)` > `set_output()` > method default. Requires `sweatstack[pandas]`,
|
|
70
|
+
`sweatstack[polars]` or `sweatstack[arrow]`; a missing library raises `ImportError` naming the extra.
|
|
71
|
+
|
|
72
|
+
**DuckDB:** `duckdb.sql("select ... from tb")` works directly on an `output="arrow"` table or an `output="polars"`
|
|
73
|
+
frame (both need pyarrow: `sweatstack[arrow]`), or on a file written from `output="bytes"`
|
|
74
|
+
(`duckdb.sql("select ... from 'season.parquet'")`, no extra needed). No frame has an index on any
|
|
75
|
+
backend: `timestamp`, the mean-max metric value and `date` are ordinary first columns (`df.set_index("timestamp")`
|
|
76
|
+
if you need one). Polars and Arrow list frames give nested fields as structs (`df.unnest("summary")`); pandas flattens them
|
|
77
|
+
to dotted columns (`summary.power.mean`).
|
|
78
|
+
|
|
48
79
|
## Activities
|
|
49
80
|
|
|
50
81
|
```python
|
|
@@ -58,8 +89,9 @@ activities = client.get_activities(
|
|
|
58
89
|
offset=0, # for pagination
|
|
59
90
|
)
|
|
60
91
|
|
|
61
|
-
# As
|
|
62
|
-
df = client.get_activities(
|
|
92
|
+
# As a frame instead (nested summary/laps/traces flattened in pandas, structs in Polars)
|
|
93
|
+
df = client.get_activities(output="pandas")
|
|
94
|
+
pf = client.get_activities(output="polars")
|
|
63
95
|
|
|
64
96
|
# Single activity by ID (returns ActivityDetails)
|
|
65
97
|
activity = client.get_activity("activity_id")
|
|
@@ -71,7 +103,7 @@ latest = client.get_latest_activity(sport=Sport.running)
|
|
|
71
103
|
|
|
72
104
|
## Time-Series Data
|
|
73
105
|
|
|
74
|
-
Returns
|
|
106
|
+
Returns a frame with 1-second sampled data (`output="pandas"`, `"polars"`, `"arrow"` or `"bytes"`; default is the installed library, Polars if both). `timestamp` is a column, not an index.
|
|
75
107
|
|
|
76
108
|
```python
|
|
77
109
|
# All available metrics
|
|
@@ -132,7 +164,7 @@ df = client.get_longitudinal_awd(
|
|
|
132
164
|
)
|
|
133
165
|
```
|
|
134
166
|
|
|
135
|
-
The
|
|
167
|
+
The frame has a timezone-aware `timestamp` column (UTC), a naive `timestamp_local` column, and `activity_id` / `sport` columns — group by `activity_id` for per-activity aggregation. Mean-max and AWD frames have the metric value and `duration` as columns.
|
|
136
168
|
|
|
137
169
|
**Local caching** for reproducible analysis (avoids re-fetching on reruns). Caches `get_longitudinal_data()` and `get_longitudinal_mean_max()`:
|
|
138
170
|
```python
|
|
@@ -151,7 +183,7 @@ Custom data points with measurements (e.g., lactate tests, RPE entries).
|
|
|
151
183
|
|
|
152
184
|
```python
|
|
153
185
|
# List traces
|
|
154
|
-
traces = client.get_traces(start=date(2025, 1, 1),
|
|
186
|
+
traces = client.get_traces(start=date(2025, 1, 1), output="pandas")
|
|
155
187
|
|
|
156
188
|
# Create a trace
|
|
157
189
|
trace = client.create_trace(
|
|
@@ -195,8 +227,8 @@ tests = client.get_tests(
|
|
|
195
227
|
limit=50, # default 50
|
|
196
228
|
)
|
|
197
229
|
|
|
198
|
-
# As
|
|
199
|
-
df = client.get_tests(
|
|
230
|
+
# As a frame (pandas flattens results into columns like results.vo2max; Polars keeps a results struct)
|
|
231
|
+
df = client.get_tests(output="pandas")
|
|
200
232
|
|
|
201
233
|
# Single test by ID (returns TestDetails with resolved traces + overlapping activities)
|
|
202
234
|
test = client.get_test("test_id")
|
|
@@ -242,8 +274,8 @@ dailies = client.get_dailies(
|
|
|
242
274
|
interpolate=True, # default; server fills gaps
|
|
243
275
|
)
|
|
244
276
|
|
|
245
|
-
# As
|
|
246
|
-
df = client.get_dailies(DailyMeasure.body_mass, start=date(2026, 1, 1), end=date(2026, 3, 31),
|
|
277
|
+
# As a frame (date is a column)
|
|
278
|
+
df = client.get_dailies(DailyMeasure.body_mass, start=date(2026, 1, 1), end=date(2026, 3, 31), output="pandas")
|
|
247
279
|
|
|
248
280
|
# Set a daily value (upsert — creates or updates)
|
|
249
281
|
daily = client.set_daily(DailyMeasure.body_mass, date=date(2026, 4, 1), value=75.2)
|
|
@@ -284,10 +316,48 @@ Metadata appears as `app_metadata` on entity responses when accessed via app tok
|
|
|
284
316
|
sports = client.get_sports() # list[Sport] — sports with data
|
|
285
317
|
root_sports = client.get_sports(only_root=True) # top-level only
|
|
286
318
|
tags = client.get_tags() # list[str]
|
|
287
|
-
user = client.get_userinfo() # UserInfoResponse (sub, name, email)
|
|
288
|
-
who = client.whoami() # UserSummary
|
|
319
|
+
user = client.get_userinfo() # UserInfoResponse (sub, name, email, issue); needs `profile` scope
|
|
320
|
+
who = client.whoami() # UserSummary for the token's user; two API calls, no `profile` scope needed
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
## Account status and the Portal
|
|
324
|
+
|
|
325
|
+
Beta on the server side. `issue` is `None` or the one thing to tell the user; show `message`, and a button to
|
|
326
|
+
`action_url` only when it is present (it is `None` on delegated tokens and on issues nobody can act on).
|
|
327
|
+
Branch on `status`, never parse `message`, never branch on `code`.
|
|
328
|
+
|
|
329
|
+
```python
|
|
330
|
+
user = client.get_userinfo() # needs `profile`
|
|
331
|
+
if user.issue:
|
|
332
|
+
banner(user.issue.message, user.issue.action_url)
|
|
333
|
+
|
|
334
|
+
status = client.get_profile_status() # `data:read` or `profile`; AccountStatusResponse
|
|
335
|
+
status.issue # same object as above
|
|
336
|
+
status.capabilities[Capability.activity_history] # CapabilityStatus: ready | syncing | action_required | unavailable
|
|
289
337
|
```
|
|
290
338
|
|
|
339
|
+
| `status` | Show |
|
|
340
|
+
|---|---|
|
|
341
|
+
| `syncing` | a waiting state; resolves within 24 h |
|
|
342
|
+
| `action_required` | a button to `action_url` when present |
|
|
343
|
+
| `unavailable` | explain once, do not poll |
|
|
344
|
+
| `issue is None` | nothing |
|
|
345
|
+
|
|
346
|
+
Codes and capability keys are open sets (unknown ones parse as pseudo-members; ignore them). These methods
|
|
347
|
+
return models and ignore `output=`.
|
|
348
|
+
|
|
349
|
+
**Portal sessions** are for apps that want to choose the destination or the return link; otherwise
|
|
350
|
+
`action_url` already is a Portal link. Server-to-server: uses the client's `client_id` and (if registered)
|
|
351
|
+
`client_secret`, never a user token. `user.client` in FastAPI and `auth.client` in Streamlit already carry them.
|
|
352
|
+
|
|
353
|
+
```python
|
|
354
|
+
app = Client(client_id="01JMYRA...", client_secret="...") # secret only if the app has one
|
|
355
|
+
session = app.create_portal_session("manage-integrations", return_url="https://example.com/app/")
|
|
356
|
+
redirect(session.url) # opaque URL; never build one by hand
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Omit `return_url` on purpose for native apps and installed PWAs: the Portal then tells the user to close the page.
|
|
360
|
+
|
|
291
361
|
## Users and Teams
|
|
292
362
|
|
|
293
363
|
```python
|
|
@@ -422,8 +492,9 @@ Hierarchy:
|
|
|
422
492
|
- **Sport enum uses underscores:** `Sport.cycling_road`, not `Sport("road")` or `Sport.cycling.road`. String values use dots: `"cycling.road"`.
|
|
423
493
|
- **`start` is required for longitudinal endpoints.** Unlike `get_activities()` where all filters are optional.
|
|
424
494
|
- **`sport` (singular) vs `sports` (list):** `get_latest_activity(sport=...)` and `create_trace(sport=...)` take a single sport. All other methods that filter by sport use `sports=[...]` (list). The singular `sport` parameter on longitudinal methods is deprecated.
|
|
425
|
-
- **
|
|
426
|
-
-
|
|
495
|
+
- **pandas frames have standard dtypes.** The library converts API-optimized types (Int16, float16) to float64/datetime64[ns] for pandas. Polars and Arrow keep the compact wire dtypes.
|
|
496
|
+
- **No frame has an index.** `timestamp`, the mean-max metric value and `date` are columns. `df.set_index("timestamp")` if you need one.
|
|
497
|
+
- **`output=`** is on every collection method; `as_dataframe` no longer exists. Time-series methods always return a frame (the installed library by default, Polars if both), list methods return models by default. Set `sweatstack.set_output(...)` once when the code assumes one library.
|
|
427
498
|
- **`update_test()` and `update_trace()` are full replaces.** Omitted optional fields are set to null. Always re-pass all fields you want to keep.
|
|
428
499
|
- **`summary` fields are optional.** Always null-check: `activity.summary.power.mean if activity.summary and activity.summary.power else None`.
|
|
429
500
|
- **`metrics` on ActivitySummary** lists available data streams, not the data itself. Use to check availability before calling `get_activity_data()`.
|
|
@@ -42,6 +42,15 @@ Available data stream names: `duration`, `power`, `speed`, `heart_rate`, `cadenc
|
|
|
42
42
|
| `Scope.openid` | `openid` |
|
|
43
43
|
| `Scope.admin` | `admin` |
|
|
44
44
|
|
|
45
|
+
## Account Status Models
|
|
46
|
+
|
|
47
|
+
- `StatusIssueResponse`: `code` (`StatusIssueCode`, open set), `status` (`CapabilityStatus`, closed:
|
|
48
|
+
`ready`, `syncing`, `action_required`, `unavailable`), `message` (display only), `action_url` (`str | None`).
|
|
49
|
+
- `AccountStatusResponse`: `issue: StatusIssueResponse | None`, `capabilities: dict[Capability, CapabilityStatus]`
|
|
50
|
+
(`Capability` is an open set: `activities`, `activity_history`, `dailies`, `workouts`, ...).
|
|
51
|
+
- `UserInfoResponse.issue: StatusIssueResponse | None`.
|
|
52
|
+
- `PortalDestination` (`manage-integrations`, `manage-teams`) and `PortalSessionResponse` (`url`).
|
|
53
|
+
|
|
45
54
|
## Response Models
|
|
46
55
|
|
|
47
56
|
**ActivitySummary** — returned by `get_activities()`:
|
|
@@ -285,3 +285,15 @@ DEBUG access-token cache hit (session=4f2a91c0)
|
|
|
285
285
|
```
|
|
286
286
|
|
|
287
287
|
One refresh, four cache hits.
|
|
288
|
+
|
|
289
|
+
## Portal Sessions
|
|
290
|
+
|
|
291
|
+
`user.client` carries the app's `client_id` / `client_secret` from `configure()`, so it can mint a Portal link
|
|
292
|
+
without further setup. See [client.md](client.md#account-status-and-the-portal).
|
|
293
|
+
|
|
294
|
+
```python
|
|
295
|
+
@app.get("/fix-my-data")
|
|
296
|
+
def fix_my_data(user: AuthenticatedUser):
|
|
297
|
+
session = user.client.create_portal_session("manage-integrations", return_url="https://example.com/app/")
|
|
298
|
+
return RedirectResponse(session.url)
|
|
299
|
+
```
|
|
@@ -33,7 +33,7 @@ if not auth.is_authenticated():
|
|
|
33
33
|
|
|
34
34
|
# Use auth.client for all API calls
|
|
35
35
|
activities = auth.client.get_activities(limit=10)
|
|
36
|
-
st.dataframe(auth.client.get_activities(
|
|
36
|
+
st.dataframe(auth.client.get_activities(output="pandas")) # st.dataframe also accepts output="polars"
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
**`authenticate(login_label=None, show_logout=True)`** — renders login button if unauthenticated, logout button if authenticated. Handles OAuth callback automatically via `st.query_params`.
|
|
@@ -114,3 +114,16 @@ activities = auth.client.get_activities()
|
|
|
114
114
|
- **Session state keys:** `sweatstack_api_key`, `sweatstack_refresh_token`. Don't overwrite these.
|
|
115
115
|
- **`select_user()` switches the client.** After calling it, `auth.client` operates as the selected user. Call `auth.switch_to_principal_user()` to revert.
|
|
116
116
|
- **Scopes default to `data:read,profile`** in Streamlit — not the broader set used by `Client.authenticate()`. Add `offline_access` if you need refresh tokens in direct OAuth mode.
|
|
117
|
+
|
|
118
|
+
## Account Status and the Portal
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
user = auth.client.get_userinfo()
|
|
122
|
+
if user.issue:
|
|
123
|
+
st.warning(user.issue.message)
|
|
124
|
+
if user.issue.action_url:
|
|
125
|
+
st.link_button("Fix it", user.issue.action_url)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`auth.client` carries the app credentials, so `auth.client.create_portal_session("manage-integrations",
|
|
129
|
+
return_url=...)` works too. See [client.md](client.md#account-status-and-the-portal).
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
name: pytest (py${{ matrix.python }}${{ matrix.pandas && format(', pandas {0}', matrix.pandas) || '' }})
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
fail-fast: false
|
|
14
|
+
matrix:
|
|
15
|
+
python: ["3.10", "3.11", "3.12", "3.13"]
|
|
16
|
+
include:
|
|
17
|
+
# The lock file tracks the newest pandas; this leg keeps the declared
|
|
18
|
+
# lower bound (pyproject `pandas>=2.2.3`) honest.
|
|
19
|
+
- python: "3.12"
|
|
20
|
+
pandas: "2.2"
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v4
|
|
23
|
+
- uses: astral-sh/setup-uv@v5
|
|
24
|
+
with:
|
|
25
|
+
python-version: ${{ matrix.python }}
|
|
26
|
+
- run: uv sync --all-extras
|
|
27
|
+
- if: matrix.pandas == '2.2'
|
|
28
|
+
run: uv pip install "pandas>=2.2.3,<3"
|
|
29
|
+
- run: uv run pytest
|
|
30
|
+
|
|
31
|
+
bare-install:
|
|
32
|
+
# The base package must import without pandas, pyarrow or polars. This job
|
|
33
|
+
# is what keeps the frame libraries optional: a top-level `import pandas`
|
|
34
|
+
# added by habit fails here, not in a user's FastAPI service.
|
|
35
|
+
name: bare install imports without frame libraries
|
|
36
|
+
runs-on: ubuntu-latest
|
|
37
|
+
steps:
|
|
38
|
+
- uses: actions/checkout@v4
|
|
39
|
+
- uses: astral-sh/setup-uv@v5
|
|
40
|
+
with:
|
|
41
|
+
python-version: "3.12"
|
|
42
|
+
- run: uv venv
|
|
43
|
+
- run: uv pip install .
|
|
44
|
+
- run: |
|
|
45
|
+
uv run --no-project python - <<'EOF'
|
|
46
|
+
import sys
|
|
47
|
+
import sweatstack
|
|
48
|
+
forbidden = {"pandas", "numpy", "pyarrow", "polars"} & set(sys.modules)
|
|
49
|
+
assert not forbidden, f"base import pulled in frame libraries: {sorted(forbidden)}"
|
|
50
|
+
print("ok: sweatstack imports without", "pandas/numpy/pyarrow/polars")
|
|
51
|
+
EOF
|
|
52
|
+
- run: uv pip install ".[fastapi]"
|
|
53
|
+
- run: |
|
|
54
|
+
uv run --no-project python - <<'EOF'
|
|
55
|
+
import sys
|
|
56
|
+
import sweatstack.fastapi
|
|
57
|
+
forbidden = {"pandas", "numpy", "pyarrow", "polars"} & set(sys.modules)
|
|
58
|
+
assert not forbidden, f"fastapi import pulled in frame libraries: {sorted(forbidden)}"
|
|
59
|
+
print("ok: sweatstack.fastapi imports without frame libraries")
|
|
60
|
+
EOF
|
|
@@ -32,7 +32,9 @@ and pagination shape. Don't redesign the API in Python.
|
|
|
32
32
|
- A `traces=` query parameter renames to `trace_resolution=` on the Python
|
|
33
33
|
side when the wire name would be ambiguous as a kwarg. Document the
|
|
34
34
|
mapping in the docstring.
|
|
35
|
-
- `
|
|
35
|
+
- `output=` is a client-side choice of container over every collection
|
|
36
|
+
endpoint: `"pandas" | "polars" | "arrow" | "bytes"` for parquet endpoints,
|
|
37
|
+
`"models" | "pandas" | "polars" | "arrow"` for list endpoints. See "Output backends".
|
|
36
38
|
- Convenience composites that wrap multiple calls
|
|
37
39
|
(`get_latest_activity_data`, `get_longitudinal_*`) live alongside the
|
|
38
40
|
literal mirrors. Add new ones sparingly; only when a real workflow is
|
|
@@ -45,6 +47,7 @@ and pagination shape. Don't redesign the API in Python.
|
|
|
45
47
|
src/sweatstack/
|
|
46
48
|
├── openapi_schemas.py # AUTO-GENERATED. Never hand-edit.
|
|
47
49
|
├── schemas.py # Re-exports (incl. OST Sport/Modifier) + Metric/Scope/DailyMeasure helpers.
|
|
50
|
+
├── _frames.py # output= backends: parquet/models -> pandas/polars/arrow/bytes.
|
|
48
51
|
├── exceptions.py # Public error contract. No httpx types leak.
|
|
49
52
|
├── client.py # Single Client class + module-level singletons.
|
|
50
53
|
├── utils.py # Dataframe / JWT helpers.
|
|
@@ -72,12 +75,25 @@ Skipping step 3 silently breaks the public surface. Same for new enums.
|
|
|
72
75
|
bottom of `client.py`. Forgetting is silent.
|
|
73
76
|
- **Enum-typed params accept `Enum | str`** and route through
|
|
74
77
|
`_enums_to_strings`. Don't introduce strict-enum-only parameters.
|
|
78
|
+
- **Open enums stay open.** When the server documents an enum as an open
|
|
79
|
+
set (metrics, scopes, daily measures, status codes, capabilities),
|
|
80
|
+
register it with `_open_enum(...)` in `schemas.py` so unknown values
|
|
81
|
+
parse as pseudo-members. Leave closed sets strict.
|
|
82
|
+
- **Server-to-server calls use `_http_client(auth=False)`.** Endpoints
|
|
83
|
+
that authenticate with the app's own credentials in the body (Portal
|
|
84
|
+
sessions) must never receive a user's bearer.
|
|
75
85
|
- **`Sport` is the OpenSportTaxonomy type** (`open_sport_taxonomy.Sport`), not a generated enum.
|
|
76
86
|
Construct with `Sport("cycling.road")` or `Sport.parse(value)`; serialise with `str(sport)`. Codegen
|
|
77
87
|
binds the `sport` field to OST's permissive `SportField` (cli.py `_bind_sport_to_ost`), so regen is
|
|
78
88
|
safe and `sport`-typed fields keep decoding to `Sport`.
|
|
79
89
|
- **Tests are offline.** No network calls. Use `Client.__new__(Client)` to
|
|
80
90
|
bypass init when you need an instance for a helper method.
|
|
91
|
+
- **Frame libraries are optional extras.** Never import pandas, numpy,
|
|
92
|
+
pyarrow or polars at module top level; import inside the function, via
|
|
93
|
+
`_frames.require(...)` where an `ImportError` should name the extra.
|
|
94
|
+
CI's bare-install job fails otherwise.
|
|
95
|
+
- **No frame carries an index**, on any backend. Don't `set_index` in a
|
|
96
|
+
method; the caller does that.
|
|
81
97
|
- **`update_*` methods are full-replace.** Document the silent-clear
|
|
82
98
|
footgun in the docstring (see below).
|
|
83
99
|
- **CHANGELOG entries are user-facing**, not dev-facing.
|
|
@@ -134,10 +150,16 @@ Invariants baked into the template:
|
|
|
134
150
|
|
|
135
151
|
**List endpoints** add a `_get_<resource>_generator()` that yields
|
|
136
152
|
validated objects with internal pagination, plus a `get_<resource>s(...,
|
|
137
|
-
|
|
138
|
-
`
|
|
139
|
-
|
|
140
|
-
exemplars.
|
|
153
|
+
output=None)` wrapper that returns
|
|
154
|
+
`self._frame_from_models(models, Model, output, flatten=(...))`. Empty
|
|
155
|
+
lists yield typed empty frames automatically. See `get_activities` and
|
|
156
|
+
`get_tests` for exemplars.
|
|
157
|
+
|
|
158
|
+
**Parquet endpoints** return `self._read_frame(response.content, output)`.
|
|
159
|
+
Never call `pd.read_parquet` in a method.
|
|
160
|
+
|
|
161
|
+
Both kinds take `output` as a keyword-only parameter and carry
|
|
162
|
+
`@overload` stubs keyed on the `Literal` values (clone the neighbours).
|
|
141
163
|
|
|
142
164
|
**Query parameters** are only sent when the caller supplied a non-default
|
|
143
165
|
value. For enum-typed query params with an explicit default, compare
|
|
@@ -162,6 +184,40 @@ Don't try to soften the contract with "preserve if omitted" sentinels;
|
|
|
162
184
|
that diverges from how every other field behaves on these methods.
|
|
163
185
|
|
|
164
186
|
|
|
187
|
+
## Output backends
|
|
188
|
+
|
|
189
|
+
`_frames.py` turns parquet bytes and lists of models into the container
|
|
190
|
+
the caller asked for. Resolution: per-call `output` > `Client(output=)` >
|
|
191
|
+
`sweatstack.set_output()` > the method's default: models for lists, and
|
|
192
|
+
for parquet the installed frame library (`_frames.installed_frame_output`:
|
|
193
|
+
Polars, then pandas, then Arrow; `ImportError` naming the extras if none).
|
|
194
|
+
A configured default a method cannot produce is skipped, a per-call one
|
|
195
|
+
is a `ValueError`. Never hard-code a frame library as a default.
|
|
196
|
+
|
|
197
|
+
**Where `output` belongs.** On the *data* endpoints only: activities,
|
|
198
|
+
traces, tests, dailies and the time series, the things a user groups,
|
|
199
|
+
filters and plots. Everything about the account, the app, teams, status
|
|
200
|
+
and the Portal (`get_userinfo`, `get_profile_status`,
|
|
201
|
+
`create_portal_session`, `whoami`, `get_users`, `get_teams`, `get_sports`,
|
|
202
|
+
`get_tags`, ...) takes no `output` and always returns models; those
|
|
203
|
+
methods never call `_read_frame` or `_frame_from_models`, so a configured
|
|
204
|
+
output cannot reach them. A new data endpoint gets `output`; a new
|
|
205
|
+
control-plane endpoint does not.
|
|
206
|
+
|
|
207
|
+
Dtype policy: pandas gets `convert_to_standard_dtypes` (float64, ns);
|
|
208
|
+
Polars keeps wire dtypes except Float16 -> Float32; Arrow is the wire
|
|
209
|
+
table minus pandas index metadata; bytes is the body.
|
|
210
|
+
|
|
211
|
+
The Polars and Arrow paths for lists derive one type tree from each
|
|
212
|
+
model's **JSON Schema** (`_frames.field_types`) and render it per library;
|
|
213
|
+
values come from the instance.
|
|
214
|
+
**Never add a per-model special case there.** If a model needs one, the
|
|
215
|
+
JSON Schema grammar table in `_field_type` is missing a row: add the
|
|
216
|
+
row and its test. `tests/test_frames.py` turns `FrameSchemaWarning` into
|
|
217
|
+
a failure for every public model, so a regen that introduces an unknown
|
|
218
|
+
construct fails the suite, not a user's notebook.
|
|
219
|
+
|
|
220
|
+
|
|
165
221
|
## Exceptions
|
|
166
222
|
|
|
167
223
|
The hierarchy in `sweatstack/exceptions.py` is the **public** error
|
|
@@ -223,7 +279,7 @@ Bad (belongs in the commit message, not the changelog):
|
|
|
223
279
|
- Match the nearest existing method in `client.py`. Consistency with the
|
|
224
280
|
surroundings beats local cleverness.
|
|
225
281
|
- Reuse the helpers: `_enums_to_strings`, `_get_*_generator`,
|
|
226
|
-
`
|
|
227
|
-
|
|
282
|
+
`_read_frame`, `_frame_from_models`, `_set_app_metadata`. Don't
|
|
283
|
+
reinvent them.
|
|
228
284
|
- Don't add abstractions for hypothetical future flexibility. Three
|
|
229
285
|
similar blocks is the pattern, not a smell.
|
|
@@ -6,6 +6,83 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
## [0.89.0] - 2026-09-28
|
|
10
|
+
|
|
11
|
+
Frames on your terms. Every method that returns a collection takes `output=`:
|
|
12
|
+
`"pandas"`, `"polars"`, `"arrow"` or `"bytes"` for time-series endpoints, and `"models"`
|
|
13
|
+
(default), `"pandas"`, `"polars"` or `"arrow"` for list endpoints. Set it per call, per client
|
|
14
|
+
(`Client(output="polars")`) or once for everything (`sweatstack.set_output("polars")`). When you
|
|
15
|
+
don't say, time series come back in the frame library you installed: Polars, then pandas,
|
|
16
|
+
then Arrow.
|
|
17
|
+
Four breaking changes come with it; the upgrade is mechanical, see **Upgrading** below.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- `get_profile_status()` (beta): why this user has little or no data, and what the
|
|
22
|
+
account can supply. Returns `issue` (`None`, or one `{code, status, message, action_url}`)
|
|
23
|
+
and `capabilities` (`activities`, `activity_history`, `dailies`, `workouts`, each `ready`,
|
|
24
|
+
`syncing`, `action_required` or `unavailable`). Accepts `data:read` or `profile`.
|
|
25
|
+
- `get_userinfo()` now carries the same `issue` (beta). The whole integration is
|
|
26
|
+
`if user.issue: banner(user.issue.message, user.issue.action_url)`; `action_url` is `None` on
|
|
27
|
+
delegated tokens and on issues the user cannot act on.
|
|
28
|
+
- `create_portal_session(destination, return_url=None)` (beta): mints a SweatStack Portal link
|
|
29
|
+
branded for the client's app, using the app's own `client_id` / `client_secret` from the
|
|
30
|
+
constructor and no user token. Works from `sweatstack.fastapi` dependencies and
|
|
31
|
+
`StreamlitAuth` as is.
|
|
32
|
+
- New models and enums: `AccountStatusResponse`, `StatusIssueResponse`, `Capability`,
|
|
33
|
+
`CapabilityStatus`, `StatusIssueCode`, `PortalDestination`, `PortalSessionResponse`.
|
|
34
|
+
`StatusIssueCode` and `Capability` are open sets: values a newer server adds parse as
|
|
35
|
+
pseudo-members instead of failing validation.
|
|
36
|
+
- `output=` on `get_activity_data`, `get_activity_mean_max`, `get_activity_awd`,
|
|
37
|
+
`get_latest_activity_data`, `get_latest_activity_mean_max`, `get_longitudinal_data`,
|
|
38
|
+
`get_longitudinal_mean_max`, `get_longitudinal_awd`, `get_activities`, `get_traces`,
|
|
39
|
+
`get_tests` and `get_dailies`.
|
|
40
|
+
- `Client(output=...)` and `sweatstack.set_output(...)` to choose once. A per-call value
|
|
41
|
+
always wins. Delegated clients inherit the setting.
|
|
42
|
+
- Polars frames keep the compact wire dtypes (Int16, Float32, Categorical, Duration) and
|
|
43
|
+
give nested fields as typed structs (`df.unnest("summary")`), as do Arrow tables. On
|
|
44
|
+
time-series endpoints Arrow tables are the response as-is. `"bytes"` is the raw parquet, ready for `duckdb.sql("... from 'file.parquet'")`.
|
|
45
|
+
- `sweatstack[polars]` and `sweatstack[arrow]` extras. `[arrow]` is pyarrow alone: what
|
|
46
|
+
`output="arrow"` needs, and what DuckDB needs to query any in-memory frame. See the
|
|
47
|
+
README's "Using DuckDB" for the three routes.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- `whoami()` raised `AttributeError` on every call since the helper it relied on was removed.
|
|
52
|
+
It resolves the token's user through `get_user()` again, for principal and delegated clients
|
|
53
|
+
alike, and still needs no `profile` scope.
|
|
54
|
+
|
|
55
|
+
### Changed
|
|
56
|
+
|
|
57
|
+
- **Breaking:** pandas is no longer installed by default. Install `sweatstack[pandas]`
|
|
58
|
+
(pandas + pyarrow), `sweatstack[polars]` or `sweatstack[arrow]`. The `streamlit` and
|
|
59
|
+
`jupyter` extras include pandas. FastAPI services and webhook consumers can stay on the base package. Asking for an
|
|
60
|
+
output whose library is missing raises an `ImportError` naming the extra to install.
|
|
61
|
+
- **Breaking:** the default frame library is the one you installed, in the order Polars,
|
|
62
|
+
pandas, Arrow. An environment with pandas and Polars now gets Polars frames from the time-series
|
|
63
|
+
methods unless you set `output` (per call, `Client(output="pandas")`, or
|
|
64
|
+
`sweatstack.set_output("pandas")` once).
|
|
65
|
+
- **Breaking:** `as_dataframe=True` is removed. Use `output="pandas"`.
|
|
66
|
+
- **Breaking:** no frame carries an index any more, on any backend. `timestamp` (time
|
|
67
|
+
series), the metric value (mean-max and AWD curves) and `date` (dailies) are now regular
|
|
68
|
+
columns, in first position. The set of columns is unchanged. Code that relied on the
|
|
69
|
+
index needs `.set_index("timestamp")` (or `"power"`, `"date"`, ...) once, or should use
|
|
70
|
+
the column directly. This also applies to the fatigue mean-max (`after=`) frame, which was
|
|
71
|
+
previously re-indexed by the client.
|
|
72
|
+
- pandas frames keep the float64 / nanosecond dtype policy. Polars and Arrow do not upcast.
|
|
73
|
+
|
|
74
|
+
### Upgrading
|
|
75
|
+
|
|
76
|
+
1. Change the install line: `uv add "sweatstack[pandas]"` (or `[polars]`, or `[arrow]` for
|
|
77
|
+
DuckDB). Streamlit and
|
|
78
|
+
Jupyter users: `sweatstack[streamlit]` / `sweatstack[jupyter]` already include pandas.
|
|
79
|
+
2. To keep pandas frames in an environment that also has Polars, add
|
|
80
|
+
`sweatstack.set_output("pandas")` once (or `Client(output="pandas")`).
|
|
81
|
+
3. Replace `as_dataframe=True` with `output="pandas"`.
|
|
82
|
+
4. Search for `.index`, `.loc[<timestamp>]`, `.resample(`, `.plot()` on frames from the
|
|
83
|
+
time-series, mean-max, AWD and dailies methods. Where the index mattered, add
|
|
84
|
+
`.set_index("timestamp")` (or the metric name, or `"date"`) right after the call.
|
|
85
|
+
|
|
9
86
|
## [0.88.0] - 2026-08-06
|
|
10
87
|
|
|
11
88
|
### Changed
|