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.
Files changed (79) hide show
  1. {sweatstack-0.88.0 → sweatstack-0.89.0}/.claude/skills/sweatstack-python/SKILL.md +11 -5
  2. {sweatstack-0.88.0 → sweatstack-0.89.0}/.claude/skills/sweatstack-python/client.md +84 -13
  3. {sweatstack-0.88.0 → sweatstack-0.89.0}/.claude/skills/sweatstack-python/data-models.md +9 -0
  4. {sweatstack-0.88.0 → sweatstack-0.89.0}/.claude/skills/sweatstack-python/fastapi.md +12 -0
  5. {sweatstack-0.88.0 → sweatstack-0.89.0}/.claude/skills/sweatstack-python/streamlit.md +14 -1
  6. sweatstack-0.89.0/.github/workflows/ci.yml +60 -0
  7. {sweatstack-0.88.0 → sweatstack-0.89.0}/AGENTS.md +63 -7
  8. {sweatstack-0.88.0 → sweatstack-0.89.0}/CHANGELOG.md +77 -0
  9. sweatstack-0.89.0/PKG-INFO +146 -0
  10. sweatstack-0.89.0/README.md +96 -0
  11. sweatstack-0.89.0/plans/006_output_backends.md +380 -0
  12. sweatstack-0.89.0/plans/007_account_status_userinfo_portal.md +234 -0
  13. {sweatstack-0.88.0 → sweatstack-0.89.0}/pyproject.toml +24 -5
  14. {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +4 -10
  15. sweatstack-0.89.0/src/sweatstack/_frames.py +471 -0
  16. {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/client.py +961 -221
  17. {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/openapi_schemas.py +311 -653
  18. {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/schemas.py +32 -48
  19. {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/streamlit.py +1 -0
  20. {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/utils.py +15 -4
  21. sweatstack-0.89.0/tests/test_account_status.py +148 -0
  22. {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_dailies.py +10 -14
  23. {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_dtype_conversion.py +7 -2
  24. sweatstack-0.89.0/tests/test_frames.py +222 -0
  25. sweatstack-0.89.0/tests/test_identity.py +60 -0
  26. {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_longitudinal_mean_max_after.py +18 -16
  27. sweatstack-0.89.0/tests/test_output.py +313 -0
  28. sweatstack-0.89.0/tests/test_portal.py +97 -0
  29. {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_segmentation.py +1 -2
  30. {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_tests.py +5 -18
  31. {sweatstack-0.88.0 → sweatstack-0.89.0}/uv.lock +208 -148
  32. sweatstack-0.88.0/PKG-INFO +0 -50
  33. sweatstack-0.88.0/README.md +0 -9
  34. {sweatstack-0.88.0 → sweatstack-0.89.0}/.claude/settings.local.json +0 -0
  35. {sweatstack-0.88.0 → sweatstack-0.89.0}/.gitignore +0 -0
  36. {sweatstack-0.88.0 → sweatstack-0.89.0}/.python-version +0 -0
  37. {sweatstack-0.88.0 → sweatstack-0.89.0}/CONTRIBUTING.md +0 -0
  38. {sweatstack-0.88.0 → sweatstack-0.89.0}/DEVELOPMENT.md +0 -0
  39. {sweatstack-0.88.0 → sweatstack-0.89.0}/LICENSE +0 -0
  40. {sweatstack-0.88.0 → sweatstack-0.89.0}/Makefile +0 -0
  41. {sweatstack-0.88.0 → sweatstack-0.89.0}/docs/conf.py +0 -0
  42. {sweatstack-0.88.0 → sweatstack-0.89.0}/docs/everything.rst +0 -0
  43. {sweatstack-0.88.0 → sweatstack-0.89.0}/docs/index.rst +0 -0
  44. {sweatstack-0.88.0 → sweatstack-0.89.0}/examples/fastapi_webhooks_example.py +0 -0
  45. {sweatstack-0.88.0 → sweatstack-0.89.0}/examples/send_webhook.py +0 -0
  46. {sweatstack-0.88.0 → sweatstack-0.89.0}/plans/001a_tests.md +0 -0
  47. {sweatstack-0.88.0 → sweatstack-0.89.0}/plans/001b_metadata.md +0 -0
  48. {sweatstack-0.88.0 → sweatstack-0.89.0}/plans/001c_dailies.md +0 -0
  49. {sweatstack-0.88.0 → sweatstack-0.89.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
  50. {sweatstack-0.88.0 → sweatstack-0.89.0}/plans/003_trace_test_linking.md +0 -0
  51. {sweatstack-0.88.0 → sweatstack-0.89.0}/plans/004_codebase_hygiene.md +0 -0
  52. {sweatstack-0.88.0 → sweatstack-0.89.0}/plans/005_ost_sport_bridge.md +0 -0
  53. {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/__init__.py +0 -0
  54. {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/cli.py +0 -0
  55. {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/constants.py +0 -0
  56. {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/exceptions.py +0 -0
  57. {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/fastapi/__init__.py +0 -0
  58. {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
  59. {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/fastapi/config.py +0 -0
  60. {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/fastapi/dependencies.py +0 -0
  61. {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/fastapi/models.py +0 -0
  62. {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/fastapi/routes.py +0 -0
  63. {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/fastapi/session.py +0 -0
  64. {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/fastapi/token_stores.py +0 -0
  65. {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/fastapi/webhooks.py +0 -0
  66. {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/ipython_init.py +0 -0
  67. {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
  68. {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/py.typed +0 -0
  69. {sweatstack-0.88.0 → sweatstack-0.89.0}/src/sweatstack/sweatshell.py +0 -0
  70. {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/__init__.py +0 -0
  71. {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_access_token_cache.py +0 -0
  72. {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_exceptions.py +0 -0
  73. {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_metadata.py +0 -0
  74. {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_public_surface.py +0 -0
  75. {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_sport_ost.py +0 -0
  76. {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_teams.py +0 -0
  77. {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_trace_test_linking.py +0 -0
  78. {sweatstack-0.88.0 → sweatstack-0.89.0}/tests/test_webhooks.py +0 -0
  79. {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 DataFrames, Streamlit
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:** `uv add sweatstack[streamlit]` · `uv add sweatstack[fastapi]`
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(as_dataframe=True)
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 DataFrame instead
62
- df = client.get_activities(as_dataframe=True)
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 pandas DataFrame with 1-second sampled data.
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 DataFrame has a timezone-aware datetime index and includes an `activity_id` column — group by it for per-activity aggregation.
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), as_dataframe=True)
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 DataFrame (results column gets normalized into flat columns like results.vo2max)
199
- df = client.get_tests(as_dataframe=True)
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 DataFrame (date as index)
246
- df = client.get_dailies(DailyMeasure.body_mass, start=date(2026, 1, 1), end=date(2026, 3, 31), as_dataframe=True)
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 (from JWT, no API call)
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
- - **DataFrames have standard dtypes.** The library converts API-optimized types (Int16, float16) to float64/datetime64[ns] automatically.
426
- - **`as_dataframe=True`** is available on `get_activities()`, `get_traces()`, `get_tests()`, and `get_dailies()`. Time-series methods (`get_activity_data`, `get_longitudinal_data`, etc.) always return DataFrames.
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(as_dataframe=True))
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
- - `as_dataframe=True` is a client-side convenience over list endpoints.
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
- as_dataframe=False)` wrapper. Empty-list DataFrames go through
138
- `_create_empty_dataframe_from_model(Model, normalize_columns=[...])` so
139
- column names stay stable. See `get_activities` and `get_tests` for
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
- `_normalize_dataframe_column`, `_create_empty_dataframe_from_model`,
227
- `_set_app_metadata`. Don't reinvent them.
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