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.
- {sweatstack-0.79.0 → sweatstack-0.80.0}/.claude/settings.local.json +5 -1
- {sweatstack-0.79.0 → sweatstack-0.80.0}/CHANGELOG.md +5 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/PKG-INFO +1 -1
- sweatstack-0.80.0/plans/005_ost_sport_bridge.md +515 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/pyproject.toml +1 -1
- {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/client.py +4 -1
- {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/schemas.py +77 -4
- sweatstack-0.80.0/tests/test_sport_ost_compat.py +123 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/uv.lock +1 -1
- {sweatstack-0.79.0 → sweatstack-0.80.0}/.claude/skills/sweatstack-python/SKILL.md +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/.claude/skills/sweatstack-python/client.md +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/.claude/skills/sweatstack-python/data-models.md +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/.claude/skills/sweatstack-python/fastapi.md +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/.claude/skills/sweatstack-python/streamlit.md +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/.gitignore +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/.python-version +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/AGENTS.md +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/CONTRIBUTING.md +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/DEVELOPMENT.md +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/LICENSE +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/Makefile +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/README.md +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/docs/conf.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/docs/everything.rst +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/docs/index.rst +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/examples/fastapi_webhooks_example.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/examples/send_webhook.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/plans/001a_tests.md +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/plans/001b_metadata.md +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/plans/001c_dailies.md +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/plans/003_trace_test_linking.md +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/plans/004_codebase_hygiene.md +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/__init__.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/cli.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/constants.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/exceptions.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/fastapi/__init__.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/fastapi/config.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/fastapi/dependencies.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/fastapi/models.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/fastapi/routes.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/fastapi/session.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/fastapi/token_stores.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/fastapi/webhooks.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/ipython_init.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/openapi_schemas.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/py.typed +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/streamlit.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/sweatshell.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/utils.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/tests/__init__.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/tests/test_access_token_cache.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/tests/test_dailies.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/tests/test_dtype_conversion.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/tests/test_exceptions.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/tests/test_metadata.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/tests/test_public_surface.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/tests/test_teams.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/tests/test_tests.py +0 -0
- {sweatstack-0.79.0 → sweatstack-0.80.0}/tests/test_trace_test_linking.py +0 -0
- {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
|
|
|
@@ -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.
|
|
@@ -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
|
-
|
|
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
|
-
"""
|
|
196
|
+
"""Resolve a sport value that is not a declared member.
|
|
131
197
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sweatstack-0.79.0 → sweatstack-0.80.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|