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.
Files changed (70) hide show
  1. {sweatstack-0.80.0 → sweatstack-0.82.0}/.claude/settings.local.json +5 -1
  2. {sweatstack-0.80.0 → sweatstack-0.82.0}/AGENTS.md +6 -2
  3. {sweatstack-0.80.0 → sweatstack-0.82.0}/CHANGELOG.md +66 -0
  4. {sweatstack-0.80.0 → sweatstack-0.82.0}/PKG-INFO +6 -1
  5. sweatstack-0.82.0/README.md +9 -0
  6. {sweatstack-0.80.0 → sweatstack-0.82.0}/docs/everything.rst +7 -2
  7. {sweatstack-0.80.0 → sweatstack-0.82.0}/pyproject.toml +2 -1
  8. sweatstack-0.82.0/src/sweatstack/cli.py +48 -0
  9. {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/client.py +38 -11
  10. {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/openapi_schemas.py +1 -48
  11. sweatstack-0.82.0/src/sweatstack/schemas.py +94 -0
  12. {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/streamlit.py +7 -7
  13. sweatstack-0.82.0/tests/test_longitudinal_mean_max_after.py +80 -0
  14. {sweatstack-0.80.0 → sweatstack-0.82.0}/tests/test_public_surface.py +1 -0
  15. sweatstack-0.82.0/tests/test_sport_ost.py +91 -0
  16. {sweatstack-0.80.0 → sweatstack-0.82.0}/tests/test_tests.py +5 -5
  17. {sweatstack-0.80.0 → sweatstack-0.82.0}/uv.lock +17 -1
  18. sweatstack-0.80.0/README.md +0 -5
  19. sweatstack-0.80.0/src/sweatstack/cli.py +0 -24
  20. sweatstack-0.80.0/src/sweatstack/schemas.py +0 -287
  21. sweatstack-0.80.0/tests/test_sport_ost_compat.py +0 -123
  22. {sweatstack-0.80.0 → sweatstack-0.82.0}/.claude/skills/sweatstack-python/SKILL.md +0 -0
  23. {sweatstack-0.80.0 → sweatstack-0.82.0}/.claude/skills/sweatstack-python/client.md +0 -0
  24. {sweatstack-0.80.0 → sweatstack-0.82.0}/.claude/skills/sweatstack-python/data-models.md +0 -0
  25. {sweatstack-0.80.0 → sweatstack-0.82.0}/.claude/skills/sweatstack-python/fastapi.md +0 -0
  26. {sweatstack-0.80.0 → sweatstack-0.82.0}/.claude/skills/sweatstack-python/streamlit.md +0 -0
  27. {sweatstack-0.80.0 → sweatstack-0.82.0}/.gitignore +0 -0
  28. {sweatstack-0.80.0 → sweatstack-0.82.0}/.python-version +0 -0
  29. {sweatstack-0.80.0 → sweatstack-0.82.0}/CONTRIBUTING.md +0 -0
  30. {sweatstack-0.80.0 → sweatstack-0.82.0}/DEVELOPMENT.md +0 -0
  31. {sweatstack-0.80.0 → sweatstack-0.82.0}/LICENSE +0 -0
  32. {sweatstack-0.80.0 → sweatstack-0.82.0}/Makefile +0 -0
  33. {sweatstack-0.80.0 → sweatstack-0.82.0}/docs/conf.py +0 -0
  34. {sweatstack-0.80.0 → sweatstack-0.82.0}/docs/index.rst +0 -0
  35. {sweatstack-0.80.0 → sweatstack-0.82.0}/examples/fastapi_webhooks_example.py +0 -0
  36. {sweatstack-0.80.0 → sweatstack-0.82.0}/examples/send_webhook.py +0 -0
  37. {sweatstack-0.80.0 → sweatstack-0.82.0}/plans/001a_tests.md +0 -0
  38. {sweatstack-0.80.0 → sweatstack-0.82.0}/plans/001b_metadata.md +0 -0
  39. {sweatstack-0.80.0 → sweatstack-0.82.0}/plans/001c_dailies.md +0 -0
  40. {sweatstack-0.80.0 → sweatstack-0.82.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
  41. {sweatstack-0.80.0 → sweatstack-0.82.0}/plans/003_trace_test_linking.md +0 -0
  42. {sweatstack-0.80.0 → sweatstack-0.82.0}/plans/004_codebase_hygiene.md +0 -0
  43. {sweatstack-0.80.0 → sweatstack-0.82.0}/plans/005_ost_sport_bridge.md +0 -0
  44. {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
  45. {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/__init__.py +0 -0
  46. {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/constants.py +0 -0
  47. {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/exceptions.py +0 -0
  48. {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/fastapi/__init__.py +0 -0
  49. {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
  50. {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/fastapi/config.py +0 -0
  51. {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/fastapi/dependencies.py +0 -0
  52. {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/fastapi/models.py +0 -0
  53. {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/fastapi/routes.py +0 -0
  54. {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/fastapi/session.py +0 -0
  55. {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/fastapi/token_stores.py +0 -0
  56. {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/fastapi/webhooks.py +0 -0
  57. {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/ipython_init.py +0 -0
  58. {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
  59. {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/py.typed +0 -0
  60. {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/sweatshell.py +0 -0
  61. {sweatstack-0.80.0 → sweatstack-0.82.0}/src/sweatstack/utils.py +0 -0
  62. {sweatstack-0.80.0 → sweatstack-0.82.0}/tests/__init__.py +0 -0
  63. {sweatstack-0.80.0 → sweatstack-0.82.0}/tests/test_access_token_cache.py +0 -0
  64. {sweatstack-0.80.0 → sweatstack-0.82.0}/tests/test_dailies.py +0 -0
  65. {sweatstack-0.80.0 → sweatstack-0.82.0}/tests/test_dtype_conversion.py +0 -0
  66. {sweatstack-0.80.0 → sweatstack-0.82.0}/tests/test_exceptions.py +0 -0
  67. {sweatstack-0.80.0 → sweatstack-0.82.0}/tests/test_metadata.py +0 -0
  68. {sweatstack-0.80.0 → sweatstack-0.82.0}/tests/test_teams.py +0 -0
  69. {sweatstack-0.80.0 → sweatstack-0.82.0}/tests/test_trace_test_linking.py +0 -0
  70. {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.cycling, Sport.running]` |
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 + Enum._missing_ / display_name helpers.
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.80.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
- .. autoclass:: sweatstack.schemas.Sport
155
- :members: root_sport, parent_sport, is_sub_sport_of, is_root_sport, display_name
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.80.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
- # Sport filters get a lossy forward-compat fallback (_sport_to_wire); it is a no-op for every
1020
- # other enum/string value.
1021
- return [_sport_to_wire(value.value if isinstance(value, Enum) else value) for value in values]
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._postprocess_dataframe(pd.read_parquet(BytesIO(cached)))
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
- df = pd.read_parquet(BytesIO(response.content))
1531
- return self._postprocess_dataframe(df)
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
- return [Sport(sport) for sport in response.json()]
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"] = sport.value if isinstance(sport, Enum) else 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
- class Sport(Enum):
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.display_name()}",
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
- sports defined in the Sport enum. Defaults to True.
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 the display_name function.
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 = [sport for sport in Sport if "." not in sport.value]
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.display_name(),
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.display_name(),
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"
@@ -62,6 +62,7 @@ class TestDunderAll:
62
62
  "Client",
63
63
  "TraceResolution",
64
64
  "Sport",
65
+ "Modifier",
65
66
  "SweatStackNotFoundError",
66
67
  "SweatStackAPIError",
67
68
  "enable_cache",