sweatstack 0.79.0__tar.gz → 0.80.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 (65) hide show
  1. {sweatstack-0.79.0 → sweatstack-0.80.0}/.claude/settings.local.json +5 -1
  2. {sweatstack-0.79.0 → sweatstack-0.80.0}/CHANGELOG.md +5 -0
  3. {sweatstack-0.79.0 → sweatstack-0.80.0}/PKG-INFO +1 -1
  4. sweatstack-0.80.0/plans/005_ost_sport_bridge.md +515 -0
  5. {sweatstack-0.79.0 → sweatstack-0.80.0}/pyproject.toml +1 -1
  6. {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/client.py +4 -1
  7. {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/schemas.py +77 -4
  8. sweatstack-0.80.0/tests/test_sport_ost_compat.py +123 -0
  9. {sweatstack-0.79.0 → sweatstack-0.80.0}/uv.lock +1 -1
  10. {sweatstack-0.79.0 → sweatstack-0.80.0}/.claude/skills/sweatstack-python/SKILL.md +0 -0
  11. {sweatstack-0.79.0 → sweatstack-0.80.0}/.claude/skills/sweatstack-python/client.md +0 -0
  12. {sweatstack-0.79.0 → sweatstack-0.80.0}/.claude/skills/sweatstack-python/data-models.md +0 -0
  13. {sweatstack-0.79.0 → sweatstack-0.80.0}/.claude/skills/sweatstack-python/fastapi.md +0 -0
  14. {sweatstack-0.79.0 → sweatstack-0.80.0}/.claude/skills/sweatstack-python/streamlit.md +0 -0
  15. {sweatstack-0.79.0 → sweatstack-0.80.0}/.gitignore +0 -0
  16. {sweatstack-0.79.0 → sweatstack-0.80.0}/.python-version +0 -0
  17. {sweatstack-0.79.0 → sweatstack-0.80.0}/AGENTS.md +0 -0
  18. {sweatstack-0.79.0 → sweatstack-0.80.0}/CONTRIBUTING.md +0 -0
  19. {sweatstack-0.79.0 → sweatstack-0.80.0}/DEVELOPMENT.md +0 -0
  20. {sweatstack-0.79.0 → sweatstack-0.80.0}/LICENSE +0 -0
  21. {sweatstack-0.79.0 → sweatstack-0.80.0}/Makefile +0 -0
  22. {sweatstack-0.79.0 → sweatstack-0.80.0}/README.md +0 -0
  23. {sweatstack-0.79.0 → sweatstack-0.80.0}/docs/conf.py +0 -0
  24. {sweatstack-0.79.0 → sweatstack-0.80.0}/docs/everything.rst +0 -0
  25. {sweatstack-0.79.0 → sweatstack-0.80.0}/docs/index.rst +0 -0
  26. {sweatstack-0.79.0 → sweatstack-0.80.0}/examples/fastapi_webhooks_example.py +0 -0
  27. {sweatstack-0.79.0 → sweatstack-0.80.0}/examples/send_webhook.py +0 -0
  28. {sweatstack-0.79.0 → sweatstack-0.80.0}/plans/001a_tests.md +0 -0
  29. {sweatstack-0.79.0 → sweatstack-0.80.0}/plans/001b_metadata.md +0 -0
  30. {sweatstack-0.79.0 → sweatstack-0.80.0}/plans/001c_dailies.md +0 -0
  31. {sweatstack-0.79.0 → sweatstack-0.80.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
  32. {sweatstack-0.79.0 → sweatstack-0.80.0}/plans/003_trace_test_linking.md +0 -0
  33. {sweatstack-0.79.0 → sweatstack-0.80.0}/plans/004_codebase_hygiene.md +0 -0
  34. {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
  35. {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/__init__.py +0 -0
  36. {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/cli.py +0 -0
  37. {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/constants.py +0 -0
  38. {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/exceptions.py +0 -0
  39. {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/fastapi/__init__.py +0 -0
  40. {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
  41. {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/fastapi/config.py +0 -0
  42. {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/fastapi/dependencies.py +0 -0
  43. {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/fastapi/models.py +0 -0
  44. {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/fastapi/routes.py +0 -0
  45. {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/fastapi/session.py +0 -0
  46. {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/fastapi/token_stores.py +0 -0
  47. {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/fastapi/webhooks.py +0 -0
  48. {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/ipython_init.py +0 -0
  49. {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
  50. {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/openapi_schemas.py +0 -0
  51. {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/py.typed +0 -0
  52. {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/streamlit.py +0 -0
  53. {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/sweatshell.py +0 -0
  54. {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/utils.py +0 -0
  55. {sweatstack-0.79.0 → sweatstack-0.80.0}/tests/__init__.py +0 -0
  56. {sweatstack-0.79.0 → sweatstack-0.80.0}/tests/test_access_token_cache.py +0 -0
  57. {sweatstack-0.79.0 → sweatstack-0.80.0}/tests/test_dailies.py +0 -0
  58. {sweatstack-0.79.0 → sweatstack-0.80.0}/tests/test_dtype_conversion.py +0 -0
  59. {sweatstack-0.79.0 → sweatstack-0.80.0}/tests/test_exceptions.py +0 -0
  60. {sweatstack-0.79.0 → sweatstack-0.80.0}/tests/test_metadata.py +0 -0
  61. {sweatstack-0.79.0 → sweatstack-0.80.0}/tests/test_public_surface.py +0 -0
  62. {sweatstack-0.79.0 → sweatstack-0.80.0}/tests/test_teams.py +0 -0
  63. {sweatstack-0.79.0 → sweatstack-0.80.0}/tests/test_tests.py +0 -0
  64. {sweatstack-0.79.0 → sweatstack-0.80.0}/tests/test_trace_test_linking.py +0 -0
  65. {sweatstack-0.79.0 → sweatstack-0.80.0}/tests/test_webhooks.py +0 -0
@@ -23,7 +23,11 @@
23
23
  "mcp__sentry__get_sentry_resource",
24
24
  "Bash(awk *)",
25
25
  "Bash(git show *)",
26
- "Read(//tmp/**)"
26
+ "Read(//tmp/**)",
27
+ "WebFetch(domain:pypi.org)",
28
+ "WebFetch(domain:github.com)",
29
+ "WebFetch(domain:raw.githubusercontent.com)",
30
+ "Bash(uv add *)"
27
31
  ],
28
32
  "deny": []
29
33
  }
@@ -5,6 +5,11 @@ 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.80.0] - 2026-06-12
9
+
10
+ ### Changed
11
+ - Makes the Sport enum forward compatible with OpenSportTaxonomy sports.
12
+
8
13
 
9
14
  ## [0.79.0] - 2026-05-28
10
15
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sweatstack
3
- Version: 0.79.0
3
+ Version: 0.80.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/
@@ -0,0 +1,515 @@
1
+ # Plan: OST sport adoption (decode-up bridge, with a removable legacy-tolerance shim)
2
+
3
+ The SweatStack API is migrating its sport vocabulary to
4
+ [OpenSportTaxonomy](https://github.com/SweatStack/open-sport-taxonomy) (OST), published as the
5
+ [`open-sport-taxonomy`](https://pypi.org/project/open-sport-taxonomy/) package on PyPI (v0.8.5 at time
6
+ of writing; requires Python ≥3.10, matching this SDK's 3.10–3.13 support). A handful of legacy sport
7
+ codes are renamed, and three become OST *modifiers* rather than codes:
8
+
9
+ | Legacy wire value (today) | OST wire value (after migration) | kind |
10
+ |---|---|---|
11
+ | `cycling.trainer` | `cycling+stationary` | leaf → modifier |
12
+ | `running.treadmill` | `running+stationary` | leaf → modifier |
13
+ | `rowing.ergometer` | `rowing+stationary` | leaf → modifier |
14
+ | `cycling.tt` | `cycling.time_trial` | rename |
15
+ | `cycling.mountainbike` | `cycling.mountain` | rename |
16
+ | `cross_country_skiing` | `xc_skiing` | rename (root) |
17
+ | `cross_country_skiing.classic` | `xc_skiing.classic` | rename |
18
+ | `cross_country_skiing.skate` | `xc_skiing.skate` | rename |
19
+ | `unknown` | `generic` | fold |
20
+
21
+ Everything else (`cycling.road`, `running`, `walking`, `swimming`, `generic`, …) is byte-identical
22
+ before and after, so this list is the *entire* delta.
23
+
24
+ ## Strategy: decode UP to OST now (one client migration), drop legacy tolerance later
25
+
26
+ This SDK **adopts OST as its public sport type in this release** and consumes API data the recommended
27
+ OST way. Apps that adopt this release migrate their code to OST **once, now**, and are done — there is
28
+ no second breaking change later.
29
+
30
+ > **Direction.** Inbound sport values from the API (whether the **legacy** pre-migration server or the
31
+ > **OST** post-migration server sends them) are normalized **up to** `open_sport_taxonomy.Sport`. The
32
+ > only thing temporary is the *legacy-tolerance* shim that recognizes old SweatStack spellings; once
33
+ > the server is OST-only it is deleted, leaving pure OST. This is the mirror image of a "keep the old
34
+ > enum, decode OST down to it" shim — chosen because it reaches the OST end state immediately and
35
+ > matches "consume the API the recommended way" at the *public* surface, not just internally.
36
+
37
+ The expand → migrate → contract rollout:
38
+
39
+ 1. **OST adoption release (this plan) — BREAKING (minor bump in `0.x`: `0.80.0`).** Public `Sport` becomes
40
+ `open_sport_taxonomy.Sport`. Both inbound surfaces — pydantic response models **and** the
41
+ parquet-backed DataFrames — decode legacy **and** OST values to OST; requests encode OST → legacy
42
+ so a pre-migration server still understands them. The SDK works against **both** the current
43
+ (legacy) and future (OST) server. Ask Molab Run, Myra Studio, and direct users to upgrade and port
44
+ their code to OST.
45
+ 2. **Server migration** (SweatStack `plans/027`). The API flips to OST. Apps on this release notice
46
+ nothing — they already speak OST.
47
+ 3. **Contract release — NON-breaking, patch bump in `0.x`.** Once the server is OST-only, delete the
48
+ legacy-tolerance shim (the maps + the custom validator/encoder). The public API does not change,
49
+ because it was already OST.
50
+
51
+ Note the version-bump structure is the inverse of a decode-down shim: the **breaking** work happens
52
+ **now** (step 1, `0.80.0`), and the cleanup (step 3) is a quiet non-breaking patch. The gate between 1 and 2 is
53
+ social: every known consumer must be on this release before the server flips. Because the consumers
54
+ barely touch sport (SweatStack `plans/027` Appendix B: the only filter anyone sends is `running`,
55
+ identical in both vocabularies), the coordination cost of the breaking change is low.
56
+
57
+ ## The bridge module (the legacy-tolerance shim — the deletable part)
58
+
59
+ One new file. It owns the migration table once and derives both directions, plus the pydantic and
60
+ encode glue. Everything in here is what gets deleted at the contract release.
61
+
62
+ ```python
63
+ # src/sweatstack/_sport_bridge.py
64
+ #
65
+ # TEMPORARY legacy-tolerance shim for the OST sport-vocabulary migration.
66
+ # The PUBLIC sport type is open_sport_taxonomy.Sport; this file only lets the SDK keep talking to a
67
+ # pre-migration (legacy) server during the rollout window. DELETE THIS FILE WHOLE in the contract
68
+ # release — see plans/005_ost_sport_bridge.md. A repo-wide grep for `_sport_bridge` returning zero
69
+ # hits is the definition of done for the cleanup.
70
+
71
+ from typing import Annotated, Any
72
+
73
+ from pydantic import BeforeValidator
74
+ from open_sport_taxonomy import Sport
75
+ from open_sport_taxonomy.pydantic import SportField
76
+
77
+ # The migration delta, authored once. (legacy SweatStack wire value, OST wire value).
78
+ _MIGRATION: list[tuple[str, str]] = [
79
+ ("cycling.trainer", "cycling+stationary"),
80
+ ("running.treadmill", "running+stationary"),
81
+ ("rowing.ergometer", "rowing+stationary"),
82
+ ("cycling.tt", "cycling.time_trial"),
83
+ ("cycling.mountainbike", "cycling.mountain"),
84
+ ("cross_country_skiing", "xc_skiing"),
85
+ ("cross_country_skiing.classic", "xc_skiing.classic"),
86
+ ("cross_country_skiing.skate", "xc_skiing.skate"),
87
+ ("unknown", "generic"),
88
+ ]
89
+ _LEGACY_TO_OST: dict[str, str] = {legacy: ost for legacy, ost in _MIGRATION}
90
+ _OST_TO_LEGACY: dict[str, str] = {ost: legacy for legacy, ost in _MIGRATION}
91
+
92
+
93
+ def to_ost_sport(value: Any) -> Sport:
94
+ """Normalize any inbound sport value to an OST `Sport`, tolerating legacy spellings.
95
+
96
+ The one inbound decoder, used in two places: as the pydantic BeforeValidator in front of OST's
97
+ `SportField` (response models), and directly where the SDK builds a Sport from a raw string
98
+ (get_sports). It applies the SweatStack-specific renames no library can know, then defers to
99
+ `Sport.parse` — permissive, so unknown/future codes are preserved, never raised; never the strict
100
+ `Sport(...)` constructor. Already-built `Sport` objects pass straight through. Returning a `Sport`
101
+ (not a string) is fine for the BeforeValidator: `SportField` still serializes it to the canonical
102
+ wire string (verified).
103
+ """
104
+ if isinstance(value, Sport):
105
+ return value
106
+ return Sport.parse(_LEGACY_TO_OST.get(value, value))
107
+
108
+
109
+ def encode_sport(sport: Sport) -> str:
110
+ """Serialize an OST Sport to a wire value a PRE-migration server accepts.
111
+
112
+ During the bridge window the server speaks legacy, so the ~9 changed values are translated back;
113
+ everything else (the vast majority, incl. `running`) is byte-identical and passes through as the
114
+ canonical OST string. Deleted at the contract release, where `str(sport)` is sent directly.
115
+ """
116
+ wire = str(sport)
117
+ return _OST_TO_LEGACY.get(wire, wire)
118
+
119
+
120
+ def normalize_sport_column(df, column: str = "sport"):
121
+ """Translate legacy wire values to OST in a DataFrame's sport column, in place.
122
+
123
+ Longitudinal/parquet DataFrames are read straight from the wire and bypass the pydantic models
124
+ (and thus to_ost_sport), so this is where the response-side legacy->OST swap happens for tabular
125
+ data. The column stays plain strings in canonical OST form (per OST's "store str(sport)" guidance),
126
+ not Sport objects. Guarded by column presence; a no-op once the server speaks OST. Deleted at the
127
+ contract release.
128
+ """
129
+ if column in df.columns:
130
+ df[column] = df[column].map(lambda v: _LEGACY_TO_OST.get(v, v))
131
+ return df
132
+
133
+
134
+ # Legacy-tolerant response field: OST's own SportField, fronted by the inbound normalizer.
135
+ # At the contract release this is replaced wholesale by open_sport_taxonomy.pydantic.SportField.
136
+ LegacySportField = Annotated[SportField, BeforeValidator(to_ost_sport)]
137
+ ```
138
+
139
+ **Why front `SportField` instead of `Annotated[Sport, …]`** — this is the wiring that was empirically
140
+ verified, and the obvious-looking alternative is a trap. A bare `Annotated[Sport, BeforeValidator(...)]`
141
+ validates fine but **serializes wrong**: `model_dump()` emits `{'code': 'cycling', 'modifiers': […]}`
142
+ instead of the wire string `"cycling+stationary"`, corrupting the SDK's output. Fronting OST's
143
+ `SportField` (which carries the correct pydantic core schema) makes `model_dump()` round-trip to the
144
+ canonical string. `SportField` is permissive (`Sport.parse`-based), so it ingests faithfully — unknown
145
+ future codes/modifiers are preserved, not rejected — which is the recommended *parse-on-ingest*
146
+ behavior; callers `.resolve()` themselves for application logic. Storing `str(sport)` round-trips
147
+ losslessly (`str(Sport.parse("cycling.road+virtual")) == "cycling.road+virtual"`).
148
+
149
+ ## Wiring (the six seams)
150
+
151
+ **1. Public type — re-export OST `Sport`.** In `schemas.py`, replace the import of the codegen'd
152
+ `Sport` enum with `from open_sport_taxonomy import Sport, Modifier`. Remove the entire legacy-enum
153
+ extension block — the `_sport_missing` hook (schemas.py:128-140, wired at :143) and the
154
+ `root_sport`/`parent_sport`/`is_sub_sport_of`/`is_root_sport`/`display_name` monkeypatching
155
+ (schemas.py:25-145). OST's `Sport` provides its own equivalents (table below). The existing export
156
+ chain then carries it through unchanged: `client.py` imports `Sport` (and now `Modifier`) from
157
+ `.schemas` (client.py:42-45), and `__init__.py`'s `from .client import *` re-exports both via the
158
+ generated `__all__` — so `from sweatstack import Sport` yields `open_sport_taxonomy.Sport`. Add
159
+ `Modifier` to `test_public_surface.py`'s core-surface list alongside `Sport`.
160
+
161
+ **2. Response decode — codegen override (AST-anchored, regeneration-safe).** The response models
162
+ (`ActivityDetails.sport`, …) must validate `sport` through the tolerant field. Add a post-generation
163
+ step to the `generate-response-models` CLI (cli.py) that replaces the whole generated `Sport` enum with
164
+ an import alias. Locate the class by name via stdlib `ast` (not a text regex, so it survives
165
+ regeneration) and splice the import + alias in over its source span:
166
+
167
+ ```python
168
+ import ast
169
+ from pathlib import Path
170
+
171
+ def _replace_sport_enum(path: Path) -> None:
172
+ """Swap the codegen'd `class Sport(Enum)` for the OST-backed legacy-tolerant field."""
173
+ src = path.read_text()
174
+ cls = next(n for n in ast.parse(src).body
175
+ if isinstance(n, ast.ClassDef) and n.name == "Sport") # no decorators on the enum
176
+ lines = src.splitlines(keepends=True)
177
+ lines[cls.lineno - 1 : cls.end_lineno] = [
178
+ "from ._sport_bridge import LegacySportField\n",
179
+ "Sport = LegacySportField # OST adoption — see plans/005; regenerated by cli.py\n",
180
+ ]
181
+ path.write_text("".join(lines))
182
+ ```
183
+
184
+ Putting the import *where the class was* (well past the file's `from __future__` header) avoids any
185
+ import-ordering pitfall. Every generated annotation (`sport: Sport`, `sport: Sport | None`,
186
+ `list[Sport]`) then binds to the tolerant field. **Verified against the current generated file:**
187
+ `class Sport(Enum)` is a single contiguous block (openapi_schemas.py:988); all 14 references are plain
188
+ type annotations; there are **zero** enum-member defaults (`Sport.cycling_road`); and the enum carries
189
+ no decorators — so wholesale replacement is safe.
190
+
191
+ This CLI step is **permanent infrastructure**: at the contract release only the injected target
192
+ changes (`LegacySportField` → `open_sport_taxonomy.pydantic.SportField`). Validated values are real OST
193
+ `Sport` instances and `model_dump()` emits the canonical wire string.
194
+
195
+ > **Pin it with a regeneration test (so it can't silently rot).** Because `openapi_schemas.py` is a
196
+ > committed artifact regenerated only by the manual CLI, guard the transform two ways: (a) a unit test
197
+ > that runs `_replace_sport_enum` on a tiny fixture and asserts `class Sport(` is gone and `Sport =
198
+ > LegacySportField` is present; (b) an import-level assertion in the test suite that
199
+ > `openapi_schemas.Sport is LegacySportField` and that `ActivityDetails(sport="cycling.trainer").sport`
200
+ > decodes to the OST member — so a regeneration that forgets the step fails CI loudly.
201
+
202
+ **3. Request encode — the request-path back-compat seam.** `Client._enums_to_strings`
203
+ (client.py:1017-1018) special-cases `Enum`; OST `Sport` is **not** an `Enum`, so it must be handled
204
+ explicitly — and this is the **only** request-path site that needs the legacy translation (the other
205
+ two back-compat seams, 5 and 6, are on response/read paths):
206
+
207
+ ```python
208
+ from ._sport_bridge import encode_sport # `Sport` is already imported in client.py (now the OST type)
209
+
210
+ def _enums_to_strings(self, values: list) -> list[str]:
211
+ out = []
212
+ for value in values:
213
+ if isinstance(value, Sport):
214
+ out.append(encode_sport(value)) # <- the temporary legacy translation lives here
215
+ elif isinstance(value, Enum):
216
+ out.append(value.value)
217
+ else:
218
+ out.append(value)
219
+ return out
220
+ ```
221
+
222
+ Two nearby points, both fine — the DataFrame surface is handled separately in seam 5:
223
+
224
+ - **Cache key** (client.py:149-152) already degrades correctly — OST `Sport` has no `.value`, so the
225
+ existing `else str(v)` branch stringifies it, which is a stable, unique key. No change required
226
+ (pin with a cache-key test).
227
+ - **No `Sport` object ever lands in a DataFrame cell**, so `utils.py` needs no sport change.
228
+ Model-derived DataFrames are built from `model_dump()` (client.py:1154), which emits the OST wire
229
+ string (decode already happened in pydantic); parquet-derived DataFrames hold strings. The parquet
230
+ path still needs a legacy→OST translation, but on the *string* — that is seam 5.
231
+
232
+ **4. Internal call sites that used the old enum API.** `streamlit.py:494` iterates the enum
233
+ (`[sport for sport in Sport if "." not in sport.value]`) → `[s for s in Sport.all() if "." not in
234
+ s.code]`; and `streamlit.py:469,502,508` call `.display_name()` → `.label`. (`utils.py` imports
235
+ `Sport` only for typing.)
236
+
237
+ **5. DataFrame decode — the longitudinal/parquet path (a second decode surface).** `get_longitudinal_data`
238
+ and the other `get_longitudinal_*`/parquet methods build their result with
239
+ `pd.read_parquet(BytesIO(response.content))`, which **bypasses the pydantic models entirely** — so a
240
+ `sport` column carries raw wire strings with no translation (legacy from a pre-migration server, OST
241
+ from a post-migration one). Every one of these funnels through `_postprocess_dataframe` (client.py),
242
+ so normalize there — one seam covers all of them, cached and fresh:
243
+
244
+ ```python
245
+ from ._sport_bridge import normalize_sport_column
246
+
247
+ def _postprocess_dataframe(self, df: pd.DataFrame) -> pd.DataFrame:
248
+ df = convert_to_standard_dtypes(df)
249
+ df = normalize_sport_column(df) # legacy -> OST in the `sport` column (back-compat)
250
+ if self.streamlit_compatible:
251
+ df = make_dataframe_streamlit_compatible(df)
252
+ return df
253
+ ```
254
+
255
+ The column stays plain strings in canonical OST form (`"cycling+stationary"`, …) — consistent with the
256
+ existing string-valued DataFrame convention and OST's *store `str(sport)`* guidance; a caller who wants
257
+ objects does `df["sport"].map(Sport.parse)`. It is guarded by column presence (DataFrames without a
258
+ `sport` column are untouched) and idempotent on already-OST columns (so it's harmless on the
259
+ `model_dump()`-derived frames that also pass through here). This is a **back-compat** seam — it uses
260
+ the legacy→OST map and is removed at the contract release.
261
+
262
+ *DataFrame surface audited (confirmed with the API owner):* the **only** frame carrying a `sport`
263
+ column is `longitudinal_data`; the mean-max, AWD, and activity-level frames carry **no** sport at all,
264
+ and sport never appears in a DataFrame **index**. So the column check above is complete — no
265
+ index-level handling is needed. (Dailies likewise have no sport: `DailyResponse` is `date`/`value`/
266
+ `source`.) The `get_activities`/`get_traces` `as_dataframe` frames are built from `model_dump()`, so
267
+ their sport is already decoded; `normalize_sport_column` is a harmless no-op on them.
268
+
269
+ **6. Direct `Sport` construction — `get_sports`.** `get_sports` builds its result with
270
+ `[Sport(sport) for sport in response.json()]` (client.py:2380). After the switch, `Sport(...)` is OST's
271
+ **strict** constructor, which **raises** on any non-standard value — so against a pre-migration server
272
+ (legacy strings) it would *crash*, and even post-migration it would crash on a brand-new server sport
273
+ the SDK's OST version doesn't know. Use the tolerant helper:
274
+
275
+ ```python
276
+ from ._sport_bridge import to_ost_sport
277
+ ...
278
+ return [to_ost_sport(sport) for sport in response.json()]
279
+ ```
280
+
281
+ The strict→`parse` change is **permanent** (never crash on an unknown server value); the legacy
282
+ translation inside `to_ost_sport` is the back-compat part, dropped at contract (→ `Sport.parse(sport)`).
283
+
284
+ ## Helper-method migration (the public-API break, documented)
285
+
286
+ Per the chosen pure decode-up approach there is **no compatibility shim** — consumers move to OST's
287
+ native API. The CHANGELOG/migration guide must spell this out:
288
+
289
+ | Old SDK (legacy enum) | OST `Sport` |
290
+ |---|---|
291
+ | `Sport.cycling_road` (member) | `Sport.CYCLING_ROAD` (constant) or `Sport.parse("cycling.road")` |
292
+ | `sport.value` | `str(sport)` (full, with modifiers) or `sport.code` (no modifiers) |
293
+ | `for s in Sport:` | `Sport.all()` |
294
+ | `sport.display_name()` | `sport.label` |
295
+ | `sport.parent_sport()` | `sport.parent` |
296
+ | `sport.is_sub_sport_of(x)` | `sport.is_subsport_of(x)` — **note:** OST takes a single `Sport`; for the SDK's old list form use `any(sport.is_subsport_of(s) for s in xs)` |
297
+ | `sport.root_sport()` | **no direct equivalent** — derive `Sport.parse(sport.code.split(".")[0])` |
298
+ | `sport.is_root_sport()` | **no direct equivalent** — `"." not in sport.code` |
299
+
300
+ The two `root` helpers are the only genuine capability gap (OST exposes `.parent`/`.disciplines` but
301
+ no root). The repo does not use them internally, and it is confirmed that **no consumer uses them** —
302
+ so they are dropped with no replacement helper; the one-line derivations in the table above are
303
+ documented in the migration guide for anyone who needs them later.
304
+
305
+ ## Migration prompt (ship this to consumers)
306
+
307
+ Publish the following copy-pastable prompt in the CHANGELOG/release notes so a consumer can hand it to
308
+ a coding agent to port their codebase. Keep it in sync with the table above.
309
+
310
+ ````text
311
+ Migrate this codebase to the new `sweatstack` release, which replaces its custom `Sport` enum with
312
+ the OpenSportTaxonomy type (`open_sport_taxonomy.Sport`). `from sweatstack import Sport` is now that
313
+ type. Find every use of SweatStack's `Sport` and update it as follows.
314
+
315
+ 1. Construction / members:
316
+ - `Sport.cycling_road` (lower_snake member) -> `Sport.CYCLING_ROAD` (UPPER constant) or
317
+ `Sport.parse("cycling.road")`.
318
+ - Building a Sport from an API/string value -> `Sport.parse(value)` (permissive, never raises on
319
+ unknown values). Use `.resolve()` only when you need the nearest *standard* sport.
320
+
321
+ 2. These sport VALUES were renamed by the API migration. Update any hardcoded strings or members:
322
+ | was | now |
323
+ |----------------|----------------------|
324
+ | cycling.trainer | cycling+stationary |
325
+ | running.treadmill | running+stationary |
326
+ | rowing.ergometer | rowing+stationary |
327
+ | cycling.tt | cycling.time_trial |
328
+ | cycling.mountainbike | cycling.mountain |
329
+ | cross_country_skiing[.classic|.skate] | xc_skiing[.classic|.skate] |
330
+ | unknown | generic |
331
+ ("stationary" etc. are now modifiers, appended with `+`; check `sport.modifiers`.)
332
+
333
+ 3. Methods / attributes:
334
+ | was | now |
335
+ |------------------------------|--------------------------------------------------|
336
+ | sport.value | str(sport) (full, incl. modifiers) / sport.code |
337
+ | sport.display_name() | sport.label |
338
+ | sport.parent_sport() | sport.parent |
339
+ | sport.is_sub_sport_of(x) | sport.is_subsport_of(x) -- x must be a single Sport; for a list use any(sport.is_subsport_of(s) for s in xs) |
340
+ | sport.root_sport() | Sport.parse(sport.code.split(".")[0]) |
341
+ | sport.is_root_sport() | ("." not in sport.code) |
342
+ | for s in Sport: ... | for s in Sport.all(): ... |
343
+
344
+ 4. Equality still works: `activity.sport == Sport.parse("cycling+stationary")`. Comparing against a
345
+ renamed value must use the NEW spelling (see table 2).
346
+
347
+ After editing, run the test suite and fix any remaining references. Do not add a compatibility shim;
348
+ migrate call sites to the OST API directly.
349
+ ````
350
+
351
+ ## The dependency
352
+
353
+ Add `open-sport-taxonomy` with the `pydantic` extra **in this release** (use uv, not pip):
354
+
355
+ ```bash
356
+ uv add "open-sport-taxonomy[pydantic]>=0.8.5"
357
+ ```
358
+
359
+ The `[pydantic]` extra is needed now because the response models consume `sport` via OST's
360
+ `SportField` (fronted by the legacy swap; the contract release uses `SportField` directly). Floor at
361
+ `0.8.5` (first release with the `parse`/`resolve`/`modifiers` API and the `Modifier.STATIONARY` member
362
+ this plan relies on); no upper bound.
363
+
364
+ > **Verified against v0.8.5 (executed, not assumed).**
365
+ > - `Modifier.STATIONARY` (value `'stationary'`) exists. Public `Sport` API: `code`, `label`,
366
+ > `parent`, `disciplines`, `modifiers`, `is_standard`, `is_subsport_of`, `all`, `parse`, `resolve`,
367
+ > plus `Sport.CYCLING_ROAD`-style constants. There is **no** `root` member (hence the gap above).
368
+ > - Every migration-table row round-trips: `Sport.parse("cycling+stationary")` → code `cycling` +
369
+ > `{STATIONARY}`; the renames come back as standard codes; `str()` round-trips faithfully.
370
+ > - **The field wiring was tested both ways.** `Annotated[SportField, BeforeValidator(to_ost_sport)]`
371
+ > (the plan's choice) validates legacy + OST input, preserves unknown values, passes through built
372
+ > `Sport` objects, and `model_dump()`s to the canonical **string** — even though the validator
373
+ > returns a `Sport` object. The naive `Annotated[Sport, BeforeValidator(...)]` instead dumps
374
+ > `{'code': …, 'modifiers': …}` — a real serialization bug, rejected.
375
+
376
+ ## Lossiness (now minimal)
377
+
378
+ Decode-up is **faithful** — inbound OST modifiers are preserved, nothing is flattened. The only lossy
379
+ edge is the **encode** path *during the bridge window*: an OST sport carrying a modifier with no legacy
380
+ equivalent (e.g. filtering on `cycling.road+virtual`) cannot be expressed to a pre-migration server;
381
+ `encode_sport` sends `str(sport)` and the old server may not recognize it. Per the traffic audit no
382
+ app filters on a changed or modified sport (only `running`, identical in both), so this never bites in
383
+ practice. It disappears entirely at the contract release, when the server speaks OST. The old
384
+ `unknown`/`generic` fold is also resolved server-side (it folds to `generic`), so the SDK never sees
385
+ `unknown` post-migration.
386
+
387
+ ## Permanent vs. temporary: the rip-out surface
388
+
389
+ The back-compat is concentrated so it lifts out cleanly. Everything below splits into "OST adoption
390
+ that stays" and "legacy shim that goes," and the shim is one file plus **four** call sites.
391
+
392
+ **Permanent (OST adoption — stays forever):** public `Sport` = `open_sport_taxonomy.Sport`; the
393
+ AST codegen step (only its injected target changes); `_enums_to_strings`/utils.py serializing a
394
+ `Sport` via string; streamlit's `.label`/`Sport.all()`; the removed legacy helpers.
395
+
396
+ **Temporary (the shim — deleted at the contract release):**
397
+
398
+ | What | Rip-out action |
399
+ |---|---|
400
+ | `src/sweatstack/_sport_bridge.py` (maps + `to_ost_sport` + `encode_sport` + `normalize_sport_column` + `LegacySportField`) | delete the file |
401
+ | cli.py injected codegen target | `LegacySportField` → `open_sport_taxonomy.pydantic.SportField` (one string) |
402
+ | `_enums_to_strings` in client.py | `encode_sport(value)` → `str(value)` (one call) |
403
+ | `_postprocess_dataframe` in client.py | remove the `normalize_sport_column(df)` call (one line) |
404
+ | `get_sports` in client.py | `to_ost_sport(sport)` → `Sport.parse(sport)` (one call) |
405
+
406
+ That is the **entire** rip-out: a file deletion plus four ~one-line edits, no public API change. A
407
+ repo-wide `grep _sport_bridge` surfaces exactly those references (the module, the cli import, and the
408
+ three client imports); when it returns zero the shim is gone. It ships as a **non-breaking minor
409
+ release**.
410
+
411
+ ## Contract release: the steps
412
+
413
+ 1. Delete `src/sweatstack/_sport_bridge.py`; apply the four call-site edits in the table above.
414
+ 2. Regenerate `openapi_schemas.py` against the migrated server (`generate-response-models`); the
415
+ AST step now injects `SportField` directly, so the migrated server's free-form `sport` string is
416
+ consumed by OST with no legacy translation.
417
+ 3. Verify `grep _sport_bridge` returns zero hits. Non-breaking patch bump within `0.x`; CHANGELOG
418
+ notes the internal cleanup (no public change).
419
+
420
+ ## Tests (write the failing ones first)
421
+
422
+ In `tests/`:
423
+
424
+ - **Public surface** — `from sweatstack import Sport` is `open_sport_taxonomy.Sport`;
425
+ `test_public_surface.py` updated accordingly.
426
+ - **Both spellings converge** — for every migration row, the legacy and OST inbound values decode to
427
+ the *same* OST sport: e.g. an `ActivityDetails` validated with `sport="cycling.trainer"` and one
428
+ with `sport="cycling+stationary"` compare equal, with `.code == "cycling"` and
429
+ `Modifier.STATIONARY in .modifiers`.
430
+ - **Renames & fold** — `cycling.tt`→`cycling.time_trial`, `cycling.mountainbike`→`cycling.mountain`,
431
+ `cross_country_skiing[.classic|.skate]`→`xc_skiing[...]`, `unknown`→`generic`.
432
+ - **Identity passthrough** — `running`, `cycling.road`, `generic` decode unchanged.
433
+ - **Future-tolerance** — `ActivityDetails(sport="kitesurfing")` does not raise; the value is preserved
434
+ (`is_standard` False), faithful to `Sport.parse`.
435
+ - **Serialization round-trip (the bug tripwire)** — a response model with an OST sport `model_dump()`s
436
+ `sport` back to the **wire string** (e.g. `"cycling+stationary"`), *not* a `{code, modifiers}`
437
+ object. This is the test that would have caught the rejected `Annotated[Sport, …]` wiring.
438
+ - **Encode (bridge window)** — `client._enums_to_strings([Sport.parse("cycling+stationary")]) ==
439
+ ["cycling.trainer"]`; `["running"]` passes through unchanged; an OST `Sport` is never sent as a
440
+ non-string object.
441
+ - **Cache key** — `_generate_cache_key` yields a stable string for an OST `Sport` (no `.value`
442
+ needed), confirming the cache path needs no back-compat change.
443
+ - **Longitudinal/parquet DataFrame decode** — a DataFrame whose `sport` column holds legacy values
444
+ (`cycling.trainer`, `cross_country_skiing`) comes out of `_postprocess_dataframe` with OST strings
445
+ (`cycling+stationary`, `xc_skiing`); OST and unchanged values pass through; a frame with no `sport`
446
+ column is untouched; and the normalization is idempotent on an already-OST column.
447
+ - **`get_sports` tolerance** — `get_sports` against a legacy payload (`["cycling.trainer", "running",
448
+ "cross_country_skiing"]`) returns the OST sports without raising; an unknown future sport is
449
+ preserved (`is_standard` False) rather than crashing — i.e. the strict `Sport(...)` constructor is
450
+ not used.
451
+ - **Modifier member tripwire** — assert `Modifier.STATIONARY` exists, so a future OST rename fails
452
+ loudly here.
453
+ - **str round-trip** — `str(Sport.parse("cycling.road+virtual")) == "cycling.road+virtual"`.
454
+ - **Migration map integrity** — `_OST_TO_LEGACY` is the exact inverse of `_LEGACY_TO_OST`, and both
455
+ cover the 9-row table.
456
+
457
+ ## Effort and risk
458
+
459
+ - **Effort: Medium.** Bigger than a decode-down shim: it adds the dependency, overrides codegen via an
460
+ AST step, wires three back-compat seams (request encode + parquet-DataFrame decode + `get_sports`),
461
+ removes the legacy enum + helpers, and is a breaking release with a migration guide. The bridge
462
+ module itself is small (~65 lines).
463
+ - **Version: `0.80.0`.** The project stays in `0.x`, so this breaking release bumps the minor (from
464
+ 0.79.0). The CHANGELOG carries the migration prompt above and the value-rename table, clearly
465
+ flagging the breaking sport-type change.
466
+ - **Primary risk — breaking change coordination.** Every known consumer must port to OST and adopt
467
+ this release before the server flips. Mitigated by the small, controllable consumer set and the
468
+ Appendix-B finding that they barely touch sport.
469
+ - **Codegen substitution.** The post-generation replacement of the `Sport` enum must be reliable
470
+ across regenerations; pin it with a test that imports a response model and checks `sport` decodes an
471
+ OST value.
472
+ - **New dependency at the public surface.** `open-sport-taxonomy` is now a hard, public dependency
473
+ (not just internal). It is small, pure-Python, ≥3.10, and maintained by SweatStack itself, so risk
474
+ is low; the modifier-member tripwire pins the one API detail keyed by name.
475
+ - **`is_subsport_of` signature change** (single vs. the old list form) — called out in the migration
476
+ guide.
477
+ - **Request path / external dependency.** The server does **not** accept OST input, so requests
478
+ encode OST→legacy (seam 3) and rely on the server accepting legacy input through the bridge window
479
+ (SweatStack `plans/027`). At the atomic input+output flip this is backstopped by observed traffic:
480
+ the only filtered sport is `running`, identical in both vocabularies, so no real request breaks even
481
+ for the changed sports. Removable at contract.
482
+
483
+ ## Confirmed decisions
484
+
485
+ - **The server does NOT accept OST sport input; the migration flips input and output together.** So
486
+ the SDK cannot send OST early — **seam 3 (`encode_sport`, OST→legacy) is required** and stays. The
487
+ request path relies on the server accepting legacy input through the bridge window (the documented
488
+ `plans/027` dependency); and at the atomic flip it is backstopped by observed traffic — the only
489
+ sport any app filters on is `running`, byte-identical in both vocabularies, so no real request breaks
490
+ even for the changed sports.
491
+ - **The longitudinal parquet column is named `sport`** — `normalize_sport_column`'s default key is
492
+ correct; no override needed.
493
+ - **No consumer uses `root_sport()`/`is_root_sport()`** — they are dropped with no replacement helper;
494
+ the migration guide documents the one-line derivations for anyone who needs them later.
495
+ - **Version: `0.80.0`** — the project stays in `0.x`, so this breaking release bumps the minor (from
496
+ 0.79.0); the contract release is a later non-breaking patch within `0.x`.
497
+
498
+ ## Build order (suggested, each step independently testable)
499
+
500
+ 1. Add the dependency; create `_sport_bridge.py` with the four helpers + `LegacySportField`; unit-test
501
+ them in isolation (map integrity, `to_ost_sport`, `encode_sport`, `normalize_sport_column`).
502
+ 2. Seam 1 (public type) + seam 2 (codegen transform + regeneration test). Now response models decode.
503
+ 3. Seams 3, 5, 6 (encode, DataFrame, `get_sports`) — the remaining I/O channels.
504
+ 4. Seam 4 (streamlit) + remove dead helper code; update `test_public_surface.py`.
505
+ 5. Write the CHANGELOG with the migration prompt; choose the version bump.
506
+
507
+ ## Out of scope
508
+
509
+ - A backwards-compatibility shim that keeps the old `Sport.cycling_road`-style members or helper
510
+ methods working — explicitly rejected in favor of a single clean migration to OST.
511
+ - Using OST's platform translators (`open_sport_taxonomy.platforms.*`, e.g. strava/garmin) — the SDK
512
+ speaks SweatStack's own wire format; there is no `sweatstack` translator, which is why the migration
513
+ map is hand-authored here.
514
+ - Helping non-SDK consumers (the Node.js apps, the KeeperCircle iOS app). They do not use this library
515
+ and are coordinated separately; per SweatStack `plans/027` Appendix B they do not filter on sport.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sweatstack"
3
- version = "0.79.0"
3
+ version = "0.80.0"
4
4
  description = "The official Python client for SweatStack"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -46,6 +46,7 @@ from .schemas import (
46
46
  TeamResponse, TestDetails, TestResults, TestSummary, TokenResponse, TraceDetails,
47
47
  TraceResolution, UserInfoResponse, UserResponse, UserSummary
48
48
  )
49
+ from .schemas import _sport_to_wire
49
50
  from .utils import convert_to_standard_dtypes, decode_jwt_body, make_dataframe_streamlit_compatible
50
51
 
51
52
  logger = logging.getLogger(__name__)
@@ -1015,7 +1016,9 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1015
1016
  return text if text else None
1016
1017
 
1017
1018
  def _enums_to_strings(self, values: list[Enum | str]) -> list[str]:
1018
- return [value.value if isinstance(value, Enum) else value for value in values]
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]
1019
1022
 
1020
1023
  def _get_activities_generator(
1021
1024
  self,
@@ -125,14 +125,87 @@ Sport.is_sub_sport_of.__doc__ = _is_sub_sport_of.__doc__
125
125
  Sport.is_root_sport = _is_root_sport
126
126
  Sport.is_root_sport.__doc__ = _is_root_sport.__doc__
127
127
 
128
+ # --- OpenSportTaxonomy (OST) compatibility -----------------------------------------------------------
129
+ # The SweatStack API is migrating its sport vocabulary to OpenSportTaxonomy. A handful of sport codes are
130
+ # renamed and three become OST "+stationary" modifiers; the table below is the entire delta (every other
131
+ # value is byte-identical in both vocabularies). Mapping OST values back to the legacy ``Sport`` members
132
+ # here lets the client keep its existing public ``Sport`` enum while transparently accepting OST values
133
+ # from the API, so e.g. ``activity.sport == Sport.cycling_trainer`` keeps working across the migration.
134
+ # Remove this shim when the SDK adopts OST natively.
135
+ _OST_TO_LEGACY_SPORT = {
136
+ "cycling+stationary": "cycling.trainer",
137
+ "running+stationary": "running.treadmill",
138
+ "rowing+stationary": "rowing.ergometer",
139
+ "cycling.time_trial": "cycling.tt",
140
+ "cycling.mountain": "cycling.mountainbike",
141
+ "xc_skiing": "cross_country_skiing",
142
+ "xc_skiing.classic": "cross_country_skiing.classic",
143
+ "xc_skiing.skate": "cross_country_skiing.skate",
144
+ }
145
+
146
+ # All declared (legacy) Sport values, captured once so resolution only ever maps to a real member and
147
+ # never to a previously-cached dynamic pseudo-member.
148
+ _LEGACY_SPORT_VALUES = frozenset(member.value for member in Sport)
149
+
150
+
151
+ def _ost_to_legacy_sport(value: str) -> "str | None":
152
+ """Translate an OST sport wire value to its legacy ``Sport`` value, or ``None`` if there is none.
153
+
154
+ Resolution order -- the rename table must win over modifier-stripping, so ``cycling+stationary``
155
+ resolves to the ``cycling.trainer`` leaf rather than the bare ``cycling`` base:
156
+
157
+ 1. Exact match in the rename table.
158
+ 2. Strip OST ``+modifier`` suffixes and retry, so a modified-but-otherwise-known sport such as
159
+ ``cycling.road+virtual`` resolves to its base ``cycling.road`` (the modifier, which the legacy
160
+ enum cannot express, is dropped).
161
+ """
162
+ if value in _OST_TO_LEGACY_SPORT:
163
+ return _OST_TO_LEGACY_SPORT[value]
164
+ base = value.split("+", 1)[0]
165
+ if base != value:
166
+ return _OST_TO_LEGACY_SPORT.get(base, base)
167
+ return None
168
+
169
+
170
+ # Lossy forward-compatibility for *encoding* sport filters. A request that filters on one of the changed
171
+ # sports is sent as the nearest code identical in BOTH vocabularies -- its root -- so the filter is
172
+ # accepted by a pre- *or* post-migration server (e.g. ``cycling.trainer`` -> ``cycling``). This
173
+ # deliberately broadens the filter to the root, which is acceptable because no app filters on these
174
+ # sub-sports. The ``cross_country_skiing.*`` family has no common root (its root was renamed to
175
+ # ``xc_skiing``) and is intentionally left untouched. Remove with the rest of the OST shim.
176
+ _LOSSY_SPORT_FALLBACK = {
177
+ "cycling.trainer": "cycling",
178
+ "cycling.tt": "cycling",
179
+ "cycling.mountainbike": "cycling",
180
+ "running.treadmill": "running",
181
+ "rowing.ergometer": "rowing",
182
+ }
183
+
184
+
185
+ def _sport_to_wire(value: str) -> str:
186
+ """Translate a sport wire value to a form a pre- or post-migration server both accept (lossy).
187
+
188
+ Used only when encoding sport *filters*; a no-op for every non-sport value. Not used when writing a
189
+ real sport (e.g. activity upload), which must keep its exact value.
190
+ """
191
+ return _LOSSY_SPORT_FALLBACK.get(value, value)
192
+
193
+
128
194
  @classmethod
129
195
  def _sport_missing(cls, value: str):
130
- """Handle unknown sport values from newer API versions.
196
+ """Resolve a sport value that is not a declared member.
131
197
 
132
- This allows the client to gracefully handle new sports added to the API
133
- without requiring a client library update. Unknown values become dynamic
134
- enum members that behave like regular Sport values.
198
+ First tries to map an OpenSportTaxonomy value back to its legacy ``Sport`` member (see
199
+ :func:`_ost_to_legacy_sport`) so equality and the helper methods keep working across the API's OST
200
+ migration. Otherwise -- a genuinely new sport with no legacy equivalent -- it falls back to a
201
+ dynamic pseudo-member, so newer API versions never crash an older client.
135
202
  """
203
+ legacy = _ost_to_legacy_sport(value)
204
+ if legacy is not None and legacy in _LEGACY_SPORT_VALUES:
205
+ member = cls._value2member_map_[legacy]
206
+ cls._value2member_map_[value] = member # cache OST spelling -> real legacy member
207
+ return member
208
+
136
209
  pseudo_member = object.__new__(cls)
137
210
  pseudo_member._name_ = value
138
211
  pseudo_member._value_ = value
@@ -0,0 +1,123 @@
1
+ """Tests for OpenSportTaxonomy (OST) compatibility in the ``Sport`` enum.
2
+
3
+ The SweatStack API is migrating its sport vocabulary to OST. The client keeps its legacy ``Sport``
4
+ enum as the public type and transparently maps OST wire values back to the legacy members via
5
+ ``Sport._missing_`` (see ``sweatstack.schemas``). These tests pin that behaviour.
6
+ """
7
+
8
+ import pytest
9
+
10
+ from sweatstack.schemas import _OST_TO_LEGACY_SPORT, _ost_to_legacy_sport, _sport_to_wire
11
+ from sweatstack import Metric, Sport
12
+ from sweatstack.client import Client
13
+ from sweatstack.openapi_schemas import TestCreate as SportCreateModel # aliased: avoid pytest "Test*" collection
14
+
15
+
16
+ # Every changed value, paired with the legacy enum member name it must resolve to.
17
+ CHANGED = [
18
+ ("cycling+stationary", "cycling_trainer"),
19
+ ("running+stationary", "running_treadmill"),
20
+ ("rowing+stationary", "rowing_ergometer"),
21
+ ("cycling.time_trial", "cycling_tt"),
22
+ ("cycling.mountain", "cycling_mountainbike"),
23
+ ("xc_skiing", "cross_country_skiing"),
24
+ ("xc_skiing.classic", "cross_country_skiing_classic"),
25
+ ("xc_skiing.skate", "cross_country_skiing_skate"),
26
+ ]
27
+
28
+
29
+ @pytest.mark.parametrize("ost, member_name", CHANGED)
30
+ def test_ost_value_resolves_to_real_legacy_member(ost, member_name):
31
+ expected = Sport[member_name]
32
+ assert Sport(ost) is expected # the real declared member, not a pseudo-member
33
+ assert Sport(ost) == expected
34
+ assert Sport(ost).value == expected.value
35
+
36
+
37
+ def test_resolved_members_have_correct_helpers_and_display_name():
38
+ assert Sport("running+stationary").display_name() == "running (treadmill)"
39
+ assert Sport("xc_skiing").display_name() == "cross country skiing"
40
+ assert Sport("xc_skiing.classic").root_sport() is Sport.cross_country_skiing
41
+ assert Sport("cycling+stationary").is_sub_sport_of(Sport.cycling)
42
+
43
+
44
+ def test_identical_values_never_reach_the_shim():
45
+ # These are declared members, so they resolve directly without _missing_.
46
+ assert Sport("running") is Sport.running
47
+ assert Sport("cycling.road") is Sport.cycling_road
48
+ assert Sport("generic") is Sport.generic
49
+
50
+
51
+ def test_modifier_is_stripped_to_the_base_sport():
52
+ # An OST modifier on an otherwise-known sport drops to the legacy base...
53
+ assert Sport("cycling.road+virtual") is Sport.cycling_road
54
+ assert Sport("running+race") is Sport.running
55
+ # ...but the rename table wins over stripping: +stationary is a leaf, not the bare base.
56
+ assert Sport("cycling+stationary") is Sport.cycling_trainer
57
+
58
+
59
+ def test_unknown_sport_falls_back_to_pseudo_member_without_raising():
60
+ new = Sport("alpine_skiing")
61
+ assert new.value == "alpine_skiing"
62
+ assert "alpine_skiing" not in Sport.__members__
63
+ assert new.display_name() == "alpine skiing"
64
+
65
+
66
+ def test_unknown_sport_with_modifier_preserves_the_original_value():
67
+ # No legacy base exists, so it stays a faithful pseudo-member (order-independent: never aliases
68
+ # onto a previously-cached bare-base pseudo).
69
+ assert Sport("alpine_skiing+race").value == "alpine_skiing+race"
70
+
71
+
72
+ def test_resolution_is_cached_to_the_same_object():
73
+ assert Sport("running+stationary") is Sport("running+stationary")
74
+
75
+
76
+ def test_pure_translation_function():
77
+ assert _ost_to_legacy_sport("cycling+stationary") == "cycling.trainer" # table beats strip
78
+ assert _ost_to_legacy_sport("cycling.road+virtual") == "cycling.road" # strip to base
79
+ assert _ost_to_legacy_sport("xc_skiing") == "cross_country_skiing" # rename
80
+ assert _ost_to_legacy_sport("running") is None # identical -> no mapping
81
+ assert _ost_to_legacy_sport("alpine_skiing") is None # unknown -> no mapping
82
+
83
+
84
+ def test_table_targets_are_all_real_legacy_members():
85
+ legacy_values = {m.value for m in Sport}
86
+ assert set(_OST_TO_LEGACY_SPORT.values()) <= legacy_values
87
+
88
+
89
+ def test_response_model_decodes_ost_sport_to_legacy_member():
90
+ model = SportCreateModel.model_validate({"sport": "running+stationary", "start": "2026-01-01T00:00:00+00:00"})
91
+ assert model.sport is Sport.running_treadmill
92
+
93
+
94
+ # --- Encode: lossy forward-compat for request filters ---------------------------------------------
95
+
96
+ @pytest.mark.parametrize("sport_value, wire", [
97
+ ("cycling.trainer", "cycling"),
98
+ ("cycling.tt", "cycling"),
99
+ ("cycling.mountainbike", "cycling"),
100
+ ("running.treadmill", "running"),
101
+ ("rowing.ergometer", "rowing"),
102
+ ])
103
+ def test_changed_sports_encode_to_their_common_root(sport_value, wire):
104
+ assert _sport_to_wire(sport_value) == wire
105
+
106
+
107
+ def test_unchanged_and_xc_sports_encode_unchanged():
108
+ # Universal sports keep full precision...
109
+ assert _sport_to_wire("cycling.road") == "cycling.road"
110
+ assert _sport_to_wire("running") == "running"
111
+ # ...and cross_country_skiing.* is deliberately left untouched (no common root).
112
+ assert _sport_to_wire("cross_country_skiing") == "cross_country_skiing"
113
+ assert _sport_to_wire("cross_country_skiing.classic") == "cross_country_skiing.classic"
114
+
115
+
116
+ def test_enums_to_strings_applies_lossy_fallback_to_sport_filters():
117
+ client = Client.__new__(Client)
118
+ # changed sport -> root; unchanged sport untouched
119
+ assert client._enums_to_strings([Sport.cycling_trainer, Sport.cycling_road]) == ["cycling", "cycling.road"]
120
+ # the only sport anyone actually filters on is identical in both vocabularies
121
+ assert client._enums_to_strings([Sport.running]) == ["running"]
122
+ # non-sport enums and raw strings are unaffected
123
+ assert client._enums_to_strings([Metric.power, "running"]) == ["power", "running"]
@@ -2571,7 +2571,7 @@ wheels = [
2571
2571
 
2572
2572
  [[package]]
2573
2573
  name = "sweatstack"
2574
- version = "0.78.0"
2574
+ version = "0.80.0"
2575
2575
  source = { editable = "." }
2576
2576
  dependencies = [
2577
2577
  { name = "email-validator" },
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