sweatstack 0.80.0__tar.gz → 0.82.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.80.0 → sweatstack-0.82.0}/.claude/settings.local.json +5 -1
- {sweatstack-0.80.0 → sweatstack-0.82.0}/AGENTS.md +6 -2
- {sweatstack-0.80.0 → sweatstack-0.82.0}/CHANGELOG.md +66 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/PKG-INFO +6 -1
- sweatstack-0.82.0/README.md +9 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/docs/everything.rst +7 -2
- {sweatstack-0.80.0 → sweatstack-0.82.0}/pyproject.toml +2 -1
- sweatstack-0.82.0/src/sweatstack/cli.py +48 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/client.py +38 -11
- {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/openapi_schemas.py +1 -48
- sweatstack-0.82.0/src/sweatstack/schemas.py +94 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/streamlit.py +7 -7
- sweatstack-0.82.0/tests/test_longitudinal_mean_max_after.py +80 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/tests/test_public_surface.py +1 -0
- sweatstack-0.82.0/tests/test_sport_ost.py +91 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/tests/test_tests.py +5 -5
- {sweatstack-0.80.0 → sweatstack-0.82.0}/uv.lock +17 -1
- sweatstack-0.80.0/README.md +0 -5
- sweatstack-0.80.0/src/sweatstack/cli.py +0 -24
- sweatstack-0.80.0/src/sweatstack/schemas.py +0 -287
- sweatstack-0.80.0/tests/test_sport_ost_compat.py +0 -123
- {sweatstack-0.80.0 → sweatstack-0.82.0}/.claude/skills/sweatstack-python/SKILL.md +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/.claude/skills/sweatstack-python/client.md +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/.claude/skills/sweatstack-python/data-models.md +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/.claude/skills/sweatstack-python/fastapi.md +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/.claude/skills/sweatstack-python/streamlit.md +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/.gitignore +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/.python-version +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/CONTRIBUTING.md +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/DEVELOPMENT.md +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/LICENSE +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/Makefile +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/docs/conf.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/docs/index.rst +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/examples/fastapi_webhooks_example.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/examples/send_webhook.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/plans/001a_tests.md +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/plans/001b_metadata.md +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/plans/001c_dailies.md +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/plans/003_trace_test_linking.md +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/plans/004_codebase_hygiene.md +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/plans/005_ost_sport_bridge.md +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/__init__.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/constants.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/exceptions.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/fastapi/__init__.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/fastapi/config.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/fastapi/dependencies.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/fastapi/models.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/fastapi/routes.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/fastapi/session.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/fastapi/token_stores.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/fastapi/webhooks.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/ipython_init.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/py.typed +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/sweatshell.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/utils.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/tests/__init__.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/tests/test_access_token_cache.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/tests/test_dailies.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/tests/test_dtype_conversion.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/tests/test_exceptions.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/tests/test_metadata.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/tests/test_teams.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/tests/test_trace_test_linking.py +0 -0
- {sweatstack-0.80.0 → sweatstack-0.82.0}/tests/test_webhooks.py +0 -0
|
@@ -27,7 +27,11 @@
|
|
|
27
27
|
"WebFetch(domain:pypi.org)",
|
|
28
28
|
"WebFetch(domain:github.com)",
|
|
29
29
|
"WebFetch(domain:raw.githubusercontent.com)",
|
|
30
|
-
"Bash(uv add *)"
|
|
30
|
+
"Bash(uv add *)",
|
|
31
|
+
"Bash(sed -n '42,50p' src/sweatstack/client.py)",
|
|
32
|
+
"Bash(sed -n '1017,1022p' src/sweatstack/client.py)",
|
|
33
|
+
"Bash(sed -i '' 's/sport=cycling\\\\.road&sport=cycling\\\\.tt/sport=cycling.road\\\\&sport=cycling.time_trial/' docs-dev/changelog.md)",
|
|
34
|
+
"Bash(sed -n '25p' docs-dev/changelog.md)"
|
|
31
35
|
],
|
|
32
36
|
"deny": []
|
|
33
37
|
}
|
|
@@ -16,7 +16,7 @@ you add or change a surface, the default is to mirror the server:
|
|
|
16
16
|
| `POST /api/v1/traces/` | `client.create_trace(...)` |
|
|
17
17
|
| `PUT /api/v1/traces/{id}` (full-replace)| `client.update_trace(trace_id, ...)` |
|
|
18
18
|
| `DELETE /api/v1/dailies/{id}` | `client.delete_daily(daily_id)` |
|
|
19
|
-
| query param `?sport=cycling&sport=running` | `sports=[Sport
|
|
19
|
+
| query param `?sport=cycling&sport=running` | `sports=[Sport("cycling"), Sport("running")]` |
|
|
20
20
|
| JSON body field `test_id` | kwarg `test_id` |
|
|
21
21
|
| OpenAPI enum value `"auto"` | `TraceResolution.auto` |
|
|
22
22
|
|
|
@@ -44,7 +44,7 @@ and pagination shape. Don't redesign the API in Python.
|
|
|
44
44
|
```
|
|
45
45
|
src/sweatstack/
|
|
46
46
|
├── openapi_schemas.py # AUTO-GENERATED. Never hand-edit.
|
|
47
|
-
├── schemas.py # Re-exports
|
|
47
|
+
├── schemas.py # Re-exports (incl. OST Sport/Modifier) + Metric/Scope/DailyMeasure helpers.
|
|
48
48
|
├── exceptions.py # Public error contract. No httpx types leak.
|
|
49
49
|
├── client.py # Single Client class + module-level singletons.
|
|
50
50
|
├── utils.py # Dataframe / JWT helpers.
|
|
@@ -72,6 +72,10 @@ Skipping step 3 silently breaks the public surface. Same for new enums.
|
|
|
72
72
|
bottom of `client.py`. Forgetting is silent.
|
|
73
73
|
- **Enum-typed params accept `Enum | str`** and route through
|
|
74
74
|
`_enums_to_strings`. Don't introduce strict-enum-only parameters.
|
|
75
|
+
- **`Sport` is the OpenSportTaxonomy type** (`open_sport_taxonomy.Sport`), not a generated enum.
|
|
76
|
+
Construct with `Sport("cycling.road")` or `Sport.parse(value)`; serialise with `str(sport)`. Codegen
|
|
77
|
+
binds the `sport` field to OST's permissive `SportField` (cli.py `_bind_sport_to_ost`), so regen is
|
|
78
|
+
safe and `sport`-typed fields keep decoding to `Sport`.
|
|
75
79
|
- **Tests are offline.** No network calls. Use `Client.__new__(Client)` to
|
|
76
80
|
bypass init when you need an instance for a helper method.
|
|
77
81
|
- **`update_*` methods are full-replace.** Document the silent-clear
|
|
@@ -5,6 +5,72 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
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
|
+
## [0.82.0] - 2026-06-16
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `get_longitudinal_mean_max(after=...)`: fatigue-state mean-max. Pass one or more thresholds (kJ of work for `power`; metres of distance for `speed`, experimental) to get, per state, the mean-max over the portion of each ride after that threshold. The returned DataFrame stays metric-indexed with an added `after` column. Max 5 states; the date range is capped at 1 year when `after` is used.
|
|
13
|
+
|
|
14
|
+
## [0.81.0] - 2026-06-16
|
|
15
|
+
|
|
16
|
+
The SweatStack API has fully adopted [OpenSportTaxonomy](https://github.com/SweatStack/open-sport-taxonomy)
|
|
17
|
+
(OST), and so has this client. `sweatstack.Sport` is now the OST `Sport` type instead of a bespoke enum.
|
|
18
|
+
This is a **breaking change** for code that uses `Sport`.
|
|
19
|
+
|
|
20
|
+
### Changed (breaking)
|
|
21
|
+
- `sweatstack.Sport` is now `open_sport_taxonomy.Sport` — a rich type (`.code`, `.label`, `.modifiers`,
|
|
22
|
+
`.parent`, `.is_subsport_of()`, `.resolve()`, `Sport.parse()`, `Sport.all()`) rather than a string
|
|
23
|
+
enum. There are no `Sport.cycling_road`-style members; construct a known sport with
|
|
24
|
+
`Sport("cycling.road")` or parse external input with `Sport.parse(value)`. The bespoke helpers
|
|
25
|
+
(`root_sport()`, `parent_sport()`, `is_sub_sport_of()`, `is_root_sport()`, `display_name()`) are
|
|
26
|
+
removed in favour of OST's native API.
|
|
27
|
+
- Sport values are now OST values, e.g. `cycling.trainer` → `cycling+stationary`, `cycling.tt` →
|
|
28
|
+
`cycling.time_trial`, `cross_country_skiing` → `xc_skiing`, `unknown` → `generic`. Response data,
|
|
29
|
+
longitudinal DataFrames and `get_sports()` all return OST values; requests send OST values.
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
- `sweatstack.Modifier` (re-exported from OpenSportTaxonomy) for inspecting sport modifiers. (For typed
|
|
33
|
+
sport annotations, `StandardSport` is available from `open_sport_taxonomy` directly.)
|
|
34
|
+
- `open-sport-taxonomy[pydantic]` is now a runtime dependency. Response models consume `sport` via OST's
|
|
35
|
+
permissive `SportField`, so sports newer than the bundled taxonomy are preserved rather than rejected.
|
|
36
|
+
|
|
37
|
+
### Migration
|
|
38
|
+
Hand the following prompt to a coding agent, or apply it by hand:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
Migrate this codebase to sweatstack 0.81.0, which replaces its custom `Sport` enum with the
|
|
42
|
+
OpenSportTaxonomy type (`open_sport_taxonomy.Sport`). `from sweatstack import Sport` is now that type.
|
|
43
|
+
|
|
44
|
+
1. Construction (there are no enum members like `Sport.cycling_road`):
|
|
45
|
+
- Known sport in app code -> `Sport("cycling.road")` (raises on an unknown code/modifier).
|
|
46
|
+
- From an API/string value -> `Sport.parse(value)` (permissive; never raises; preserves unknown).
|
|
47
|
+
- For typed annotations/autocomplete of the standard catalogue ->
|
|
48
|
+
`from open_sport_taxonomy import StandardSport` (a Literal).
|
|
49
|
+
|
|
50
|
+
2. These sport VALUES changed; update hardcoded strings or members:
|
|
51
|
+
cycling.trainer->cycling+stationary, running.treadmill->running+stationary,
|
|
52
|
+
rowing.ergometer->rowing+stationary, cycling.tt->cycling.time_trial,
|
|
53
|
+
cycling.mountainbike->cycling.mountain, cross_country_skiing[.classic|.skate]->xc_skiing[...],
|
|
54
|
+
unknown->generic. ("stationary" etc. are now modifiers, appended with `+`; see sport.modifiers.)
|
|
55
|
+
|
|
56
|
+
3. Methods / attributes:
|
|
57
|
+
Sport.cycling_road -> Sport("cycling.road")
|
|
58
|
+
sport.value -> str(sport) (canonical, incl. modifiers) or sport.code
|
|
59
|
+
sport.display_name() -> sport.label
|
|
60
|
+
sport.parent_sport() -> sport.parent
|
|
61
|
+
sport.is_sub_sport_of(x) -> sport.is_subsport_of(x) (x is a single Sport; for a list use
|
|
62
|
+
any(sport.is_subsport_of(s) for s in xs))
|
|
63
|
+
sport.root_sport() -> Sport(sport.code.split(".")[0])
|
|
64
|
+
sport.is_root_sport() -> ("." not in sport.code and not sport.modifiers)
|
|
65
|
+
for s in Sport: ... -> for s in Sport.all(): ...
|
|
66
|
+
|
|
67
|
+
4. Equality works: `activity.sport == Sport("cycling+stationary")`. Compare against the NEW value.
|
|
68
|
+
|
|
69
|
+
After editing, run the test suite and fix any remaining references. Do not add a compatibility shim;
|
|
70
|
+
migrate call sites to the OST API directly.
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
|
|
8
74
|
## [0.80.0] - 2026-06-12
|
|
9
75
|
|
|
10
76
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sweatstack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.82.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/getting-started/
|
|
@@ -23,6 +23,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
23
23
|
Requires-Python: >=3.10
|
|
24
24
|
Requires-Dist: email-validator>=2.2.0
|
|
25
25
|
Requires-Dist: httpx>=0.28.1
|
|
26
|
+
Requires-Dist: open-sport-taxonomy[pydantic]>=0.10.0
|
|
26
27
|
Requires-Dist: pandas>=2.2.3
|
|
27
28
|
Requires-Dist: platformdirs>=4.0.0
|
|
28
29
|
Requires-Dist: pyarrow>=18.0.0
|
|
@@ -43,3 +44,7 @@ Description-Content-Type: text/markdown
|
|
|
43
44
|
This is the official Python client library for SweatStack.
|
|
44
45
|
|
|
45
46
|
Documentation can be found [here](https://docs.sweatstack.no/getting-started/).
|
|
47
|
+
|
|
48
|
+
Sports follow [OpenSportTaxonomy](https://open-sport-taxonomy.sweatstack.no): `sweatstack.Sport` is the
|
|
49
|
+
OST `Sport` type. See the [OpenSportTaxonomy Python guide](https://github.com/SweatStack/open-sport-taxonomy/blob/main/python/README.md)
|
|
50
|
+
for the full `Sport` API.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# SweatStack Python client library
|
|
2
|
+
|
|
3
|
+
This is the official Python client library for SweatStack.
|
|
4
|
+
|
|
5
|
+
Documentation can be found [here](https://docs.sweatstack.no/getting-started/).
|
|
6
|
+
|
|
7
|
+
Sports follow [OpenSportTaxonomy](https://open-sport-taxonomy.sweatstack.no): `sweatstack.Sport` is the
|
|
8
|
+
OST `Sport` type. See the [OpenSportTaxonomy Python guide](https://github.com/SweatStack/open-sport-taxonomy/blob/main/python/README.md)
|
|
9
|
+
for the full `Sport` API.
|
|
@@ -151,8 +151,13 @@ sweatstack.schemas
|
|
|
151
151
|
Sport
|
|
152
152
|
~~~~~
|
|
153
153
|
|
|
154
|
-
|
|
155
|
-
|
|
154
|
+
``sweatstack.Sport`` is the `OpenSportTaxonomy <https://github.com/SweatStack/open-sport-taxonomy>`_
|
|
155
|
+
``Sport`` type. Construct a known sport with ``Sport("cycling.road")`` and parse external/API input
|
|
156
|
+
with ``Sport.parse(value)``. See the OpenSportTaxonomy documentation for the full API (``code``,
|
|
157
|
+
``label``, ``modifiers``, ``parent``, ``disciplines``, ``is_subsport_of``, ``resolve``, ``all``).
|
|
158
|
+
|
|
159
|
+
.. autoclass:: sweatstack.Sport
|
|
160
|
+
:members: parse, resolve, is_subsport_of, all
|
|
156
161
|
:undoc-members:
|
|
157
162
|
|
|
158
163
|
Metric
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sweatstack"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.82.0"
|
|
4
4
|
description = "The official Python client for SweatStack"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -23,6 +23,7 @@ classifiers = [
|
|
|
23
23
|
dependencies = [
|
|
24
24
|
"email-validator>=2.2.0",
|
|
25
25
|
"httpx>=0.28.1",
|
|
26
|
+
"open-sport-taxonomy[pydantic]>=0.10.0",
|
|
26
27
|
"pandas>=2.2.3",
|
|
27
28
|
"platformdirs>=4.0.0",
|
|
28
29
|
"pyarrow>=18.0.0",
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from datamodel_code_generator import InputFileType, generate
|
|
7
|
+
from datamodel_code_generator import DataModelType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _bind_sport_to_ost(path: Path) -> None:
|
|
11
|
+
"""Replace the codegen'd ``Sport`` enum with OpenSportTaxonomy's pydantic field.
|
|
12
|
+
|
|
13
|
+
The API speaks OpenSportTaxonomy, so ``sport`` fields are consumed via OST's permissive
|
|
14
|
+
``SportField``: it validates inbound values to an ``open_sport_taxonomy.Sport`` and serialises
|
|
15
|
+
back to the canonical wire string, tolerating sports newer than the bundled taxonomy. Anchored on
|
|
16
|
+
the AST (not a text match) so it survives regeneration; the import is injected where the class was,
|
|
17
|
+
safely past the module's ``from __future__`` header.
|
|
18
|
+
"""
|
|
19
|
+
src = path.read_text()
|
|
20
|
+
cls = next(
|
|
21
|
+
node for node in ast.parse(src).body
|
|
22
|
+
if isinstance(node, ast.ClassDef) and node.name == "Sport" # the enum carries no decorators
|
|
23
|
+
)
|
|
24
|
+
lines = src.splitlines(keepends=True)
|
|
25
|
+
lines[cls.lineno - 1:cls.end_lineno] = [
|
|
26
|
+
"from open_sport_taxonomy.pydantic import SportField as Sport # OST sport type (see schemas.py)\n",
|
|
27
|
+
]
|
|
28
|
+
path.write_text("".join(lines))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def generate_response_models():
|
|
32
|
+
response = httpx.get("http://localhost:8080/openapi.json")
|
|
33
|
+
response.raise_for_status()
|
|
34
|
+
output_directory = Path(__file__).parent
|
|
35
|
+
output = Path(output_directory / "openapi_schemas.py")
|
|
36
|
+
output.unlink(missing_ok=True)
|
|
37
|
+
generate(
|
|
38
|
+
response.text,
|
|
39
|
+
input_file_type=InputFileType.OpenAPI,
|
|
40
|
+
input_filename="openapi.json",
|
|
41
|
+
output=output,
|
|
42
|
+
# set up the output model types
|
|
43
|
+
output_model_type=DataModelType.PydanticV2BaseModel,
|
|
44
|
+
)
|
|
45
|
+
_bind_sport_to_ost(output)
|
|
46
|
+
|
|
47
|
+
model = output.read_text()
|
|
48
|
+
print(model)
|
|
@@ -42,11 +42,10 @@ from .exceptions import (
|
|
|
42
42
|
from .schemas import (
|
|
43
43
|
ActivityDetails, ActivitySummary, ApplicationMemberRole, AuthorizedTeamResponse,
|
|
44
44
|
BackfillStatus, DailyMeasure, DailyResponse,
|
|
45
|
-
Marker, Metric, Scope, Sport,
|
|
45
|
+
Marker, Metric, Modifier, Scope, Sport,
|
|
46
46
|
TeamResponse, TestDetails, TestResults, TestSummary, TokenResponse, TraceDetails,
|
|
47
47
|
TraceResolution, UserInfoResponse, UserResponse, UserSummary
|
|
48
48
|
)
|
|
49
|
-
from .schemas import _sport_to_wire
|
|
50
49
|
from .utils import convert_to_standard_dtypes, decode_jwt_body, make_dataframe_streamlit_compatible
|
|
51
50
|
|
|
52
51
|
logger = logging.getLogger(__name__)
|
|
@@ -1016,9 +1015,15 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
1016
1015
|
return text if text else None
|
|
1017
1016
|
|
|
1018
1017
|
def _enums_to_strings(self, values: list[Enum | str]) -> list[str]:
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1018
|
+
out = []
|
|
1019
|
+
for value in values:
|
|
1020
|
+
if isinstance(value, Sport):
|
|
1021
|
+
out.append(str(value)) # OST Sport -> canonical wire string
|
|
1022
|
+
elif isinstance(value, Enum):
|
|
1023
|
+
out.append(value.value)
|
|
1024
|
+
else:
|
|
1025
|
+
out.append(value)
|
|
1026
|
+
return out
|
|
1022
1027
|
|
|
1023
1028
|
def _get_activities_generator(
|
|
1024
1029
|
self,
|
|
@@ -1459,6 +1464,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
1459
1464
|
end: date | str | None = None,
|
|
1460
1465
|
date: date | str | None = None,
|
|
1461
1466
|
window_days: int | None = None,
|
|
1467
|
+
after: list[float] | float | None = None,
|
|
1462
1468
|
) -> pd.DataFrame:
|
|
1463
1469
|
"""Gets the mean-max curve for one or more sports and a metric.
|
|
1464
1470
|
|
|
@@ -1470,9 +1476,17 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
1470
1476
|
end: End of the date range (defaults to today).
|
|
1471
1477
|
date: Deprecated since 0.70.0. Use ``start`` and ``end`` instead.
|
|
1472
1478
|
window_days: Deprecated since 0.70.0. Use ``start`` and ``end`` instead.
|
|
1479
|
+
after: One or more fatigue states. For each value, the mean-max is computed
|
|
1480
|
+
over the portion of each ride after that much accumulated work (kJ, for
|
|
1481
|
+
``power``) or distance (metres, for ``speed``; experimental), then
|
|
1482
|
+
enveloped across rides. The returned DataFrame then has an ``after``
|
|
1483
|
+
column (one curve per value). Max 5 values; the date range is capped at
|
|
1484
|
+
1 year when ``after`` is used.
|
|
1473
1485
|
|
|
1474
1486
|
Returns:
|
|
1475
|
-
pd.DataFrame: A pandas DataFrame containing the mean-max curve data
|
|
1487
|
+
pd.DataFrame: A pandas DataFrame containing the mean-max curve data, indexed
|
|
1488
|
+
by the metric value. With ``after``, an ``after`` column distinguishes
|
|
1489
|
+
the fatigue states.
|
|
1476
1490
|
|
|
1477
1491
|
Raises:
|
|
1478
1492
|
ValueError: If both ``sport`` and ``sports`` are provided, or neither is provided.
|
|
@@ -1510,12 +1524,14 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
1510
1524
|
params["date"] = date
|
|
1511
1525
|
if window_days is not None:
|
|
1512
1526
|
params["window_days"] = window_days
|
|
1527
|
+
if after is not None:
|
|
1528
|
+
params["after"] = [after] if isinstance(after, (int, float)) else after
|
|
1513
1529
|
|
|
1514
1530
|
if self._cache_enabled():
|
|
1515
1531
|
cache_key = self._generate_cache_key("mean_max", **params)
|
|
1516
1532
|
cached = self._read_cache("mean_max", cache_key)
|
|
1517
1533
|
if cached is not None:
|
|
1518
|
-
return self.
|
|
1534
|
+
return self._shape_mean_max(pd.read_parquet(BytesIO(cached)), metric, after)
|
|
1519
1535
|
|
|
1520
1536
|
with self._http_client() as client:
|
|
1521
1537
|
response = client.get(
|
|
@@ -1527,8 +1543,16 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
1527
1543
|
if self._cache_enabled():
|
|
1528
1544
|
self._write_cache("mean_max", cache_key, response.content)
|
|
1529
1545
|
|
|
1530
|
-
|
|
1531
|
-
|
|
1546
|
+
return self._shape_mean_max(pd.read_parquet(BytesIO(response.content)), metric, after)
|
|
1547
|
+
|
|
1548
|
+
def _shape_mean_max(self, df: pd.DataFrame, metric: str, after) -> pd.DataFrame:
|
|
1549
|
+
"""Standard post-processing for mean-max responses. The ``after`` response is
|
|
1550
|
+
index-free on the wire; restore the metric-value index so its shape matches the
|
|
1551
|
+
no-``after`` curve (with an extra ``after`` column)."""
|
|
1552
|
+
df = self._postprocess_dataframe(df)
|
|
1553
|
+
if after is not None:
|
|
1554
|
+
df = df.set_index(metric)
|
|
1555
|
+
return df
|
|
1532
1556
|
|
|
1533
1557
|
def get_longitudinal_awd(
|
|
1534
1558
|
self,
|
|
@@ -2380,7 +2404,9 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
2380
2404
|
params={"only_root": only_root},
|
|
2381
2405
|
)
|
|
2382
2406
|
self._raise_for_status(response)
|
|
2383
|
-
|
|
2407
|
+
# Sport.parse: the recommended OST way to ingest external input (tolerates sports newer
|
|
2408
|
+
# than the bundled taxonomy).
|
|
2409
|
+
return [Sport.parse(sport) for sport in response.json()]
|
|
2384
2410
|
|
|
2385
2411
|
def get_tags(self) -> list[str]:
|
|
2386
2412
|
"""Gets a list of all tags used by the user.
|
|
@@ -2584,7 +2610,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
2584
2610
|
|
|
2585
2611
|
data = {}
|
|
2586
2612
|
if sport is not None:
|
|
2587
|
-
data["sport"] =
|
|
2613
|
+
data["sport"] = self._enums_to_strings([sport])[0]
|
|
2588
2614
|
|
|
2589
2615
|
with self._http_client() as client:
|
|
2590
2616
|
response = client.post(
|
|
@@ -2764,6 +2790,7 @@ __all__ = sorted([
|
|
|
2764
2790
|
"DailyResponse",
|
|
2765
2791
|
"Marker",
|
|
2766
2792
|
"Metric",
|
|
2793
|
+
"Modifier",
|
|
2767
2794
|
"Scope",
|
|
2768
2795
|
"Sport",
|
|
2769
2796
|
"TeamResponse",
|
|
@@ -985,54 +985,7 @@ class SpeedSummary(BaseModel):
|
|
|
985
985
|
max: float | None = Field(None, title='Max')
|
|
986
986
|
|
|
987
987
|
|
|
988
|
-
|
|
989
|
-
cycling = 'cycling'
|
|
990
|
-
cycling_road = 'cycling.road'
|
|
991
|
-
cycling_tt = 'cycling.tt'
|
|
992
|
-
cycling_cyclocross = 'cycling.cyclocross'
|
|
993
|
-
cycling_gravel = 'cycling.gravel'
|
|
994
|
-
cycling_mountainbike = 'cycling.mountainbike'
|
|
995
|
-
cycling_track = 'cycling.track'
|
|
996
|
-
cycling_track_250m = 'cycling.track.250m'
|
|
997
|
-
cycling_track_333m = 'cycling.track.333m'
|
|
998
|
-
cycling_trainer = 'cycling.trainer'
|
|
999
|
-
running = 'running'
|
|
1000
|
-
running_road = 'running.road'
|
|
1001
|
-
running_track = 'running.track'
|
|
1002
|
-
running_track_200m = 'running.track.200m'
|
|
1003
|
-
running_track_400m = 'running.track.400m'
|
|
1004
|
-
running_trail = 'running.trail'
|
|
1005
|
-
running_treadmill = 'running.treadmill'
|
|
1006
|
-
walking = 'walking'
|
|
1007
|
-
walking_hiking = 'walking.hiking'
|
|
1008
|
-
cross_country_skiing = 'cross_country_skiing'
|
|
1009
|
-
cross_country_skiing_classic = 'cross_country_skiing.classic'
|
|
1010
|
-
cross_country_skiing_skate = 'cross_country_skiing.skate'
|
|
1011
|
-
cross_country_skiing_backcountry = 'cross_country_skiing.backcountry'
|
|
1012
|
-
cross_country_skiing_ergometer = 'cross_country_skiing.ergometer'
|
|
1013
|
-
cross_country_skiing_roller_skiing = 'cross_country_skiing.roller_skiing'
|
|
1014
|
-
cross_country_skiing_roller_skiing_classic = (
|
|
1015
|
-
'cross_country_skiing.roller_skiing.classic'
|
|
1016
|
-
)
|
|
1017
|
-
cross_country_skiing_roller_skiing_skate = (
|
|
1018
|
-
'cross_country_skiing.roller_skiing.skate'
|
|
1019
|
-
)
|
|
1020
|
-
rowing = 'rowing'
|
|
1021
|
-
rowing_ergometer = 'rowing.ergometer'
|
|
1022
|
-
rowing_indoor = 'rowing.indoor'
|
|
1023
|
-
rowing_regatta = 'rowing.regatta'
|
|
1024
|
-
rowing_fixed_seat = 'rowing.fixed-seat'
|
|
1025
|
-
rowing_coastal = 'rowing.coastal'
|
|
1026
|
-
swimming = 'swimming'
|
|
1027
|
-
swimming_pool = 'swimming.pool'
|
|
1028
|
-
swimming_pool_50m = 'swimming.pool.50m'
|
|
1029
|
-
swimming_pool_25m = 'swimming.pool.25m'
|
|
1030
|
-
swimming_pool_25y = 'swimming.pool.25y'
|
|
1031
|
-
swimming_pool_33m = 'swimming.pool.33m'
|
|
1032
|
-
swimming_open_water = 'swimming.open_water'
|
|
1033
|
-
swimming_flume = 'swimming.flume'
|
|
1034
|
-
generic = 'generic'
|
|
1035
|
-
unknown = 'unknown'
|
|
988
|
+
from open_sport_taxonomy.pydantic import SportField as Sport # OST sport type (see schemas.py)
|
|
1036
989
|
|
|
1037
990
|
|
|
1038
991
|
class SubscriptionPlan(Enum):
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""SweatStack data schemas.
|
|
2
|
+
|
|
3
|
+
Re-exports the Pydantic response models from :mod:`openapi_schemas`, the public sport types from
|
|
4
|
+
OpenSportTaxonomy (``Sport`` and ``Modifier``), and extends the ``Metric``, ``Scope`` and
|
|
5
|
+
``DailyMeasure`` enums with convenience methods.
|
|
6
|
+
|
|
7
|
+
Sport values follow OpenSportTaxonomy: construct a known sport with ``Sport("cycling.road")`` and parse
|
|
8
|
+
external/API input with ``Sport.parse(value)``. See https://github.com/SweatStack/open-sport-taxonomy.
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
from sweatstack import Sport
|
|
12
|
+
sport = Sport("cycling.road")
|
|
13
|
+
print(sport.label) # "road cycling"
|
|
14
|
+
print(sport.parent) # Sport("cycling")
|
|
15
|
+
print(sport.is_subsport_of(Sport("cycling"))) # True
|
|
16
|
+
"""
|
|
17
|
+
from open_sport_taxonomy import Modifier, Sport
|
|
18
|
+
|
|
19
|
+
from .openapi_schemas import (
|
|
20
|
+
ActivityDetails, ActivitySummary, ApplicationMemberRole, AuthorizedTeamResponse,
|
|
21
|
+
BackfillStatus, DailyMeasure, DailyResponse,
|
|
22
|
+
Marker, Metric, Scope,
|
|
23
|
+
TeamResponse, TestDetails, TestResults, TestSummary, TokenResponse, TraceDetails,
|
|
24
|
+
TraceResolution, UserInfoResponse, UserResponse, UserSummary
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _metric_display_name(metric: Metric) -> str:
|
|
29
|
+
"""Returns a human-readable display name for a metric.
|
|
30
|
+
|
|
31
|
+
This function converts a Metric enum value into a formatted string suitable for display.
|
|
32
|
+
"""
|
|
33
|
+
return metric.value.replace("_", " ")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def _metric_missing(cls, value: str):
|
|
38
|
+
"""Handle unknown metric values from newer API versions.
|
|
39
|
+
|
|
40
|
+
This allows the client to gracefully handle new metrics added to the API
|
|
41
|
+
without requiring a client library update. Unknown values become dynamic
|
|
42
|
+
enum members that behave like regular Metric values.
|
|
43
|
+
"""
|
|
44
|
+
pseudo_member = object.__new__(cls)
|
|
45
|
+
pseudo_member._name_ = value
|
|
46
|
+
pseudo_member._value_ = value
|
|
47
|
+
cls._value2member_map_[value] = pseudo_member # Cache for future lookups
|
|
48
|
+
return pseudo_member
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
Metric._missing_ = _metric_missing
|
|
52
|
+
Metric.display_name = _metric_display_name
|
|
53
|
+
Metric.display_name.__doc__ = _metric_display_name.__doc__
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def _scope_missing(cls, value: str):
|
|
58
|
+
"""Handle unknown scope values from newer API versions."""
|
|
59
|
+
pseudo_member = object.__new__(cls)
|
|
60
|
+
pseudo_member._name_ = value
|
|
61
|
+
pseudo_member._value_ = value
|
|
62
|
+
cls._value2member_map_[value] = pseudo_member
|
|
63
|
+
return pseudo_member
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
Scope._missing_ = _scope_missing
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _daily_measure_display_name(measure: DailyMeasure) -> str:
|
|
70
|
+
"""Returns a human-readable display name for a daily measure.
|
|
71
|
+
|
|
72
|
+
This function converts a DailyMeasure enum value into a formatted string suitable for display.
|
|
73
|
+
"""
|
|
74
|
+
return measure.value.replace("_", " ")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def _daily_measure_missing(cls, value: str):
|
|
79
|
+
"""Handle unknown daily measure values from newer API versions.
|
|
80
|
+
|
|
81
|
+
This allows the client to gracefully handle new measures added to the API
|
|
82
|
+
without requiring a client library update. Unknown values become dynamic
|
|
83
|
+
enum members that behave like regular DailyMeasure values.
|
|
84
|
+
"""
|
|
85
|
+
pseudo_member = object.__new__(cls)
|
|
86
|
+
pseudo_member._name_ = value
|
|
87
|
+
pseudo_member._value_ = value
|
|
88
|
+
cls._value2member_map_[value] = pseudo_member # Cache for future lookups
|
|
89
|
+
return pseudo_member
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
DailyMeasure._missing_ = _daily_measure_missing
|
|
93
|
+
DailyMeasure.display_name = _daily_measure_display_name
|
|
94
|
+
DailyMeasure.display_name.__doc__ = _daily_measure_display_name.__doc__
|
|
@@ -466,7 +466,7 @@ class StreamlitAuth:
|
|
|
466
466
|
selected_activity = st.selectbox(
|
|
467
467
|
"Select an activity",
|
|
468
468
|
activities,
|
|
469
|
-
format_func=lambda activity: f"{activity.start.date().isoformat()} {activity.sport.
|
|
469
|
+
format_func=lambda activity: f"{activity.start.date().isoformat()} {activity.sport.label}",
|
|
470
470
|
)
|
|
471
471
|
return selected_activity
|
|
472
472
|
|
|
@@ -479,33 +479,33 @@ class StreamlitAuth:
|
|
|
479
479
|
only_root: If True, only returns root sports without parents. Defaults to False.
|
|
480
480
|
allow_multiple: If True, allows selecting multiple sports. Defaults to False.
|
|
481
481
|
only_available: If True, only shows sports available to the user. If False, shows all
|
|
482
|
-
|
|
482
|
+
standard OpenSportTaxonomy sports. Defaults to True.
|
|
483
483
|
|
|
484
484
|
Returns:
|
|
485
485
|
Sport or list[Sport]: The selected sport or list of sports, depending on allow_multiple.
|
|
486
486
|
|
|
487
487
|
Note:
|
|
488
|
-
Sports are displayed in a human-readable format using
|
|
488
|
+
Sports are displayed in a human-readable format using each sport's ``label``.
|
|
489
489
|
"""
|
|
490
490
|
if only_available:
|
|
491
491
|
sports = self.client.get_sports(only_root)
|
|
492
492
|
else:
|
|
493
493
|
if only_root:
|
|
494
|
-
sports = [
|
|
494
|
+
sports = [s for s in Sport.all() if "." not in s.code and not s.modifiers]
|
|
495
495
|
else:
|
|
496
|
-
sports = Sport
|
|
496
|
+
sports = Sport.all()
|
|
497
497
|
|
|
498
498
|
if allow_multiple:
|
|
499
499
|
selected_sport = st.multiselect(
|
|
500
500
|
"Select sports",
|
|
501
501
|
sports,
|
|
502
|
-
format_func=lambda sport: sport.
|
|
502
|
+
format_func=lambda sport: sport.label,
|
|
503
503
|
)
|
|
504
504
|
else:
|
|
505
505
|
selected_sport = st.selectbox(
|
|
506
506
|
"Select a sport",
|
|
507
507
|
sports,
|
|
508
|
-
format_func=lambda sport: sport.
|
|
508
|
+
format_func=lambda sport: sport.label,
|
|
509
509
|
)
|
|
510
510
|
return selected_sport
|
|
511
511
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Tests for the `after` (fatigue-state) param on get_longitudinal_mean_max."""
|
|
2
|
+
|
|
3
|
+
from io import BytesIO
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from sweatstack.client import Client
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def client():
|
|
14
|
+
c = Client.__new__(Client)
|
|
15
|
+
c.url = "https://test.sweatstack.no"
|
|
16
|
+
c._access_token = None
|
|
17
|
+
c.streamlit_compatible = False
|
|
18
|
+
c.skip_token_expiry_check = True
|
|
19
|
+
return c
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _index_free_after_response() -> bytes:
|
|
23
|
+
"""An index-free `after` long-format response, as the API returns it."""
|
|
24
|
+
df = pd.DataFrame({
|
|
25
|
+
"power": [100.0, 200.0, 90.0, 180.0],
|
|
26
|
+
"after": [0.0, 0.0, 500.0, 500.0],
|
|
27
|
+
"duration": pd.to_timedelta([300, 60, 200, 40], unit="s"),
|
|
28
|
+
"start": pd.to_datetime(["2024-01-01"] * 4, utc=True),
|
|
29
|
+
"activity_id": ["a", "a", "b", "b"],
|
|
30
|
+
"sport": ["cycling"] * 4,
|
|
31
|
+
})
|
|
32
|
+
buf = BytesIO()
|
|
33
|
+
df.to_parquet(buf, index=False)
|
|
34
|
+
return buf.getvalue()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _call(client, **kwargs):
|
|
38
|
+
response = MagicMock(status_code=200, content=_index_free_after_response())
|
|
39
|
+
http = MagicMock()
|
|
40
|
+
http.__enter__ = MagicMock(return_value=http)
|
|
41
|
+
http.__exit__ = MagicMock(return_value=False)
|
|
42
|
+
http.get.return_value = response
|
|
43
|
+
with patch.object(client, "_http_client", return_value=http), \
|
|
44
|
+
patch.object(client, "_raise_for_status"), \
|
|
45
|
+
patch.object(client, "_cache_enabled", return_value=False):
|
|
46
|
+
result = client.get_longitudinal_mean_max(sports=["cycling"], metric="power", **kwargs)
|
|
47
|
+
return result, http.get.call_args.kwargs["params"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_after_list_is_sent_and_shape_is_metric_indexed(client):
|
|
51
|
+
result, params = _call(client, after=[0, 500])
|
|
52
|
+
# repeated query param
|
|
53
|
+
assert params["after"] == [0, 500]
|
|
54
|
+
# shape parallels the no-after curve: metric value is the index, `after` a column
|
|
55
|
+
assert result.index.name == "power"
|
|
56
|
+
assert "after" in result.columns
|
|
57
|
+
assert set(result["after"].unique()) == {0.0, 500.0}
|
|
58
|
+
# postprocessed to standard dtypes
|
|
59
|
+
assert result["duration"].dtype == "timedelta64[ns]"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_single_after_is_normalised_to_a_list(client):
|
|
63
|
+
_result, params = _call(client, after=500)
|
|
64
|
+
assert params["after"] == [500]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_no_after_is_unchanged(client):
|
|
68
|
+
# without `after`, no after param is sent and the metric stays the index
|
|
69
|
+
response = MagicMock(status_code=200)
|
|
70
|
+
df = pd.DataFrame({"duration": pd.to_timedelta([60], unit="s")}, index=pd.Index([200.0], name="power"))
|
|
71
|
+
buf = BytesIO(); df.to_parquet(buf); response.content = buf.getvalue()
|
|
72
|
+
http = MagicMock()
|
|
73
|
+
http.__enter__ = MagicMock(return_value=http); http.__exit__ = MagicMock(return_value=False)
|
|
74
|
+
http.get.return_value = response
|
|
75
|
+
with patch.object(client, "_http_client", return_value=http), \
|
|
76
|
+
patch.object(client, "_raise_for_status"), \
|
|
77
|
+
patch.object(client, "_cache_enabled", return_value=False):
|
|
78
|
+
result = client.get_longitudinal_mean_max(sports=["cycling"], metric="power")
|
|
79
|
+
assert "after" not in http.get.call_args.kwargs["params"]
|
|
80
|
+
assert result.index.name == "power"
|