sweatstack 0.97.0__tar.gz → 0.98.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 (114) hide show
  1. {sweatstack-0.97.0 → sweatstack-0.98.0}/AGENTS.md +1 -1
  2. {sweatstack-0.97.0 → sweatstack-0.98.0}/CHANGELOG.md +32 -0
  3. {sweatstack-0.97.0 → sweatstack-0.98.0}/PKG-INFO +2 -1
  4. {sweatstack-0.97.0 → sweatstack-0.98.0}/pyproject.toml +3 -1
  5. {sweatstack-0.97.0 → sweatstack-0.98.0}/skills/sweatstack-python/SKILL.md +2 -1
  6. {sweatstack-0.97.0 → sweatstack-0.98.0}/skills/sweatstack-python/api.md +2 -1
  7. {sweatstack-0.97.0 → sweatstack-0.98.0}/skills/sweatstack-python/data-models.md +2 -1
  8. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/_frames.py +12 -4
  9. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/client.py +87 -14
  10. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/schemas.py +15 -6
  11. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_credentials.py +148 -1
  12. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_dailies.py +47 -2
  13. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_output.py +22 -0
  14. {sweatstack-0.97.0 → sweatstack-0.98.0}/uv.lock +3 -1
  15. {sweatstack-0.97.0 → sweatstack-0.98.0}/.git-blame-ignore-revs +0 -0
  16. {sweatstack-0.97.0 → sweatstack-0.98.0}/.github/workflows/ci.yml +0 -0
  17. {sweatstack-0.97.0 → sweatstack-0.98.0}/.gitignore +0 -0
  18. {sweatstack-0.97.0 → sweatstack-0.98.0}/.gitleaks.toml +0 -0
  19. {sweatstack-0.97.0 → sweatstack-0.98.0}/.python-version +0 -0
  20. {sweatstack-0.97.0 → sweatstack-0.98.0}/CONTRIBUTING.md +0 -0
  21. {sweatstack-0.97.0 → sweatstack-0.98.0}/DEVELOPMENT.md +0 -0
  22. {sweatstack-0.97.0 → sweatstack-0.98.0}/LICENSE +0 -0
  23. {sweatstack-0.97.0 → sweatstack-0.98.0}/Makefile +0 -0
  24. {sweatstack-0.97.0 → sweatstack-0.98.0}/README.md +0 -0
  25. {sweatstack-0.97.0 → sweatstack-0.98.0}/UPGRADING.md +0 -0
  26. {sweatstack-0.97.0 → sweatstack-0.98.0}/evals/__init__.py +0 -0
  27. {sweatstack-0.97.0 → sweatstack-0.98.0}/evals/agent/__init__.py +0 -0
  28. {sweatstack-0.97.0 → sweatstack-0.98.0}/evals/agent/agent.py +0 -0
  29. {sweatstack-0.97.0 → sweatstack-0.98.0}/evals/agent/cases.py +0 -0
  30. {sweatstack-0.97.0 → sweatstack-0.98.0}/evals/agent/evaluators.py +0 -0
  31. {sweatstack-0.97.0 → sweatstack-0.98.0}/evals/agent/harness.py +0 -0
  32. {sweatstack-0.97.0 → sweatstack-0.98.0}/evals/agent/lifecycle.py +0 -0
  33. {sweatstack-0.97.0 → sweatstack-0.98.0}/evals/agent/run.py +0 -0
  34. {sweatstack-0.97.0 → sweatstack-0.98.0}/examples/fastapi_webhooks_example.py +0 -0
  35. {sweatstack-0.97.0 → sweatstack-0.98.0}/examples/send_webhook.py +0 -0
  36. {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/001a_tests.md +0 -0
  37. {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/001b_metadata.md +0 -0
  38. {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/001c_dailies.md +0 -0
  39. {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
  40. {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/003_trace_test_linking.md +0 -0
  41. {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/004_codebase_hygiene.md +0 -0
  42. {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/005_ost_sport_bridge.md +0 -0
  43. {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/006_output_backends.md +0 -0
  44. {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/007_account_status_userinfo_portal.md +0 -0
  45. {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/008_sdk_documentation.md +0 -0
  46. {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/009_road_to_1_0.md +0 -0
  47. {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/010_credentials_one_source.md +0 -0
  48. {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/011_user_agent_on_every_request.md +0 -0
  49. {sweatstack-0.97.0 → sweatstack-0.98.0}/skills/sweatstack-python/fastapi.md +0 -0
  50. {sweatstack-0.97.0 → sweatstack-0.98.0}/skills/sweatstack-python/streamlit.md +0 -0
  51. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/__init__.py +0 -0
  52. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/_docs.py +0 -0
  53. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/_renames.py +0 -0
  54. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/_transport.py +0 -0
  55. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/cli.py +0 -0
  56. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/constants.py +0 -0
  57. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/exceptions.py +0 -0
  58. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/__init__.py +0 -0
  59. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
  60. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/config.py +0 -0
  61. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/dependencies.py +0 -0
  62. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/models.py +0 -0
  63. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/routes.py +0 -0
  64. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/session.py +0 -0
  65. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/token_stores.py +0 -0
  66. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/webhooks.py +0 -0
  67. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/openapi_schemas.py +0 -0
  68. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/py.typed +0 -0
  69. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/__init__.py +0 -0
  70. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/_app_metadata.py +0 -0
  71. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/_base.py +0 -0
  72. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/activities.py +0 -0
  73. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/dailies.py +0 -0
  74. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/oauth.py +0 -0
  75. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/portal.py +0 -0
  76. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/profile.py +0 -0
  77. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/teams.py +0 -0
  78. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/tests.py +0 -0
  79. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/traces.py +0 -0
  80. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/users.py +0 -0
  81. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/streamlit.py +0 -0
  82. {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/utils.py +0 -0
  83. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/__init__.py +0 -0
  84. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/conftest.py +0 -0
  85. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_access_token_cache.py +0 -0
  86. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_account_status.py +0 -0
  87. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_authorization_url.py +0 -0
  88. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_changelog.py +0 -0
  89. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_docs_check.py +0 -0
  90. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_docstrings.py +0 -0
  91. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_dtype_conversion.py +0 -0
  92. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_exceptions.py +0 -0
  93. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_frames.py +0 -0
  94. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_identity.py +0 -0
  95. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_longitudinal_mean_max_after.py +0 -0
  96. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_metadata.py +0 -0
  97. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_portal.py +0 -0
  98. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_public_hygiene.py +0 -0
  99. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_public_surface.py +0 -0
  100. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_renames.py +0 -0
  101. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_resources.py +0 -0
  102. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_segmentation.py +0 -0
  103. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_skill.py +0 -0
  104. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_sport_ost.py +0 -0
  105. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_streamlit_auth.py +0 -0
  106. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_teams.py +0 -0
  107. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_tests.py +0 -0
  108. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_trace_test_linking.py +0 -0
  109. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_transport.py +0 -0
  110. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_user_agent.py +0 -0
  111. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_webhooks.py +0 -0
  112. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_wire_names.py +0 -0
  113. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_write_timezone_validation.py +0 -0
  114. {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/typing/output_types.py +0 -0
@@ -137,7 +137,7 @@ it automatically.
137
137
  - **Enum-typed params accept `Enum | str`** and route through
138
138
  `_enums_to_strings`. Don't introduce strict-enum-only parameters.
139
139
  - **Open enums stay open.** When the server documents an enum as an open
140
- set (metrics, scopes, daily measures, status codes, capabilities),
140
+ set (metrics, scopes, daily measures, sources, status codes, capabilities),
141
141
  register it with `_open_enum(...)` in `schemas.py` so unknown values
142
142
  parse as pseudo-members. Leave closed sets strict.
143
143
  - **Never retry a request the server can't safely repeat.** `_transport.RETRYABLE_METHODS`
@@ -11,6 +11,38 @@ for a coding agent, are in `UPGRADING.md` next to this file and at
11
11
  https://docs.sweatstack.no/learn/python/upgrading/.
12
12
 
13
13
 
14
+ ## [0.98.0] - 2026-10-30
15
+
16
+ ### Added
17
+
18
+ - `SWEATSTACK_CLIENT_ID` and `SWEATSTACK_CLIENT_SECRET` name the app that issued
19
+ `SWEATSTACK_API_KEY` and `SWEATSTACK_REFRESH_TOKEN`, so a client that takes its tokens from the
20
+ environment renews them with that app. Before, it renewed with the SDK's own client, which
21
+ SweatStack refuses for a token issued to another app: tokens your app hands to a notebook, a
22
+ job or a sandbox stopped working once the access token expired. Set the secret only if your app
23
+ exchanged the sign-in code with it. The variables never apply to tokens saved by
24
+ `authenticate()` or passed to `Client`, and `client_id=` and `client_secret=` given to `Client`
25
+ always win.
26
+
27
+ ### Changed
28
+
29
+ - If your environment sets `SWEATSTACK_CLIENT_ID` for the Streamlit or FastAPI helper and also
30
+ holds a `SWEATSTACK_REFRESH_TOKEN` that `authenticate()` issued, a plain `Client()` there now
31
+ renews that token with your app, which SweatStack refuses: the renewal fails once the access
32
+ token expires, with an error that says why. Unset `SWEATSTACK_CLIENT_ID` and
33
+ `SWEATSTACK_CLIENT_SECRET` for that process, or pass them to the helper as `client_id=` and
34
+ `client_secret=` instead.
35
+ - `DailySource` is an open enum, like `Metric` and `DailyMeasure`: a `source` this version does
36
+ not know (on a daily, a marker or a max effort) becomes a `DailySource` with that value instead
37
+ of failing the whole response, so a new source on SweatStack never breaks this version.
38
+
39
+ ### Fixed
40
+
41
+ - Polars output works in the browser (Pyodide, as in JupyterLite). Pyodide's Polars has no
42
+ Parquet reader, so every time series and mean-max call raised an `AttributeError`; the client
43
+ now reads through pyarrow there, and `sweatstack[polars]` installs pyarrow on Pyodide.
44
+
45
+
14
46
  ## [0.97.0] - 2026-10-22
15
47
 
16
48
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sweatstack
3
- Version: 0.97.0
3
+ Version: 0.98.0
4
4
  Summary: The official Python client for SweatStack
5
5
  Project-URL: Homepage, https://sweatstack.no
6
6
  Project-URL: Documentation, https://docs.sweatstack.no/learn/python/
@@ -36,6 +36,7 @@ Requires-Dist: pandas>=2.2.3; extra == 'pandas'
36
36
  Requires-Dist: pyarrow>=20.0.0; extra == 'pandas'
37
37
  Provides-Extra: polars
38
38
  Requires-Dist: polars>=1.30; extra == 'polars'
39
+ Requires-Dist: pyarrow>=20.0.0; (sys_platform == 'emscripten') and extra == 'polars'
39
40
  Provides-Extra: streamlit
40
41
  Requires-Dist: pandas>=2.2.3; extra == 'streamlit'
41
42
  Requires-Dist: pyarrow>=20.0.0; extra == 'streamlit'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sweatstack"
3
- version = "0.97.0"
3
+ version = "0.98.0"
4
4
  description = "The official Python client for SweatStack"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -53,6 +53,8 @@ polars = [
53
53
  # >=1.30 verified: reads the server's float16 columns (as Float32 before Float16
54
54
  # support landed, as Float16 after; the client casts to Float32 either way).
55
55
  "polars>=1.30",
56
+ # Pyodide's Polars has no Parquet reader; the client reads through pyarrow there.
57
+ "pyarrow>=20.0.0; sys_platform == 'emscripten'",
56
58
  ]
57
59
  streamlit = [
58
60
  "sweatstack[pandas]",
@@ -88,7 +88,8 @@ pandas (`summary.power.mean`) and structs in Polars (`.unnest("summary")`).
88
88
  ## Authentication
89
89
 
90
90
  - Scripts and notebooks: `client.authenticate()` (browser once, tokens saved on the machine).
91
- - Headless: set `SWEATSTACK_API_KEY` (and `SWEATSTACK_REFRESH_TOKEN` for refresh); no
91
+ - Headless: set `SWEATSTACK_API_KEY` (and `SWEATSTACK_REFRESH_TOKEN` for refresh, with
92
+ `SWEATSTACK_CLIENT_ID` and `SWEATSTACK_CLIENT_SECRET` when your own app issued them); no
92
93
  `authenticate()` call.
93
94
  - Apps where other people sign in: the Streamlit or FastAPI helper, see
94
95
  [streamlit.md](streamlit.md) and [fastapi.md](fastapi.md). In your own server, build one
@@ -124,7 +124,8 @@ GET, PUT and DELETE are retried (connection errors, timeouts, 408, 429, 5xx) up
124
124
  `max_retries` times; POST never is. Use `max_retries=0` inside web requests.
125
125
 
126
126
  The access and refresh token come from one source: the arguments, else `SWEATSTACK_API_KEY`
127
- and `SWEATSTACK_REFRESH_TOKEN`, else the tokens `authenticate()` saved on this machine. A
127
+ and `SWEATSTACK_REFRESH_TOKEN` (renewed by the app in `SWEATSTACK_CLIENT_ID` and
128
+ `SWEATSTACK_CLIENT_SECRET`, if set), else the tokens `authenticate()` saved on this machine. A
128
129
  client given only an access token cannot renew it and raises `SweatStackTokenRefreshError` on
129
130
  expiry. In a server, build each user's client from that user's tokens with
130
131
  `ambient_credentials=False`: it then never reads or writes the machine's tokens.
@@ -48,7 +48,8 @@ taxonomy is preserved (`is_standard` is `False`) instead of failing.
48
48
  `end_local`, `results` (`TestResults`: thresholds `lt1`, `lt2`, `vt1`, `vt2`, `mlss`, `fatmax`
49
49
  as `Marker`s, plus `vo2max`, `critical_power`, `w_prime`, ...), `tags`, `created_by`;
50
50
  `TestDetails` adds the resolved `traces` and overlapping `activities`.
51
- - **DailyResponse**: `date`, `value`, `status`, `source`.
51
+ - **DailyResponse**: `date`, `value`, `status`, `source` (`DailySource`, an open set: `manual`,
52
+ `garmin_connect`, ...; markers and max efforts carry it too).
52
53
  - **UserSummary** (`users.list()`): `id`, `first_name`, `last_name`, `display_name`, `scopes`,
53
54
  `is_managed`.
54
55
  - **UserResponse** (`users.create()`, `retrieve()`, `update()`): `id`, `first_name`,
@@ -20,6 +20,7 @@ package imports without any of them (enforced by CI's bare-install job).
20
20
  from __future__ import annotations
21
21
 
22
22
  import json
23
+ import sys
23
24
  import warnings
24
25
  from collections.abc import Sequence
25
26
  from datetime import UTC, datetime
@@ -185,15 +186,22 @@ def parquet_to_pandas(content: bytes) -> pd.DataFrame:
185
186
 
186
187
  def parquet_to_polars(content: bytes) -> pl.DataFrame:
187
188
  """Parquet bytes to a Polars DataFrame with the wire dtypes, except
188
- Float16 -> Float32 (Polars supports few operations on Float16)."""
189
+ Float16 -> Float32 (Polars supports few operations on Float16).
190
+
191
+ In the browser (Pyodide), Polars is built without its Parquet reader, so the bytes go
192
+ through pyarrow, which Pyodide ships, and ``pl.from_arrow``."""
189
193
  pl = require("polars")
190
194
 
191
- df = pl.read_parquet(BytesIO(content))
195
+ if sys.platform == "emscripten":
196
+ df = pl.from_arrow(parquet_to_arrow(content))
197
+ else:
198
+ df = pl.read_parquet(BytesIO(content))
199
+ metadata = pl.read_parquet_metadata(BytesIO(content))
200
+ df = df.select(_index_columns_first(df.columns, metadata.get("pandas")))
192
201
  float16 = getattr(pl, "Float16", None) # older Polars already reads float16 as Float32
193
202
  if float16 is not None:
194
203
  df = df.cast({name: pl.Float32 for name, dtype in df.schema.items() if dtype == float16})
195
- metadata = pl.read_parquet_metadata(BytesIO(content))
196
- return df.select(_index_columns_first(df.columns, metadata.get("pandas")))
204
+ return df
197
205
 
198
206
 
199
207
  def parquet_to_arrow(content: bytes) -> Any:
@@ -59,6 +59,7 @@ from .schemas import (
59
59
  CapabilityStatus,
60
60
  DailyMeasure,
61
61
  DailyResponse,
62
+ DailySource,
62
63
  IssueStatus,
63
64
  Marker,
64
65
  Metric,
@@ -178,6 +179,12 @@ class Client:
178
179
  ambient_credentials: bool = True
179
180
  _api_key: SecretStr | None = None
180
181
  _refresh_token: SecretStr | None = None
182
+ # The app credentials given to the client; see ``_app`` for the ones a refresh uses.
183
+ _client_id: str | None = None
184
+ _client_secret: SecretStr | None = None
185
+ # The app that issued a refresh token loaded from the environment, as SWEATSTACK_CLIENT_ID
186
+ # and SWEATSTACK_CLIENT_SECRET name it. Cleared when a refresh token is set from elsewhere.
187
+ _environment_app: tuple[str, SecretStr | None] | None = None
181
188
  # Whether this client's tokens live in the token file, so a refresh is written back there.
182
189
  # Set by loading them from the file or by saving them to it; never by giving tokens directly.
183
190
  _file_backed: bool = False
@@ -215,13 +222,18 @@ class Client:
215
222
  ``SweatStackTokenRefreshError`` rather than renew with a refresh token from elsewhere.
216
223
  Only a client whose tokens came from the saved file writes a renewed token back to it.
217
224
 
225
+ Only the app a refresh token was issued to can renew it. That is the ``client_id``
226
+ and ``client_secret`` given here; else, for tokens from the environment, the app in
227
+ ``SWEATSTACK_CLIENT_ID`` and ``SWEATSTACK_CLIENT_SECRET``; else the SDK's own client,
228
+ which ``authenticate()`` signs in with.
229
+
218
230
  Args:
219
231
  api_key: Optional API access token. If not provided, will check environment or storage.
220
232
  refresh_token: Optional refresh token for automatic token renewal.
221
233
  url: Optional SweatStack instance URL. Defaults to production.
222
234
  streamlit_compatible: Set to True when using in Streamlit apps.
223
- client_id: Optional OAuth client ID. Defaults to the public client ID.
224
- client_secret: Optional OAuth client secret for confidential clients.
235
+ client_id: The client ID of the app the tokens were issued to.
236
+ client_secret: That app's client secret, if it exchanged the sign-in code with one.
225
237
  skip_token_expiry_check: If True, skip JWT expiry validation and use the token as-is.
226
238
  Use this when token lifecycle is managed externally (e.g. by a proxy).
227
239
  output: Default container for every method that returns a collection:
@@ -270,7 +282,7 @@ class Client:
270
282
  self.streamlit_compatible = streamlit_compatible
271
283
  self.skip_token_expiry_check = skip_token_expiry_check
272
284
  self.output = _frames.check_frame_output(output)
273
- self.client_id = client_id or OAUTH2_CLIENT_ID
285
+ self._client_id = client_id or None
274
286
  self.timeout = timeout
275
287
  self.max_retries = max_retries
276
288
  self.ambient_credentials = ambient_credentials
@@ -362,6 +374,13 @@ class Client:
362
374
  # sources, so a client given only an access token never renews with a refresh token that
363
375
  # another client saved on this machine, and a bare client in a server never becomes the
364
376
  # user who refreshed last. Only a file-backed client writes the file.
377
+ #
378
+ # A refresh token can only be renewed by the app it was issued to, with that app's secret
379
+ # if it has one. The file holds what authenticate() saved, issued to the SDK's own client.
380
+ # A pair in the environment may come from any app, so that app travels with it as
381
+ # SWEATSTACK_CLIENT_ID and SWEATSTACK_CLIENT_SECRET. It belongs to that refresh token: it
382
+ # never applies to a pair from elsewhere, and is dropped when the refresh token is replaced
383
+ # (a new sign-in). App credentials given to the client always win.
365
384
 
366
385
  def _resolve_credentials(self) -> tuple[str | None, str | None]:
367
386
  """The access token and the refresh token, as a pair from one source.
@@ -381,6 +400,8 @@ class Client:
381
400
  self._api_key = _to_secret(access_token)
382
401
  self._refresh_token = _to_secret(refresh_token)
383
402
  self._file_backed = file_backed
403
+ if not file_backed:
404
+ self._environment_app = self._load_environment_app()
384
405
  break
385
406
  return (
386
407
  self._api_key.get_secret_value() if self._api_key is not None else None,
@@ -391,29 +412,61 @@ class Client:
391
412
  def _load_environment_tokens() -> tuple[str | None, str | None]:
392
413
  return os.getenv("SWEATSTACK_API_KEY"), os.getenv("SWEATSTACK_REFRESH_TOKEN")
393
414
 
415
+ @staticmethod
416
+ def _load_environment_app() -> tuple[str, SecretStr | None] | None:
417
+ client_id = os.getenv("SWEATSTACK_CLIENT_ID")
418
+ if not client_id:
419
+ return None
420
+ return client_id, _to_secret(os.getenv("SWEATSTACK_CLIENT_SECRET") or None)
421
+
422
+ def _app(self) -> tuple[str, SecretStr | None]:
423
+ """The client ID and secret of the app that renews this client's tokens.
424
+
425
+ A given client ID wins, and a given secret too. Otherwise a pair from the environment
426
+ is renewed by the app the environment names, and any other pair by the SDK's own
427
+ client, which has no secret.
428
+ """
429
+ self._resolve_credentials()
430
+ if (app := self._renewing_environment_app()) is not None:
431
+ client_id, client_secret = app
432
+ return client_id, self._client_secret or client_secret
433
+ return self._client_id or OAUTH2_CLIENT_ID, self._client_secret
434
+
435
+ def _renewing_environment_app(self) -> tuple[str, SecretStr | None] | None:
436
+ """The environment's app, when it is the one that renews this client's tokens."""
437
+ return self._environment_app if self._client_id is None else None
438
+
394
439
  def _do_token_refresh(self, refresh_token: str) -> TokenResponse:
395
440
  """Exchange the refresh token for a new access token.
396
441
 
397
442
  Raises:
398
443
  SweatStackTokenRefreshError: If the refresh request fails.
399
444
  """
445
+ client_id, client_secret = self._app()
400
446
  with self._http_client(skip_token_check=True) as client:
401
447
  response = client.post(
402
448
  "/api/v1/oauth/token",
403
449
  data={
404
450
  "grant_type": "refresh_token",
405
451
  "refresh_token": refresh_token,
406
- "client_id": self.client_id,
407
- "client_secret": self._client_secret.get_secret_value()
408
- if self._client_secret
409
- else None,
452
+ "client_id": client_id,
453
+ "client_secret": client_secret.get_secret_value() if client_secret else None,
410
454
  },
411
455
  )
412
456
 
413
457
  try:
414
458
  self._raise_for_status(response)
415
459
  except SweatStackAPIError as e:
416
- raise SweatStackTokenRefreshError(f"Token refresh request failed: {e}") from e
460
+ message = f"Token refresh request failed: {e}"
461
+ refused = isinstance(e, (SweatStackAuthError, SweatStackBadRequestError))
462
+ if refused and self._renewing_environment_app() is not None:
463
+ message += (
464
+ " The tokens came from the environment, so they were renewed with the "
465
+ f"app in SWEATSTACK_CLIENT_ID ({client_id}), which works only for tokens "
466
+ "that app issued. If authenticate() issued them, unset "
467
+ "SWEATSTACK_CLIENT_ID and SWEATSTACK_CLIENT_SECRET for this process."
468
+ )
469
+ raise SweatStackTokenRefreshError(message) from e
417
470
 
418
471
  return TokenResponse.model_validate(response.json())
419
472
 
@@ -495,15 +548,28 @@ class Client:
495
548
  @refresh_token.setter
496
549
  def refresh_token(self, value: str | SecretStr | None):
497
550
  self._refresh_token = _to_secret(value)
551
+ self._environment_app = None # that app issued the environment's token, not this one
552
+
553
+ @property
554
+ def client_id(self) -> str:
555
+ """The client ID of the app that renews this client's tokens.
556
+
557
+ See ``Client`` for which app that is.
558
+ """
559
+ return self._app()[0]
560
+
561
+ @client_id.setter
562
+ def client_id(self, value: str | None):
563
+ self._client_id = value or None
498
564
 
499
565
  @property
500
566
  def client_secret(self) -> SecretStr | None:
501
- """The OAuth client secret for confidential clients.
567
+ """The client secret of the app that renews this client's tokens, if it has one.
502
568
 
503
569
  Returns a SecretStr to prevent accidental logging of the secret.
504
570
  Use .get_secret_value() to get the actual secret string.
505
571
  """
506
- return self._client_secret
572
+ return self._app()[1]
507
573
 
508
574
  @client_secret.setter
509
575
  def client_secret(self, value: str | SecretStr | None):
@@ -580,7 +646,9 @@ class Client:
580
646
  sign-in on this machine. Opens the browser for a SweatStack sign-in only when none are
581
647
  found, or when ``force=True``.
582
648
 
583
- In headless environments, set the environment variables instead of calling this.
649
+ In headless environments, set the environment variables instead of calling this, with
650
+ ``SWEATSTACK_CLIENT_ID`` and ``SWEATSTACK_CLIENT_SECRET`` when your own app issued the
651
+ tokens.
584
652
 
585
653
  Args:
586
654
  force: Sign in again even if credentials exist.
@@ -732,14 +800,18 @@ class Client:
732
800
  return self._with_tokens(tokens)
733
801
 
734
802
  def _with_tokens(self, tokens: dict[str, Any]) -> Client:
735
- """A new client with these tokens and this client's settings."""
803
+ """A new client with these tokens and this client's settings.
804
+
805
+ The tokens were issued to this client's app, so the new client renews them with it.
806
+ """
807
+ client_id, client_secret = self._app()
736
808
  return self.__class__(
737
809
  api_key=tokens["access_token"],
738
810
  refresh_token=tokens["refresh_token"],
739
811
  url=self._url,
740
812
  streamlit_compatible=self.streamlit_compatible,
741
- client_id=self.client_id,
742
- client_secret=self._client_secret,
813
+ client_id=client_id,
814
+ client_secret=client_secret,
743
815
  output=self.output,
744
816
  timeout=self.timeout,
745
817
  max_retries=self.max_retries,
@@ -1151,6 +1223,7 @@ __all__ = sorted(
1151
1223
  "CapabilityStatus",
1152
1224
  "DailyMeasure",
1153
1225
  "DailyResponse",
1226
+ "DailySource",
1154
1227
  "IssueStatus",
1155
1228
  "Marker",
1156
1229
  "Metric",
@@ -30,6 +30,7 @@ from .openapi_schemas import (
30
30
  CapabilityStatus,
31
31
  DailyMeasure,
32
32
  DailyResponse,
33
+ DailySource,
33
34
  IssueStatus,
34
35
  Marker,
35
36
  Metric,
@@ -56,11 +57,11 @@ from .openapi_schemas import (
56
57
  def _open_enum(enum_cls: type[Enum]) -> None:
57
58
  """Let ``enum_cls`` accept values the bundled schema does not know yet.
58
59
 
59
- The server treats some enums as open sets (new metrics, scopes, daily measures, status
60
- codes, capabilities and Portal destinations appear without notice). An unknown value
61
- becomes a pseudo-member with ``.value`` and ``.name`` set to the string, cached so repeated
62
- lookups return the same object, instead of a validation error that would break the client
63
- until it is updated. Closed enums (``CapabilityStatus``, ...) are left strict on purpose:
60
+ The server treats some enums as open sets (new metrics, scopes, daily measures, sources of a
61
+ value, status codes, capabilities and Portal destinations appear without notice). An unknown
62
+ value becomes a pseudo-member with ``.value`` and ``.name`` set to the string, cached so
63
+ repeated lookups return the same object, instead of a validation error that would break the
64
+ client until it is updated. Closed enums (``CapabilityStatus``, ...) are left strict on purpose:
64
65
  the server promises those never grow, or a value the client does not know is not something
65
66
  it should act on.
66
67
  """
@@ -76,7 +77,15 @@ def _open_enum(enum_cls: type[Enum]) -> None:
76
77
  enum_cls._missing_ = _missing_ # ty: ignore[invalid-assignment] # patched onto a generated enum
77
78
 
78
79
 
79
- for _open in (Metric, Scope, DailyMeasure, StatusIssueCode, Capability, PortalDestination):
80
+ for _open in (
81
+ Metric,
82
+ Scope,
83
+ DailyMeasure,
84
+ DailySource,
85
+ StatusIssueCode,
86
+ Capability,
87
+ PortalDestination,
88
+ ):
80
89
  _open_enum(_open)
81
90
 
82
91
 
@@ -12,10 +12,12 @@ import threading
12
12
  import time
13
13
  from types import SimpleNamespace
14
14
  from unittest.mock import patch
15
+ from urllib.parse import parse_qs
15
16
 
17
+ import httpx2
16
18
  import pytest
17
19
 
18
- from sweatstack.client import Client
20
+ from sweatstack.client import OAUTH2_CLIENT_ID, Client
19
21
  from sweatstack.exceptions import SweatStackTokenRefreshError
20
22
  from sweatstack.schemas import TokenResponse
21
23
 
@@ -259,3 +261,148 @@ class TestResolution:
259
261
  import sweatstack
260
262
 
261
263
  assert sweatstack.client._default_client.ambient_credentials is True
264
+
265
+
266
+ class TestApp:
267
+ """Only the app a refresh token was issued to can renew it, with that app's secret.
268
+
269
+ What a refresh sends is read off the wire, so these pin the request the server sees.
270
+ """
271
+
272
+ @pytest.fixture
273
+ def renewed_by(self, monkeypatch):
274
+ """The ``(client_id, client_secret)`` of every refresh request, answered offline."""
275
+ sent: list[tuple[str, str | None]] = []
276
+
277
+ def token_endpoint(request: httpx2.Request) -> httpx2.Response:
278
+ form = parse_qs(request.content.decode())
279
+ sent.append((form["client_id"][0], form.get("client_secret", [None])[0]))
280
+ owner = OWNERS[form["refresh_token"][0]]
281
+ return httpx2.Response(200, json={"access_token": jwt(owner, 900), "expires_in": 900})
282
+
283
+ pool = httpx2.Client(base_url=URL, transport=httpx2.MockTransport(token_endpoint))
284
+ monkeypatch.setattr(Client, "_pool", lambda self: pool)
285
+ return sent
286
+
287
+ @pytest.fixture
288
+ def answer(self, monkeypatch):
289
+ """Makes the token endpoint answer every refresh request with an error status."""
290
+
291
+ def answer_with(status: int) -> None:
292
+ def token_endpoint(request: httpx2.Request) -> httpx2.Response:
293
+ return httpx2.Response(status, json={"error": "invalid_grant"})
294
+
295
+ pool = httpx2.Client(base_url=URL, transport=httpx2.MockTransport(token_endpoint))
296
+ monkeypatch.setattr(Client, "_pool", lambda self: pool)
297
+
298
+ return answer_with
299
+
300
+ @pytest.fixture
301
+ def environment_pair(self, monkeypatch):
302
+ monkeypatch.setenv("SWEATSTACK_API_KEY", jwt("user_a", -60))
303
+ monkeypatch.setenv("SWEATSTACK_REFRESH_TOKEN", "refresh_a")
304
+
305
+ @pytest.fixture
306
+ def environment_app(self, monkeypatch):
307
+ monkeypatch.setenv("SWEATSTACK_CLIENT_ID", "app_123")
308
+ monkeypatch.setenv("SWEATSTACK_CLIENT_SECRET", "secret_123")
309
+
310
+ def test_an_environment_pair_is_renewed_by_the_environment_app(
311
+ self, renewed_by, environment_pair, environment_app
312
+ ):
313
+ client = Client(url=URL)
314
+ assert client.client_id == "app_123" # before any request, too
315
+ assert acts_as(client) == "user_a"
316
+ assert renewed_by == [("app_123", "secret_123")]
317
+
318
+ def test_a_public_environment_app_sends_no_secret(
319
+ self, renewed_by, environment_pair, monkeypatch
320
+ ):
321
+ monkeypatch.setenv("SWEATSTACK_CLIENT_ID", "app_123")
322
+ assert acts_as(Client(url=URL)) == "user_a"
323
+ assert renewed_by == [("app_123", None)]
324
+
325
+ def test_without_an_environment_app_the_sdk_client_renews(self, renewed_by, environment_pair):
326
+ assert acts_as(Client(url=URL)) == "user_a"
327
+ assert renewed_by == [(OAUTH2_CLIENT_ID, None)]
328
+
329
+ def test_a_secret_without_a_client_id_is_ignored(
330
+ self, renewed_by, environment_pair, monkeypatch
331
+ ):
332
+ monkeypatch.setenv("SWEATSTACK_CLIENT_SECRET", "secret_123")
333
+ assert acts_as(Client(url=URL)) == "user_a"
334
+ assert renewed_by == [(OAUTH2_CLIENT_ID, None)]
335
+
336
+ def test_a_given_app_wins_and_never_takes_the_environment_secret(
337
+ self, renewed_by, environment_pair, environment_app
338
+ ):
339
+ assert acts_as(Client(url=URL, client_id="app_456")) == "user_a"
340
+ assert renewed_by == [("app_456", None)]
341
+
342
+ def test_a_given_secret_wins(self, renewed_by, environment_pair, environment_app):
343
+ assert acts_as(Client(url=URL, client_secret="secret_456")) == "user_a"
344
+ assert renewed_by == [("app_123", "secret_456")]
345
+
346
+ def test_it_never_applies_to_the_saved_pair(self, renewed_by, token_file, environment_app):
347
+ write_token_file(token_file, jwt("user_a", -60), "refresh_a") # from authenticate()
348
+ assert acts_as(Client(url=URL)) == "user_a"
349
+ assert renewed_by == [(OAUTH2_CLIENT_ID, None)]
350
+
351
+ def test_it_never_applies_to_given_tokens(self, renewed_by, environment_app):
352
+ assert acts_as(Client(jwt("user_a", -60), refresh_token="refresh_a", url=URL)) == "user_a"
353
+ assert renewed_by == [(OAUTH2_CLIENT_ID, None)]
354
+
355
+ def test_a_new_sign_in_drops_it(self, renewed_by, environment_pair, environment_app):
356
+ client = Client(url=URL)
357
+ assert acts_as(client) == "user_a"
358
+ # What authenticate(force=True) does: tokens issued to the SDK's own client.
359
+ client.api_key, client.refresh_token = jwt("user_b", -60), "refresh_b"
360
+ assert acts_as(client) == "user_b"
361
+ assert renewed_by == [("app_123", "secret_123"), (OAUTH2_CLIENT_ID, None)]
362
+
363
+ def test_a_new_access_token_keeps_it(self, renewed_by, environment_pair, environment_app):
364
+ client = Client(url=URL)
365
+ assert acts_as(client) == "user_a"
366
+ client.api_key = jwt("user_a", -60) # the refresh token is still the environment's
367
+ assert acts_as(client) == "user_a"
368
+ assert renewed_by == [("app_123", "secret_123")] * 2
369
+
370
+ def test_a_derived_client_keeps_it(self, renewed_by, environment_pair, environment_app):
371
+ derived = Client(url=URL)._with_tokens(
372
+ {"access_token": jwt("user_b", -60), "refresh_token": "refresh_b"}
373
+ )
374
+ assert acts_as(derived) == "user_b"
375
+ assert renewed_by == [("app_123", "secret_123")]
376
+
377
+ def test_the_module_level_client_picks_it_up(
378
+ self, renewed_by, environment_pair, environment_app, monkeypatch
379
+ ):
380
+ import sweatstack
381
+
382
+ client = sweatstack.client._default_client
383
+ monkeypatch.setattr(client, "url", URL)
384
+ for name in ("_api_key", "_refresh_token", "_client_id", "_environment_app"):
385
+ monkeypatch.setattr(client, name, None)
386
+ assert acts_as(client) == "user_a"
387
+ assert renewed_by == [("app_123", "secret_123")]
388
+
389
+ @pytest.mark.parametrize("status", [400, 401])
390
+ def test_a_refusal_says_the_environment_app_renewed(
391
+ self, answer, environment_pair, environment_app, status
392
+ ):
393
+ answer(status)
394
+ with pytest.raises(SweatStackTokenRefreshError, match=r"SWEATSTACK_CLIENT_ID \(app_123\)"):
395
+ _ = Client(url=URL).api_key
396
+
397
+ @pytest.mark.parametrize(
398
+ ("status", "client_id"),
399
+ [(503, None), (400, "app_456")],
400
+ ids=["an outage", "a given app"],
401
+ )
402
+ def test_other_failures_do_not_mention_it(
403
+ self, answer, environment_pair, environment_app, status, client_id
404
+ ):
405
+ answer(status)
406
+ with pytest.raises(SweatStackTokenRefreshError) as raised:
407
+ _ = Client(url=URL, client_id=client_id).api_key
408
+ assert "SWEATSTACK_CLIENT_ID" not in str(raised.value)
@@ -9,8 +9,8 @@ from datetime import date
9
9
  import pandas as pd
10
10
  import pytest
11
11
 
12
- from sweatstack import DailyMeasure, DailyResponse, _frames
13
- from sweatstack.openapi_schemas import DailySource, DailyStatus
12
+ from sweatstack import DailyMeasure, DailyResponse, DailySource, _frames
13
+ from sweatstack.openapi_schemas import DailyStatus, MarkerRowResponse, MaxEffortRowResponse
14
14
 
15
15
  # ---------------------------------------------------------------------------
16
16
  # Fixtures
@@ -127,3 +127,48 @@ class TestSchemaRoundTrip:
127
127
  restored = DailyResponse.model_validate(payload)
128
128
  assert restored.source is None
129
129
  assert restored.status is DailyStatus(status)
130
+
131
+
132
+ @pytest.mark.parametrize(
133
+ "model, row",
134
+ [
135
+ (DailyResponse, {"date": "2026-03-10", "value": 82.5, "status": "stored"}),
136
+ (
137
+ MarkerRowResponse,
138
+ {
139
+ "id": "m1",
140
+ "sport": "cycling",
141
+ "marker": "second_threshold",
142
+ "metric": "power",
143
+ "value": 280.0,
144
+ "date": "2026-03-10",
145
+ "provenance": "declared",
146
+ "pinned": False,
147
+ "application_id": None,
148
+ "activity_id": None,
149
+ },
150
+ ),
151
+ (
152
+ MaxEffortRowResponse,
153
+ {
154
+ "id": "e1",
155
+ "sport": "cycling",
156
+ "metric": "power",
157
+ "duration": 300,
158
+ "distance": None,
159
+ "value": 350.0,
160
+ "date": "2026-03-10",
161
+ "provenance": "measured",
162
+ "title": None,
163
+ "application_id": None,
164
+ "activity_id": None,
165
+ },
166
+ ),
167
+ ],
168
+ ids=["daily", "marker", "max_effort"],
169
+ )
170
+ def test_source_is_an_open_set(model, row):
171
+ # `DailySource` is opened in schemas.py (`_open_enum`): the API may add `source` values.
172
+ assert model.model_validate({**row, "source": "manual"}).source is DailySource.manual
173
+ added_later = model.model_validate({**row, "source": "a_source_added_later"}).source
174
+ assert isinstance(added_later, DailySource) and added_later.value == "a_source_added_later"
@@ -14,6 +14,7 @@ import pandas as pd
14
14
  import polars as pl
15
15
  import pyarrow as pa
16
16
  import pytest
17
+ from polars.testing import assert_frame_equal
17
18
 
18
19
  import sweatstack
19
20
  from sweatstack import _frames
@@ -349,3 +350,24 @@ class TestPropagation:
349
350
  selected = auth.select_activity()
350
351
  assert selected.id == "a1"
351
352
  assert auth.client.activities.list.call_args.kwargs["output"] == "models"
353
+
354
+
355
+ # ---------------------------------------------------------------------------
356
+ # Polars in the browser
357
+ # ---------------------------------------------------------------------------
358
+
359
+
360
+ @pytest.mark.parametrize("content", [_mean_max_with_index(), _mean_max_without_index()])
361
+ def test_polars_in_pyodide_reads_through_arrow_with_the_same_result(monkeypatch, content):
362
+ """Pyodide's Polars has no Parquet reader; the pyarrow route must give the same frame."""
363
+ expected = _frames.parquet_to_polars(content)
364
+
365
+ def no_parquet_reader(*args, **kwargs):
366
+ raise AttributeError(
367
+ "type object 'builtins.PyLazyFrame' has no attribute 'new_from_parquet'"
368
+ )
369
+
370
+ monkeypatch.setattr(pl, "read_parquet", no_parquet_reader)
371
+ monkeypatch.setattr(pl, "read_parquet_metadata", no_parquet_reader)
372
+ monkeypatch.setattr(_frames.sys, "platform", "emscripten")
373
+ assert_frame_equal(_frames.parquet_to_polars(content), expected)
@@ -2356,7 +2356,7 @@ wheels = [
2356
2356
 
2357
2357
  [[package]]
2358
2358
  name = "sweatstack"
2359
- version = "0.97.0"
2359
+ version = "0.98.0"
2360
2360
  source = { editable = "." }
2361
2361
  dependencies = [
2362
2362
  { name = "email-validator" },
@@ -2380,6 +2380,7 @@ pandas = [
2380
2380
  ]
2381
2381
  polars = [
2382
2382
  { name = "polars" },
2383
+ { name = "pyarrow", marker = "sys_platform == 'emscripten'" },
2383
2384
  ]
2384
2385
  streamlit = [
2385
2386
  { name = "pandas" },
@@ -2415,6 +2416,7 @@ requires-dist = [
2415
2416
  { name = "pandas", marker = "extra == 'pandas'", specifier = ">=2.2.3" },
2416
2417
  { name = "platformdirs", specifier = ">=4.0.0" },
2417
2418
  { name = "polars", marker = "extra == 'polars'", specifier = ">=1.30" },
2419
+ { name = "pyarrow", marker = "sys_platform == 'emscripten' and extra == 'polars'", specifier = ">=20.0.0" },
2418
2420
  { name = "pyarrow", marker = "extra == 'arrow'", specifier = ">=20.0.0" },
2419
2421
  { name = "pydantic", specifier = ">=2.10.5" },
2420
2422
  { name = "streamlit", marker = "extra == 'streamlit'", specifier = ">=1.42.0" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes