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.
- {sweatstack-0.97.0 → sweatstack-0.98.0}/AGENTS.md +1 -1
- {sweatstack-0.97.0 → sweatstack-0.98.0}/CHANGELOG.md +32 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/PKG-INFO +2 -1
- {sweatstack-0.97.0 → sweatstack-0.98.0}/pyproject.toml +3 -1
- {sweatstack-0.97.0 → sweatstack-0.98.0}/skills/sweatstack-python/SKILL.md +2 -1
- {sweatstack-0.97.0 → sweatstack-0.98.0}/skills/sweatstack-python/api.md +2 -1
- {sweatstack-0.97.0 → sweatstack-0.98.0}/skills/sweatstack-python/data-models.md +2 -1
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/_frames.py +12 -4
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/client.py +87 -14
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/schemas.py +15 -6
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_credentials.py +148 -1
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_dailies.py +47 -2
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_output.py +22 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/uv.lock +3 -1
- {sweatstack-0.97.0 → sweatstack-0.98.0}/.git-blame-ignore-revs +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/.github/workflows/ci.yml +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/.gitignore +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/.gitleaks.toml +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/.python-version +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/CONTRIBUTING.md +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/DEVELOPMENT.md +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/LICENSE +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/Makefile +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/README.md +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/UPGRADING.md +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/evals/__init__.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/evals/agent/__init__.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/evals/agent/agent.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/evals/agent/cases.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/evals/agent/evaluators.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/evals/agent/harness.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/evals/agent/lifecycle.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/evals/agent/run.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/examples/fastapi_webhooks_example.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/examples/send_webhook.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/001a_tests.md +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/001b_metadata.md +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/001c_dailies.md +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/003_trace_test_linking.md +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/004_codebase_hygiene.md +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/005_ost_sport_bridge.md +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/006_output_backends.md +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/007_account_status_userinfo_portal.md +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/008_sdk_documentation.md +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/009_road_to_1_0.md +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/010_credentials_one_source.md +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/plans/011_user_agent_on_every_request.md +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/skills/sweatstack-python/fastapi.md +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/skills/sweatstack-python/streamlit.md +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/__init__.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/_docs.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/_renames.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/_transport.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/cli.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/constants.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/exceptions.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/__init__.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/config.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/dependencies.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/models.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/routes.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/session.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/token_stores.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/webhooks.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/openapi_schemas.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/py.typed +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/__init__.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/_app_metadata.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/_base.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/activities.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/dailies.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/oauth.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/portal.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/profile.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/teams.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/tests.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/traces.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/resources/users.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/streamlit.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/src/sweatstack/utils.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/__init__.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/conftest.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_access_token_cache.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_account_status.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_authorization_url.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_changelog.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_docs_check.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_docstrings.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_dtype_conversion.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_exceptions.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_frames.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_identity.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_longitudinal_mean_max_after.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_metadata.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_portal.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_public_hygiene.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_public_surface.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_renames.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_resources.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_segmentation.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_skill.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_sport_ost.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_streamlit_auth.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_teams.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_tests.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_trace_test_linking.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_transport.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_user_agent.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_webhooks.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_wire_names.py +0 -0
- {sweatstack-0.97.0 → sweatstack-0.98.0}/tests/test_write_timezone_validation.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
224
|
-
client_secret:
|
|
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.
|
|
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":
|
|
407
|
-
"client_secret":
|
|
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
|
-
|
|
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
|
|
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.
|
|
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=
|
|
742
|
-
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,
|
|
60
|
-
codes, capabilities and Portal destinations appear without notice). An unknown
|
|
61
|
-
becomes a pseudo-member with ``.value`` and ``.name`` set to the string, cached so
|
|
62
|
-
lookups return the same object, instead of a validation error that would break the
|
|
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 (
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|