Upgrading
How to move a project to a newer sweatstack release, from any older one. Until 1.0 a minor release may contain breaking changes; every one has its upgrade path below, newest first, and the full list of changes is in the changelog.
Procedure
- Find your version.
uv pip show sweatstack, or your lockfile.
- Inventory your SDK usage before changing anything: every call on a
Client or on the
sweatstack module (import sweatstack as ss is common), StreamlitAuth and
sweatstack.fastapi, the model fields you read, the frame columns and indexes you rely on,
the exceptions you catch, and the install line with its extras.
- Read the Upgrading section of every release between your version and the target; a
release without one has nothing to do. Work oldest first: each table uses the names of the
release before it.
adaptive_sampling_on became nlec_on in 0.86 and segmentation_on in 0.87, so a name may
change more than once; follow the chain.
- Change the pin and run your code and tests. A removed name raises an
AttributeError
naming its replacement, a renamed or keyword-only argument raises TypeError, and
from sweatstack import <removed name> raises Python's plain ImportError. The rows marked
Silent don't fail at all: check each one by hand.
- Don't add compatibility shims. Move every call site to the new name.
The install line changes too: Python 3.10 or later (0.78), the frame library as an extra
(sweatstack[pandas], [polars] or [arrow]; [streamlit] includes pandas) and pyarrow 20 or
later (0.84, 0.89), and no [jupyter] extra (0.91).
Prompt for a coding agent
Fill in the two versions and paste it into your agent. It works for any distance between them.
Upgrade this project from sweatstack <OLD> to sweatstack <NEW>.
1. Inventory. Before changing anything, list every use of the SDK: calls on a Client or on the
sweatstack module (also as "ss"), StreamlitAuth and sweatstack.fastapi, model fields read
from responses, frame columns and indexes, exceptions caught, and the dependency line with
its extras.
2. Plan. Read https://docs.sweatstack.no/learn/python/upgrading/ (the same content is
UPGRADING.md and the "Upgrading" sections of CHANGELOG.md in
https://github.com/SweatStack/sweatstack-python). Take every release between <OLD> and <NEW>
that has an Upgrading section, oldest first, and map each row of its table onto the
inventory. A name may be renamed more than once across releases; follow the chain to the
final name.
3. Apply the changes, oldest release first. Do not add compatibility shims.
4. Install: uv add "sweatstack[<extras>]==<NEW>". The frame library is an extra ([pandas],
[polars] or [arrow]); [streamlit] includes pandas.
5. Run the code and the tests. Fix every AttributeError (its message names the replacement),
ImportError and TypeError. Then check every row marked "Silent" against the inventory:
those change behaviour without raising.
6. For the current API, install the SDK's agent skill (npx skills add
SweatStack/sweatstack-python) or read https://docs.sweatstack.no/llms.txt.
Report what you changed, and every item of the inventory you could not map to a table row.
0.94
Upgrading
| 0.93 |
0.94 |
Client(api_key=...) without a refresh token renewed with SWEATSTACK_REFRESH_TOKEN or the saved refresh token |
Silent: it never borrows one, and raises SweatStackTokenRefreshError on expiry. Pass refresh_token= too, or pass neither to use the environment or the saved tokens |
oauth.exchange_code(...) saved the tokens on this machine |
Silent: pass persist=True to keep that |
StreamlitAuth.behind_proxy() sent the handshake token as it was after expiry (a 401 from the API) |
Forward X-SweatStack-Refresh-Token from the proxy and the session renews itself. Without it, calls after expiry raise SweatStackTokenRefreshError with a reload hint |
0.93
Upgrading
| 0.92 |
0.93 |
issue.status is a CapabilityStatus |
IssueStatus: action_required or syncing. The .value strings are unchanged |
Branching on issue.code being activity_history_unavailable, dailies_unavailable or workouts_unavailable |
Silent: these codes are never sent. Read profile.status().capabilities instead, where unavailable means no connected source provides it |
| Only the known issue codes |
Also activities_blocked_upstream, dailies_no_source and workouts_no_source: each action_required with a destination; handle them like any other issue |
| An app that uses dailies or workouts |
Declare them in the app's capabilities (ApplicationCreate.capabilities); issues are reported only for declared capabilities |
0.92
Upgrading
| 0.91 |
0.92 |
user.issue.action_url |
client.portal.sessions.create(user.issue.destination, return_url=...).url |
Client(client_id=..., client_secret=...).portal.sessions.create(...) |
the same call on the client holding the user's token |
portal.sessions.create(destination) without return_url linking back to your app |
Silent: the Portal tells the user to close the page; pass return_url= (under a registered redirect URI) to link back |
0.91
Upgrading
Removed names raise an AttributeError naming the replacement, so running your code points at
each change. The module-level functions moved the same way: sweatstack.get_activities() is
sweatstack.activities.list(). Only from sweatstack import get_activities fails with Python's
plain ImportError; look the name up in the table.
| 0.90 |
0.91 |
get_activities |
client.activities.list() |
get_activity |
client.activities.retrieve(activity_id) |
get_latest_activity |
client.activities.latest() |
get_activity_data |
client.activities.data(activity_id) |
get_activity_mean_max |
client.activities.mean_max(activity_id, metric=...) |
get_activity_awd |
client.activities.awd(activity_id) |
get_latest_activity_data |
client.activities.data(client.activities.latest().id) |
get_latest_activity_mean_max |
client.activities.mean_max(client.activities.latest().id, metric=...) |
get_longitudinal_data |
client.activities.longitudinal.data(...) |
get_longitudinal_mean_max |
client.activities.longitudinal.mean_max(...) |
get_longitudinal_awd |
client.activities.longitudinal.awd(...) |
upload |
client.activities.upload(files) |
get_backfill_status |
client.activities.backfill_status() |
watch_backfill_status |
client.activities.watch_backfill_status() |
set_activity_app_metadata |
client.activities.app_metadata.set(activity_id, data=...) |
delete_activity_app_metadata |
client.activities.app_metadata.delete(activity_id) |
get_traces |
client.traces.list() |
create_trace |
client.traces.create(...) |
update_trace |
client.traces.replace(trace_id, ...) |
delete_trace |
client.traces.delete(trace_id) |
set_trace_app_metadata |
client.traces.app_metadata.set(trace_id, data=...) |
delete_trace_app_metadata |
client.traces.app_metadata.delete(trace_id) |
get_tests |
client.tests.list() |
get_test |
client.tests.retrieve(test_id) |
create_test |
client.tests.create(...) |
update_test |
client.tests.replace(test_id, ...) |
delete_test |
client.tests.delete(test_id) |
set_test_app_metadata |
client.tests.app_metadata.set(test_id, data=...) |
delete_test_app_metadata |
client.tests.app_metadata.delete(test_id) |
get_dailies |
client.dailies.list(measure, start=..., end=...) |
set_daily |
client.dailies.set(measure, date=..., value=...) |
delete_daily |
client.dailies.delete(measure, date=...) |
get_profile_status |
client.profile.status() |
get_sports |
client.profile.sports() |
get_tags |
client.profile.tags() |
set_user_app_metadata |
client.profile.app_metadata.set(data=...) |
delete_user_app_metadata |
client.profile.app_metadata.delete() |
get_users |
client.users.list() |
get_user |
client.users.list(name=...): every match, so check the length |
create_user |
client.users.create(first_name=...) |
get_teams |
client.teams.list() |
get_team_users |
client.teams.users(team_id) |
get_team_user |
client.teams.users(team_id, name=...): every match |
get_authorized_teams |
client.teams.authorized() |
authorize_team |
client.teams.authorize(team_id) |
create_portal_session |
client.portal.sessions.create(destination) |
get_userinfo |
client.oauth.userinfo() |
get_authorization_url |
client.oauth.authorization_url(...) |
exchange_code_for_token |
client.oauth.exchange_code(...) |
generate_pkce_params |
client.oauth.generate_pkce_params() |
switch_user |
client.delegated_client(user): a new client; this one is left unchanged |
switch_back |
keep the original client, or client.principal_client() |
client.jwt |
client.api_key |
Changes that a rename alone doesn't cover. Most raise a TypeError naming the argument; the
ones marked silent don't fail loudly, so check for them.
| 0.90 |
0.91 |
sports=["cycling"] (every filter, and StreamlitAuth.select_activity) |
sport="cycling" or sport=["cycling", "running"] |
get_activity_mean_max(id, "power"), get_activity_awd(id, "power") |
activities.mean_max(id, metric="power"), activities.awd(id, metric="power") |
get_activity_data(id, "power", ["power"]) |
activities.data(id, segmentation_on="power", metrics=["power"]) |
create_user("Carla", "Smith") |
users.create(first_name="Carla", last_name="Smith") |
get_sports(True), authorize_team(id, scopes) |
profile.sports(only_root=True), teams.authorize(id, scopes=scopes) |
Positional arguments to get_authorization_url() / exchange_code_for_token() |
Keywords: oauth.authorization_url(client_id=..., ...) |
get_latest_activity(start=, end=, tag=) |
activities.latest(sport=); for the rest, activities.list(start=..., end=..., tags=..., limit=1) |
get_latest_activity() raising StopIteration when there is none |
Silent: activities.latest() returns None; check before using .id |
get_longitudinal_*(date=..., window_days=...) |
start= and end= |
get_longitudinal_*(...) without sport |
sport= is required (the API already rejected a request without one) |
upload(files, sport=...) |
activities.upload(files): no sport=; a CSV needs a sport column |
upload(...) returning a dict |
Silent: activities.upload(...) returns list[SourceResponse]; read .status / .error |
exchange_code_for_token(..., redirect_uri=...) |
oauth.exchange_code(...) without redirect_uri= |
get_user("Carla") returning one UserSummary |
Silent: users.list(name="Carla") returns a list, every match; search_mode= is gone |
switch_user("Carla") (a name) |
client.delegated_client(users.list(name="Carla")[0]): a name is not accepted |
client.jwt |
client.api_key |
sweatlab, sweatshell, pip install "sweatstack[jupyter]" |
uv add "sweatstack[pandas]" jupyterlab; sweatstack.authenticate() in the first cell |
New defaults that change behaviour without changing code: GET, PUT and DELETE requests are
retried (Client(max_retries=0) restores single attempts), and requests time out after 60 s
(Client(timeout=...)).
Migration prompt. Paste this into your coding agent:
Upgrade this codebase to sweatstack 0.91, which moved every method to a resource namespace.
The full upgrade guide, with both tables, is the "Upgrading" section of the 0.91.0 entry in
https://github.com/SweatStack/sweatstack-python/blob/main/CHANGELOG.md
(also at https://docs.sweatstack.no/learn/python/upgrading/). Read it first.
Find every use of the sweatstack client (a Client instance, the sweatstack module, and
StreamlitAuth or FastAPI user clients) and:
1. Rename calls per the first table, e.g. get_activities() -> activities.list(),
get_activity_data(id) -> activities.data(id),
get_longitudinal_mean_max(...) -> activities.longitudinal.mean_max(...),
update_trace(id, ...) -> traces.replace(id, ...), get_users() -> users.list().
2. Apply every row of the second table. In particular:
- sports= -> sport= (one value or a list).
- Pass by keyword: metric= (mean_max, awd), segmentation_on= and metrics= (data),
first_name= and last_name= (users.create), only_root= (profile.sports),
scopes= (teams.authorize), and all arguments of oauth.authorization_url() and
oauth.exchange_code().
- Longitudinal methods: sport= is required, and date=/window_days= become start=/end=
(datetime.date objects).
- activities.latest() takes only sport= and returns None when there is no activity:
handle None.
- activities.upload() takes no sport= (a CSV needs a sport column) and returns
list[SourceResponse], not a dict.
- oauth.exchange_code() takes no redirect_uri=.
- get_user(x) -> users.list(name=x), which returns a list of every match.
- switch_user(user) -> athlete = client.delegated_client(user_id_or_summary); use that
new client for the athlete's calls. It doesn't accept a name: look the user up first.
switch_back() -> keep using the original client.
- client.jwt -> client.api_key.
3. Replace get_latest_activity_data(...) with activities.data(activities.latest().id, ...),
after checking that latest() returned an activity.
4. If the project used sweatlab, sweatshell or the [jupyter] extra, depend on
"sweatstack[pandas]" (or [polars]) and jupyterlab instead.
Then run the code and the tests. Fix any AttributeError (its message names the replacement)
and TypeError (a renamed or keyword-only argument). Do not add compatibility shims.
0.90
Upgrading
| 0.89 |
0.90 |
A mean-max frame with one row per intensity: the metric value, and the duration it was held |
Silent: one row per duration, 19 by default: duration, the metric (power or speed) and start; the longitudinal curve adds activity_id, sport and after. The column names still exist, so code keeps running with the axes swapped: df[df.power >= 300] becomes df[df.duration == 300] |
| The full curve |
durations="all", or a list of seconds |
get_activity_mean_max(id, "power", True) or segmentation=True |
Drop the argument |
get_longitudinal_mean_max(..., by="duration") or by="intensity" |
Drop by=; every curve is by duration |
0.89
Upgrading
- Change the install line:
uv add "sweatstack[pandas]" (or [polars], or [arrow] for
DuckDB). Streamlit and
Jupyter users: sweatstack[streamlit] / sweatstack[jupyter] already include pandas.
- To keep pandas frames in an environment that also has Polars, add
sweatstack.set_output("pandas") once (or Client(output="pandas")).
- Replace
as_dataframe=True with output="pandas".
- Search for
.index, .loc[<timestamp>], .resample(, .plot() on frames from the
time-series, mean-max, AWD and dailies methods. Where the index mattered, add
.set_index("timestamp") (or the metric name, or "date") right after the call.
- Silent:
as_dataframe=True on get_activities and get_traces kept nested fields as
dict columns (df["summary"][0]["power"]). output="pandas" flattens them to dotted
columns (df["summary.power.mean"]); Polars and Arrow give structs (df.unnest("summary")).
0.88
Upgrading
| 0.87 |
0.88 |
create_trace(timestamp=datetime(2026, 1, 1, 10, 0)), and start/end on create_test, update_test, update_trace |
Aware: datetime(2026, 1, 1, 10, 0, tzinfo=ZoneInfo("Europe/Amsterdam")) or datetime.now(timezone.utc). Naive raises ValueError |
activity.start, trace.timestamp shown as the athlete's wall-clock time |
Silent: these are UTC now. Show start_local, end_local and timestamp_local, which are always present |
0.87
Upgrading
| 0.86 |
0.87 |
nlec_on= on get_activity_data, get_latest_activity_data, get_longitudinal_data |
segmentation_on= |
nlec= on get_activity_mean_max, get_latest_activity_mean_max |
segmentation= (removed in 0.90) |
0.86
Upgrading
| 0.85 |
0.86 |
adaptive_sampling_on= on get_activity_data, get_latest_activity_data, get_longitudinal_data |
nlec_on= (segmentation_on= from 0.87) |
adaptive_sampling= on get_activity_mean_max, get_latest_activity_mean_max |
nlec= (segmentation= from 0.87; removed in 0.90) |
0.81
Upgrading
| 0.80 |
0.81 |
Sport.cycling_road and other enum members |
Sport("cycling.road"); Sport.parse(value) for external input |
sport.value |
str(sport) (with modifiers) or sport.code |
sport.display_name() |
sport.label |
sport.parent_sport() |
sport.parent |
sport.is_sub_sport_of(x) |
sport.is_subsport_of(x) (one Sport; any(...) for a list) |
sport.root_sport(), sport.is_root_sport() |
Sport(sport.code.split(".")[0]), "." not in sport.code and not sport.modifiers |
for s in Sport |
for s in Sport.all() |
Hardcoded cycling.trainer, running.treadmill, rowing.ergometer, cycling.tt, cycling.mountainbike, cross_country_skiing[.classic\|.skate], unknown |
Silent in string comparisons: cycling+stationary, running+stationary, rowing+stationary, cycling.time_trial, cycling.mountain, xc_skiing[.classic\|.skate], generic |
Migration prompt. Paste this into your coding agent:
Migrate this codebase to sweatstack 0.81.0, which replaces its custom `Sport` enum with the
OpenSportTaxonomy type (`open_sport_taxonomy.Sport`). `from sweatstack import Sport` is now that type.
1. Construction (there are no enum members like `Sport.cycling_road`):
- Known sport in app code -> `Sport("cycling.road")` (raises on an unknown code/modifier).
- From an API/string value -> `Sport.parse(value)` (permissive; never raises; preserves unknown).
- For typed annotations/autocomplete of the standard catalogue ->
`from open_sport_taxonomy import StandardSport` (a Literal).
2. These sport VALUES changed; update hardcoded strings or members:
cycling.trainer->cycling+stationary, running.treadmill->running+stationary,
rowing.ergometer->rowing+stationary, cycling.tt->cycling.time_trial,
cycling.mountainbike->cycling.mountain, cross_country_skiing[.classic|.skate]->xc_skiing[...],
unknown->generic. ("stationary" etc. are now modifiers, appended with `+`; see sport.modifiers.)
3. Methods / attributes:
Sport.cycling_road -> Sport("cycling.road")
sport.value -> str(sport) (canonical, incl. modifiers) or sport.code
sport.display_name() -> sport.label
sport.parent_sport() -> sport.parent
sport.is_sub_sport_of(x) -> sport.is_subsport_of(x) (x is a single Sport; for a list use
any(sport.is_subsport_of(s) for s in xs))
sport.root_sport() -> Sport(sport.code.split(".")[0])
sport.is_root_sport() -> ("." not in sport.code and not sport.modifiers)
for s in Sport: ... -> for s in Sport.all(): ...
4. Equality works: `activity.sport == Sport("cycling+stationary")`. Compare against the NEW value.
After editing, run the test suite and fix any remaining references. Do not add a compatibility shim;
migrate call sites to the OST API directly.
0.78
Upgrading
| 0.77 |
0.78 |
from sweatstack import make_dataframe_streamlit_compatible (also convert_to_standard_dtypes, decode_jwt_body) |
from sweatstack.utils import make_dataframe_streamlit_compatible |
sweatstack.DEFAULT_URL |
sweatstack.constants.DEFAULT_URL |
| Python 3.9 |
Python 3.10 or later |
0.76
Upgrading
| 0.75 |
0.76 |
except httpx.HTTPStatusError as e |
except SweatStackAPIError as e, or a subclass: SweatStackNotFoundError (404), SweatStackAuthError (401, 403), SweatStackRateLimitError (429), SweatStackBadRequestError (other 4xx), SweatStackServerError (5xx) |
e.response.status_code, e.request.url |
e.status_code, e.url; also e.method, e.request_id, e.body |
except ValueError around a call, for a 422 |
except SweatStackBadRequestError |
except httpx.ConnectError, httpx.TimeoutException |
except SweatStackConnectionError |
from sweatstack import TokenRefreshError |
from sweatstack import SweatStackTokenRefreshError |
0.72
Upgrading
| 0.71 |
0.72 |
SWEATSTACK_LOCAL_CACHE=true in the environment |
sweatstack.enable_cache() in code. Silent: the variable is ignored, so nothing is cached until you call it |
SWEATSTACK_CACHE_DIR=/some/dir |
sweatstack.enable_cache("/some/dir") |