sweatstack 0.89.0__tar.gz → 0.91.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 (127) hide show
  1. sweatstack-0.91.0/.git-blame-ignore-revs +3 -0
  2. {sweatstack-0.89.0 → sweatstack-0.91.0}/.gitignore +5 -3
  3. sweatstack-0.91.0/.gitleaks.toml +9 -0
  4. sweatstack-0.91.0/AGENTS.md +378 -0
  5. {sweatstack-0.89.0 → sweatstack-0.91.0}/CHANGELOG.md +245 -1
  6. sweatstack-0.91.0/DEVELOPMENT.md +169 -0
  7. sweatstack-0.91.0/Makefile +19 -0
  8. sweatstack-0.91.0/PKG-INFO +103 -0
  9. sweatstack-0.91.0/README.md +59 -0
  10. sweatstack-0.91.0/evals/__init__.py +1 -0
  11. sweatstack-0.91.0/evals/agent/__init__.py +5 -0
  12. sweatstack-0.91.0/evals/agent/agent.py +47 -0
  13. sweatstack-0.91.0/evals/agent/cases.py +132 -0
  14. sweatstack-0.91.0/evals/agent/evaluators.py +131 -0
  15. sweatstack-0.91.0/evals/agent/harness.py +159 -0
  16. sweatstack-0.91.0/evals/agent/lifecycle.py +64 -0
  17. sweatstack-0.91.0/evals/agent/run.py +94 -0
  18. {sweatstack-0.89.0 → sweatstack-0.91.0}/examples/fastapi_webhooks_example.py +4 -5
  19. {sweatstack-0.89.0 → sweatstack-0.91.0}/examples/send_webhook.py +9 -7
  20. {sweatstack-0.89.0 → sweatstack-0.91.0}/plans/002_TYPED_EXCEPTIONS.md +1 -2
  21. {sweatstack-0.89.0 → sweatstack-0.91.0}/plans/003_trace_test_linking.md +7 -9
  22. {sweatstack-0.89.0 → sweatstack-0.91.0}/plans/005_ost_sport_bridge.md +11 -25
  23. {sweatstack-0.89.0 → sweatstack-0.91.0}/plans/006_output_backends.md +20 -25
  24. {sweatstack-0.89.0 → sweatstack-0.91.0}/plans/007_account_status_userinfo_portal.md +3 -5
  25. sweatstack-0.91.0/plans/008_sdk_documentation.md +327 -0
  26. sweatstack-0.91.0/plans/009_road_to_1_0.md +736 -0
  27. {sweatstack-0.89.0 → sweatstack-0.91.0}/pyproject.toml +31 -15
  28. sweatstack-0.91.0/skills/sweatstack-python/SKILL.md +109 -0
  29. sweatstack-0.91.0/skills/sweatstack-python/api.md +124 -0
  30. sweatstack-0.91.0/skills/sweatstack-python/data-models.md +65 -0
  31. {sweatstack-0.89.0/.claude → sweatstack-0.91.0}/skills/sweatstack-python/fastapi.md +9 -8
  32. {sweatstack-0.89.0/.claude → sweatstack-0.91.0}/skills/sweatstack-python/streamlit.md +9 -9
  33. sweatstack-0.91.0/src/sweatstack/__init__.py +34 -0
  34. sweatstack-0.91.0/src/sweatstack/_docs.py +391 -0
  35. {sweatstack-0.89.0 → sweatstack-0.91.0}/src/sweatstack/_frames.py +45 -18
  36. sweatstack-0.91.0/src/sweatstack/_renames.py +93 -0
  37. sweatstack-0.91.0/src/sweatstack/_transport.py +109 -0
  38. {sweatstack-0.89.0 → sweatstack-0.91.0}/src/sweatstack/cli.py +23 -12
  39. sweatstack-0.91.0/src/sweatstack/client.py +1131 -0
  40. sweatstack-0.91.0/src/sweatstack/constants.py +1 -0
  41. {sweatstack-0.89.0 → sweatstack-0.91.0}/src/sweatstack/exceptions.py +9 -1
  42. {sweatstack-0.89.0 → sweatstack-0.91.0}/src/sweatstack/fastapi/__init__.py +9 -9
  43. {sweatstack-0.89.0 → sweatstack-0.91.0}/src/sweatstack/fastapi/access_token_cache.py +6 -13
  44. {sweatstack-0.89.0 → sweatstack-0.91.0}/src/sweatstack/fastapi/config.py +7 -7
  45. {sweatstack-0.89.0 → sweatstack-0.91.0}/src/sweatstack/fastapi/dependencies.py +13 -20
  46. {sweatstack-0.89.0 → sweatstack-0.91.0}/src/sweatstack/fastapi/routes.py +19 -14
  47. {sweatstack-0.89.0 → sweatstack-0.91.0}/src/sweatstack/fastapi/webhooks.py +0 -1
  48. {sweatstack-0.89.0 → sweatstack-0.91.0}/src/sweatstack/openapi_schemas.py +422 -8
  49. sweatstack-0.91.0/src/sweatstack/resources/__init__.py +38 -0
  50. sweatstack-0.91.0/src/sweatstack/resources/_app_metadata.py +247 -0
  51. sweatstack-0.91.0/src/sweatstack/resources/_base.py +83 -0
  52. sweatstack-0.91.0/src/sweatstack/resources/activities.py +1001 -0
  53. sweatstack-0.91.0/src/sweatstack/resources/dailies.py +176 -0
  54. sweatstack-0.91.0/src/sweatstack/resources/oauth.py +170 -0
  55. sweatstack-0.91.0/src/sweatstack/resources/portal.py +87 -0
  56. sweatstack-0.91.0/src/sweatstack/resources/profile.py +106 -0
  57. sweatstack-0.91.0/src/sweatstack/resources/teams.py +122 -0
  58. sweatstack-0.91.0/src/sweatstack/resources/tests.py +337 -0
  59. sweatstack-0.91.0/src/sweatstack/resources/traces.py +314 -0
  60. sweatstack-0.91.0/src/sweatstack/resources/users.py +199 -0
  61. {sweatstack-0.89.0 → sweatstack-0.91.0}/src/sweatstack/schemas.py +34 -11
  62. {sweatstack-0.89.0 → sweatstack-0.91.0}/src/sweatstack/streamlit.py +80 -65
  63. {sweatstack-0.89.0 → sweatstack-0.91.0}/src/sweatstack/utils.py +13 -5
  64. {sweatstack-0.89.0 → sweatstack-0.91.0}/tests/test_access_token_cache.py +10 -20
  65. {sweatstack-0.89.0 → sweatstack-0.91.0}/tests/test_account_status.py +78 -31
  66. {sweatstack-0.89.0 → sweatstack-0.91.0}/tests/test_dailies.py +0 -2
  67. sweatstack-0.91.0/tests/test_docs_check.py +140 -0
  68. sweatstack-0.91.0/tests/test_docstrings.py +71 -0
  69. {sweatstack-0.89.0 → sweatstack-0.91.0}/tests/test_dtype_conversion.py +214 -140
  70. {sweatstack-0.89.0 → sweatstack-0.91.0}/tests/test_exceptions.py +3 -5
  71. {sweatstack-0.89.0 → sweatstack-0.91.0}/tests/test_frames.py +50 -20
  72. {sweatstack-0.89.0 → sweatstack-0.91.0}/tests/test_identity.py +38 -12
  73. sweatstack-0.91.0/tests/test_longitudinal_mean_max_after.py +147 -0
  74. {sweatstack-0.89.0 → sweatstack-0.91.0}/tests/test_metadata.py +46 -12
  75. {sweatstack-0.89.0 → sweatstack-0.91.0}/tests/test_output.py +98 -60
  76. {sweatstack-0.89.0 → sweatstack-0.91.0}/tests/test_portal.py +19 -9
  77. sweatstack-0.91.0/tests/test_public_hygiene.py +78 -0
  78. {sweatstack-0.89.0 → sweatstack-0.91.0}/tests/test_public_surface.py +39 -16
  79. sweatstack-0.91.0/tests/test_renames.py +61 -0
  80. sweatstack-0.91.0/tests/test_resources.py +263 -0
  81. sweatstack-0.91.0/tests/test_segmentation.py +92 -0
  82. sweatstack-0.91.0/tests/test_skill.py +49 -0
  83. {sweatstack-0.89.0 → sweatstack-0.91.0}/tests/test_sport_ost.py +32 -13
  84. {sweatstack-0.89.0 → sweatstack-0.91.0}/tests/test_teams.py +0 -1
  85. {sweatstack-0.89.0 → sweatstack-0.91.0}/tests/test_tests.py +1 -3
  86. sweatstack-0.91.0/tests/test_transport.py +218 -0
  87. {sweatstack-0.89.0 → sweatstack-0.91.0}/tests/test_webhooks.py +3 -3
  88. sweatstack-0.91.0/tests/test_wire_names.py +93 -0
  89. {sweatstack-0.89.0 → sweatstack-0.91.0}/tests/test_write_timezone_validation.py +12 -15
  90. sweatstack-0.91.0/tests/typing/output_types.py +41 -0
  91. sweatstack-0.91.0/uv.lock +3357 -0
  92. sweatstack-0.89.0/.claude/settings.local.json +0 -39
  93. sweatstack-0.89.0/.claude/skills/sweatstack-python/SKILL.md +0 -55
  94. sweatstack-0.89.0/.claude/skills/sweatstack-python/client.md +0 -500
  95. sweatstack-0.89.0/.claude/skills/sweatstack-python/data-models.md +0 -77
  96. sweatstack-0.89.0/AGENTS.md +0 -285
  97. sweatstack-0.89.0/DEVELOPMENT.md +0 -105
  98. sweatstack-0.89.0/Makefile +0 -12
  99. sweatstack-0.89.0/PKG-INFO +0 -146
  100. sweatstack-0.89.0/README.md +0 -96
  101. sweatstack-0.89.0/docs/conf.py +0 -26
  102. sweatstack-0.89.0/docs/everything.rst +0 -265
  103. sweatstack-0.89.0/docs/index.rst +0 -7
  104. sweatstack-0.89.0/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -345
  105. sweatstack-0.89.0/src/sweatstack/__init__.py +0 -26
  106. sweatstack-0.89.0/src/sweatstack/client.py +0 -3614
  107. sweatstack-0.89.0/src/sweatstack/constants.py +0 -1
  108. sweatstack-0.89.0/src/sweatstack/ipython_init.py +0 -8
  109. sweatstack-0.89.0/src/sweatstack/jupyterlab_oauth2_startup.py +0 -29
  110. sweatstack-0.89.0/src/sweatstack/sweatshell.py +0 -14
  111. sweatstack-0.89.0/tests/test_longitudinal_mean_max_after.py +0 -133
  112. sweatstack-0.89.0/tests/test_segmentation.py +0 -69
  113. sweatstack-0.89.0/uv.lock +0 -3114
  114. {sweatstack-0.89.0 → sweatstack-0.91.0}/.github/workflows/ci.yml +0 -0
  115. {sweatstack-0.89.0 → sweatstack-0.91.0}/.python-version +0 -0
  116. {sweatstack-0.89.0 → sweatstack-0.91.0}/CONTRIBUTING.md +0 -0
  117. {sweatstack-0.89.0 → sweatstack-0.91.0}/LICENSE +0 -0
  118. {sweatstack-0.89.0 → sweatstack-0.91.0}/plans/001a_tests.md +0 -0
  119. {sweatstack-0.89.0 → sweatstack-0.91.0}/plans/001b_metadata.md +0 -0
  120. {sweatstack-0.89.0 → sweatstack-0.91.0}/plans/001c_dailies.md +0 -0
  121. {sweatstack-0.89.0 → sweatstack-0.91.0}/plans/004_codebase_hygiene.md +0 -0
  122. {sweatstack-0.89.0 → sweatstack-0.91.0}/src/sweatstack/fastapi/models.py +0 -0
  123. {sweatstack-0.89.0 → sweatstack-0.91.0}/src/sweatstack/fastapi/session.py +0 -0
  124. {sweatstack-0.89.0 → sweatstack-0.91.0}/src/sweatstack/fastapi/token_stores.py +0 -0
  125. {sweatstack-0.89.0 → sweatstack-0.91.0}/src/sweatstack/py.typed +0 -0
  126. {sweatstack-0.89.0 → sweatstack-0.91.0}/tests/__init__.py +0 -0
  127. {sweatstack-0.89.0 → sweatstack-0.91.0}/tests/test_trace_test_linking.py +0 -0
@@ -0,0 +1,3 @@
1
+ # Formatting-only commits, skipped by git blame (git config blame.ignoreRevsFile .git-blame-ignore-revs)
2
+ # Format with ruff
3
+ 61993be257e0873621e01baadbd25e881e6c3521
@@ -40,6 +40,8 @@ dump/
40
40
  # Playground
41
41
  playground/
42
42
 
43
- # Claude skills (except our own)
44
- .claude/skills/*
45
- !.claude/skills/sweatstack-python/
43
+ # Agent tooling (local only; the published skill lives in skills/)
44
+ .claude/
45
+
46
+ # Agent test workspaces and program output: real user data
47
+ evals/agent/runs/
@@ -0,0 +1,9 @@
1
+ # gitleaks configuration: the default rules, plus known false positives.
2
+ # Run before going public and before each release: `gitleaks git .`
3
+
4
+ [extend]
5
+ useDefault = true
6
+
7
+ [[allowlists]]
8
+ description = "The Fernet key the tests use; it decodes to 'test-only-key-for-unit-tests-32b'"
9
+ regexes = ['''dGVzdC1vbmx5LWtleS1mb3ItdW5pdC10ZXN0cy0zMmI=''']
@@ -0,0 +1,378 @@
1
+ # AGENTS.md
2
+
3
+ Working agreement for agents (and humans) changing this codebase. Read end
4
+ to end before your first change. Mechanics — install, test, regen, release —
5
+ live in [DEVELOPMENT.md](DEVELOPMENT.md).
6
+
7
+
8
+ ## Public repository
9
+
10
+ This repository is public, and so is everything in its history. Treat every
11
+ change as published the moment it is committed: code, comments, docstrings,
12
+ tests and fixtures, `plans/`, `skills/`, examples, notebooks, the CHANGELOG,
13
+ commit messages and PR descriptions. Removing something later does not
14
+ unpublish it.
15
+
16
+ Never commit:
17
+
18
+ - **Secrets and credentials**: API keys, tokens, client secrets, session
19
+ keys, `.env` contents. Not even expired or test-account ones.
20
+ - **Real identifiers**: user, app, activity or team IDs, emails, client
21
+ IDs, or truncated prefixes of them. Fixtures and examples use obvious
22
+ fakes: `app_123`, `user_123`, `YOUR_CLIENT_ID`.
23
+ - **Customers and their behaviour**: names of apps, companies or people
24
+ using SweatStack, how many there are, what they call or filter on, or
25
+ anything learned from server logs, traffic or support conversations.
26
+ Write "SDK users" or "downstream apps".
27
+ - **The private server codebase**: its file paths, function names, commit
28
+ hashes, branch names and plan numbers. Describe the server by its public
29
+ contract: the OpenAPI schema and docs.sweatstack.no. "The server now
30
+ returns X" is fine; "see server plan N" is not.
31
+ - **Local environments**: home directories, machine paths, names of
32
+ private sibling repos, personal data in notebook outputs or test data.
33
+ Commit notebooks with outputs cleared.
34
+
35
+ When unsure, leave it out and ask. `tests/test_public_hygiene.py` catches
36
+ the patterns a regex can (real-looking IDs, JWTs, keys, home paths, server
37
+ references, notebook outputs). Names and behaviour of customers need you
38
+ to notice them; there is no list to check against, because the list itself
39
+ would be the leak.
40
+
41
+
42
+ ## Guiding principle: mirror the REST API
43
+
44
+ This client is a thin Python projection of the SweatStack REST API. Every endpoint group is a
45
+ resource namespace on `Client`, and every name follows from the URL by eight rules. Apply them;
46
+ don't invent names.
47
+
48
+ | # | Rule | Examples |
49
+ |---|---|---|
50
+ | R1 | **The first URL segment after `/api/v1/` is the top-level namespace**, a typed `cached_property` on `Client`. | `/activities/...` → `client.activities` |
51
+ | R2 | **Collection verbs follow Stripe:** `list`, `retrieve`, `create`, `delete`. | `GET /tests/{id}` → `tests.retrieve(id)` |
52
+ | R3 | **Name writes by what they do, not by the HTTP verb.** Overwrites every field and clears what you leave out: `replace`. Changes only the fields you pass: `update`. Read the server code to tell which; the verb doesn't say. | `PUT /traces/{id}` → `traces.replace`; `PUT /users/{id}` → `users.update` |
53
+ | R4 | **A single value that is set or cleared** (an upsert, or a blob with no ID) uses `set` and `delete`. | `POST /dailies/{measure}` → `dailies.set` |
54
+ | R5 | **A nested collection is a nested namespace**, parent ID first. | `/portal/sessions` → `portal.sessions.create`; `.../{id}/app-metadata` → `activities.app_metadata.set(id, ...)` |
55
+ | R6 | **A sub-path with a single `GET` is a method named after the noun.** | `GET /activities/{id}/data` → `activities.data(id)` |
56
+ | R7 | **An action endpoint (`POST .../<verb>`) is a method named after the verb.** | `POST /activities/upload` → `activities.upload` |
57
+ | R8 | **Parameter names are the wire names.** A repeatable query parameter takes one value or a list under the same name (normalise with `Resource._wire`). | `?sport=a&sport=b` → `sport=["a", "b"]` or `sport="a"` |
58
+
59
+ Match the server's field names, enum values and write semantics. Don't redesign the API in
60
+ Python.
61
+
62
+ **Deliberate deviations** (these are the only ones; don't invent more):
63
+
64
+ - Path-param IDs are positional Python args (`tests.retrieve(test_id)`); everything else is
65
+ keyword-only.
66
+ - Enum-typed params accept `Enum | str` so callers don't need to import the enum.
67
+ - `limit` and `offset` on list methods are SDK-level: `limit` is how many items the caller gets,
68
+ and `Resource._paginate` pages through the endpoint to get them.
69
+ - A `traces=` query parameter is `trace_resolution=` on `tests.retrieve`, where the wire name
70
+ would be ambiguous. The docstring states the mapping.
71
+ - `name=` on `users.list` and `teams.users` filters client-side (case-insensitive substring of
72
+ the display name) until the server offers the filter; then it becomes the wire parameter
73
+ with the same name and semantics. Don't add other client-side filters.
74
+ - `output=` is a client-side choice of container on the data endpoints. See "Output backends".
75
+ - `activities.longitudinal` groups the three `/activities/longitudinal-*` endpoints.
76
+ - Convenience composites that wrap several calls are a last resort. The 0.91 redesign removed
77
+ them (`get_latest_activity_data` became chaining); add one only when a real workflow is
78
+ awkward through the literal surface.
79
+
80
+
81
+ ## Project shape
82
+
83
+ ```
84
+ src/sweatstack/
85
+ ├── openapi_schemas.py # AUTO-GENERATED. Never hand-edit.
86
+ ├── schemas.py # Re-exports (incl. OST Sport/Modifier) + Metric/Scope/DailyMeasure helpers.
87
+ ├── _frames.py # output= backends: parquet/models -> pandas/polars/arrow/bytes.
88
+ ├── _renames.py # Removed names -> replacement hints. Permanent; see "Breaking changes".
89
+ ├── _transport.py # Retry policy and the per-request Session on the client's connection pool.
90
+ ├── exceptions.py # Public error contract. No httpx types leak.
91
+ ├── client.py # Client: config, auth, delegation, transport (_request), resource attributes,
92
+ │ # and the module-level interface (one shared default client).
93
+ ├── resources/ # One module per URL segment: activities.py, traces.py, tests.py, ...
94
+ │ ├── _base.py # Resource, _paginate, _wire, shared parameter types.
95
+ │ └── _app_metadata.py # One app-metadata class per endpoint (Activity/Trace/Test/Profile).
96
+ ├── utils.py # Dataframe / JWT helpers.
97
+ ├── streamlit.py # Streamlit integration (optional extra).
98
+ ├── fastapi/ # FastAPI integration (optional extra).
99
+ └── cli.py # Entry points (schema regeneration).
100
+
101
+ skills/sweatstack-python/ # The published agent skill (npx skills add). Update with the public surface.
102
+ plans/ # Design plans, numbered. Public: see "Public repository".
103
+ tests/typing/ # Static type tests, checked by ty, never run by pytest.
104
+ ```
105
+
106
+ `.claude/` is git-ignored: it is local agent tooling only.
107
+
108
+ Adding a new Pydantic model from the server takes three steps:
109
+
110
+ 1. Regen `openapi_schemas.py` ([DEVELOPMENT.md](DEVELOPMENT.md#regenerating-openapi_schemaspy)).
111
+ 2. Re-export it from `schemas.py`.
112
+ 3. Import it into `client.py` and add it to `__all__` so `from sweatstack import *` exposes it.
113
+
114
+ Skipping step 3 silently breaks the public surface. Same for new enums.
115
+
116
+ Adding a new endpoint: find its namespace by R1, its name by R2–R7, write the method in that
117
+ resource module (template below), and add it to `skills/sweatstack-python/api.md`. A new URL
118
+ segment is a new resource module plus a `cached_property` on `Client`; the module level mirrors
119
+ it automatically.
120
+
121
+
122
+ ## Hard rules
123
+
124
+ - **`uv`, never `pip`.** Every command goes through `uv run` / `uv add`.
125
+ - **`make check` before every commit.** Format, lint, types and tests; `make
126
+ publish` runs it too. Don't silence a finding without a comment saying why.
127
+ - **Never hand-edit `openapi_schemas.py`.** Regenerate.
128
+ - **`Raises:` references the typed exceptions** from `sweatstack.exceptions`.
129
+ - **Every public resource method's docstring has an `Endpoint:` line and an `Examples:` block**
130
+ with a complete, runnable snippet. `tests/test_docstrings.py` enforces both.
131
+ - **Public resources are typed `cached_property` attributes of `Client`**, so type checkers and
132
+ the snippet check resolve them statically, and a client built with `Client.__new__` still
133
+ has them. The module level mirrors them automatically.
134
+ - **A breaking change ships with its upgrade path** (see "Breaking changes").
135
+ - **A change to the public surface updates `skills/sweatstack-python/` in the same commit.**
136
+ `tests/test_skill.py` fails when `api.md` misses a method.
137
+ - **Enum-typed params accept `Enum | str`** and route through
138
+ `_enums_to_strings`. Don't introduce strict-enum-only parameters.
139
+ - **Open enums stay open.** When the server documents an enum as an open
140
+ set (metrics, scopes, daily measures, status codes, capabilities),
141
+ register it with `_open_enum(...)` in `schemas.py` so unknown values
142
+ parse as pseudo-members. Leave closed sets strict.
143
+ - **Never retry a request the server can't safely repeat.** `_transport.RETRYABLE_METHODS`
144
+ is `GET`, `PUT` and `DELETE`; a `POST` is sent once (no idempotency keys). Don't add
145
+ per-method retry logic: everything goes through `_request`.
146
+ - **Server-to-server calls pass `auth=False` to `_request`.** Endpoints that authenticate with
147
+ the app's own credentials in the body (Portal sessions, the token exchange) must never
148
+ receive a user's bearer.
149
+ - **`Sport` is the OpenSportTaxonomy type** (`open_sport_taxonomy.Sport`), not a generated enum.
150
+ Construct with `Sport("cycling.road")` or `Sport.parse(value)`; serialise with `str(sport)`. Codegen
151
+ binds the `sport` field to OST's permissive `SportField` (cli.py `_bind_sport_to_ost`), so regen is
152
+ safe and `sport`-typed fields keep decoding to `Sport`.
153
+ - **Tests are offline.** No network calls. Fake the transport at `Client._request` (see
154
+ `tests/test_resources.py`), or use `Client.__new__(Client)` to bypass init.
155
+ - **Frame libraries are optional extras.** Never import pandas, numpy,
156
+ pyarrow or polars at module top level; import inside the function, via
157
+ `_frames.require(...)` where an `ImportError` should name the extra.
158
+ CI's bare-install job fails otherwise.
159
+ - **No frame carries an index**, on any backend. Don't `set_index` in a
160
+ method; the caller does that.
161
+ - **`replace` methods overwrite every field.** Document the silent-clear footgun in the
162
+ docstring (see below).
163
+ - **CHANGELOG entries are user-facing**, not dev-facing.
164
+
165
+
166
+ ## Method shape
167
+
168
+ Every resource method follows this shape. Match it.
169
+
170
+ ```python
171
+ class Things(Resource):
172
+ def create(
173
+ self,
174
+ *, # keyword-only (path IDs come first, positional)
175
+ timestamp: datetime,
176
+ sport: Sport | str | None = None, # enum params: Enum | str
177
+ tags: list[str] | None = None,
178
+ ) -> ThingDetails:
179
+ """One-line summary.
180
+
181
+ Endpoint: ``POST /api/v1/things/``
182
+
183
+ Optional paragraph for non-obvious behaviour (full replace,
184
+ server-side defaults, side effects).
185
+
186
+ Args:
187
+ ...
188
+
189
+ Returns:
190
+ ThingDetails: ...
191
+
192
+ Raises:
193
+ SweatStackNotFoundError: If <specific condition>.
194
+ SweatStackAPIError: If the API request fails for any other reason.
195
+
196
+ Examples:
197
+ ```python
198
+ from sweatstack import Client
199
+
200
+ client = Client()
201
+ thing = client.things.create(timestamp=datetime.now(timezone.utc))
202
+ ```
203
+ """
204
+ sport = self._client._enums_to_strings([sport])[0] if sport else None
205
+ response = self._client._request(
206
+ "post",
207
+ "/api/v1/things/",
208
+ json={"timestamp": timestamp.isoformat(), "sport": sport, "tags": tags},
209
+ )
210
+ return ThingDetails.model_validate(response.json())
211
+ ```
212
+
213
+ Invariants baked into the template:
214
+
215
+ - Path-param IDs positional, everything else keyword-only.
216
+ - Datetimes serialize via `.isoformat()`; write timestamps go through `_require_aware`.
217
+ - Every request goes through `self._client._request(...)`, which raises the typed exception
218
+ for an error status. Only streaming endpoints open `_http_client()` themselves.
219
+ - Return is a validated Pydantic model, never the raw dict.
220
+ - `replace` methods are typed `-> None`. The server's `{"message": "..."}` body carries no
221
+ useful info.
222
+ - A class that defines `list` annotates with `builtins.list[...]`: inside the class body,
223
+ `list` is the method.
224
+
225
+ **List endpoints** page with `self._paginate(path, params, Model, page_size=..., limit=...,
226
+ offset=...)` and return `self._client._frame_from_models(models, Model, output, flatten=(...))`.
227
+ Empty lists yield typed empty frames automatically. See `Activities.list`.
228
+
229
+ **Parquet endpoints** return `self._client._read_frame(response.content, output)`. Never call
230
+ `pd.read_parquet` in a method.
231
+
232
+ Both kinds take `output` as a keyword-only parameter and carry `@overload` stubs keyed on the
233
+ `Literal` values (clone the neighbours); `tests/typing/output_types.py` pins them.
234
+
235
+ **Query parameters** are only sent when the caller supplied a non-default value. Repeatable
236
+ ones go through `self._wire(value)` (R8). Always pass `.value` for enums into `httpx` params:
237
+ `httpx` calls `str()`, which gives `"TraceResolution.linked"`, not `"linked"`.
238
+
239
+
240
+ ## Full-replace `replace` methods
241
+
242
+ Some PUT endpoints overwrite every field, setting unsent fields to `null` (traces, tests); their
243
+ methods are named `replace` (R3). Two rules:
244
+
245
+ 1. **Send every field in the body, even when `None`.** Don't omit.
246
+ 2. **Document the silent-clear footgun in the docstring** whenever you
247
+ add a new optional field to an existing `replace` method, *and*
248
+ call it out in the CHANGELOG `### Changed` section. Existing callers
249
+ who don't know about the new field will silently null it on their
250
+ next call.
251
+
252
+ Don't try to soften the contract with "preserve if omitted" sentinels;
253
+ that diverges from how every other field behaves on these methods. A PUT that leaves unsent
254
+ fields unchanged (users) is an `update` and sends only the fields given.
255
+
256
+
257
+ ## Breaking changes
258
+
259
+ Until 1.0, breaking changes are allowed when they ship with an upgrade path. Every one needs:
260
+
261
+ 1. A `### Upgrading` section in the CHANGELOG entry, with an old-to-new table.
262
+ 2. A copy-paste migration prompt for coding agents in the same section, for any change bigger
263
+ than a rename or two.
264
+ 3. For a removed method or attribute, an entry in `_renames.REMOVED_IN`, so calling it raises an
265
+ `AttributeError` that names the replacement. Entries are permanent.
266
+ `tests/test_renames.py` checks each one against the client, the module and the CHANGELOG.
267
+
268
+ Removed *parameters* need no entry: Python's `TypeError` names them, and the CHANGELOG explains
269
+ the replacement.
270
+
271
+
272
+ ## Output backends
273
+
274
+ `_frames.py` turns parquet bytes and lists of models into the container
275
+ the caller asked for. Resolution: per-call `output` > `Client(output=)` >
276
+ `sweatstack.set_output()` > the method's default: models for lists, and
277
+ for parquet the installed frame library (`_frames.installed_frame_output`:
278
+ Polars, then pandas, then Arrow; `ImportError` naming the extras if none).
279
+ A configured default a method cannot produce is skipped, a per-call one
280
+ is a `ValueError`. Never hard-code a frame library as a default.
281
+
282
+ **Where `output` belongs.** On the *data* endpoints only: activities,
283
+ traces, tests, dailies and the time series, the things a user groups,
284
+ filters and plots. Everything about the account, the app, teams, status
285
+ and the Portal (`oauth.userinfo`, `profile.status`, `portal.sessions.create`,
286
+ `whoami`, `users.list`, `teams.list`, `profile.sports`, `profile.tags`, ...) takes no `output` and always returns models; those
287
+ methods never call `_read_frame` or `_frame_from_models`, so a configured
288
+ output cannot reach them. A new data endpoint gets `output`; a new
289
+ control-plane endpoint does not.
290
+
291
+ Dtype policy: pandas gets `convert_to_standard_dtypes` (float64, ns);
292
+ Polars keeps wire dtypes except Float16 -> Float32; Arrow is the wire
293
+ table minus pandas index metadata; bytes is the body.
294
+
295
+ The Polars and Arrow paths for lists derive one type tree from each
296
+ model's **JSON Schema** (`_frames.field_types`) and render it per library;
297
+ values come from the instance.
298
+ **Never add a per-model special case there.** If a model needs one, the
299
+ JSON Schema grammar table in `_field_type` is missing a row: add the
300
+ row and its test. `tests/test_frames.py` turns `FrameSchemaWarning` into
301
+ a failure for every public model, so a regen that introduces an unknown
302
+ construct fails the suite, not a user's notebook.
303
+
304
+
305
+ ## Exceptions
306
+
307
+ The hierarchy in `sweatstack/exceptions.py` is the **public** error
308
+ contract — consumers should never need to import `httpx`.
309
+
310
+ ```
311
+ SweatStackError
312
+ ├── SweatStackConnectionError # DNS / timeout / no response
313
+ ├── SweatStackTokenRefreshError
314
+ └── SweatStackAPIError # got a response with status >= 400
315
+ ├── SweatStackAuthError # 401, 403
316
+ ├── SweatStackNotFoundError # 404
317
+ ├── SweatStackRateLimitError # 429
318
+ ├── SweatStackBadRequestError # other 4xx
319
+ └── SweatStackServerError # 5xx
320
+ ```
321
+
322
+ Docstrings: list specific subclasses for meaningful conditions (e.g. 404
323
+ when a path ID may not exist), then a catch-all `SweatStackAPIError`.
324
+
325
+
326
+ ## Tests
327
+
328
+ Live in `tests/`. Offline only. Two workhorse patterns.
329
+
330
+ **Faking the transport** at `Client._request`, the one choke point, to see exactly what goes
331
+ on the wire:
332
+
333
+ ```python
334
+ with patch.object(client, "_request", return_value=response) as request:
335
+ client.activities.list(sport="cycling")
336
+ assert request.call_args.kwargs["params"]["sport"] == ["cycling"]
337
+ ```
338
+
339
+ **Schema round-tripping:**
340
+
341
+ ```python
342
+ restored = TraceDetails.model_validate(original.model_dump())
343
+ assert restored.test_id == "test_123"
344
+ ```
345
+
346
+ Catches missing fields, type drift, and enum-casing changes from regen.
347
+ Exemplars: `tests/test_resources.py`, `tests/test_trace_test_linking.py`,
348
+ `tests/test_dtype_conversion.py`.
349
+
350
+
351
+ ## CHANGELOG
352
+
353
+ User-facing only. Lead with what changed for the user; skip regen
354
+ output, file renames, refactors. Loud-callout any behaviour change in
355
+ `### Changed`, especially full-replace footguns. SemVer.
356
+
357
+ Good:
358
+
359
+ > ### Changed
360
+ > - `traces.replace()` replaces all fields, including `test_id`. Callers
361
+ > that omit `test_id` will clear any existing link.
362
+
363
+ Bad (belongs in the commit message, not the changelog):
364
+
365
+ > ### Changed
366
+ > - Local datetime fields are now `AwareDatetime`. The
367
+ > `BodyExpressAddEmail...` schema is renamed to
368
+ > `BodyConnectAddEmail...`.
369
+
370
+
371
+ ## When in doubt
372
+
373
+ - Match the nearest existing method in `resources/`. Consistency with the
374
+ surroundings beats local cleverness.
375
+ - Reuse the helpers: `_request`, `_paginate`, `_wire`, `_enums_to_strings`,
376
+ `_read_frame`, `_frame_from_models`, `_RecordAppMetadata`. Don't reinvent them.
377
+ - Don't add abstractions for hypothetical future flexibility. Three
378
+ similar blocks is the pattern, not a smell.
@@ -6,6 +6,250 @@ 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
8
 
9
+ ## [0.91.0] - 2026-10-08
10
+
11
+ One namespace per API resource: `client.activities.list()` instead of `client.get_activities()`.
12
+ Every name now follows from the URL (`/api/v1/activities/...` is `client.activities`), so the
13
+ REST API reference doubles as the SDK's map. This is a breaking release with a mechanical
14
+ upgrade; see **Upgrading** below, or hand the migration prompt to your coding agent. Calling a
15
+ removed name raises an `AttributeError` that names its replacement.
16
+
17
+ ### Changed
18
+
19
+ - **BREAKING: methods moved to resource namespaces**: `activities`, `traces`, `tests`, `dailies`,
20
+ `profile`, `users`, `teams`, `portal` and `oauth`, on `Client` and at module level
21
+ (`sweatstack.activities.list()`). The full table is under **Upgrading**.
22
+ - **BREAKING: `traces.replace()` and `tests.replace()`** are the old `update_trace()` and
23
+ `update_test()`. The new name says what they do: every field you leave out is cleared,
24
+ including a trace's `test_id`.
25
+ - **BREAKING: `sport=` replaces `sports=`** on every filter, as on the server (which deprecated
26
+ `sports`). It takes one sport or a list: `sport="cycling"` or `sport=["cycling", "running"]`.
27
+ `tags=` and `metrics=` also take a single value now.
28
+ - **BREAKING: `activities.latest()`** calls the API's `/activities/latest`: it takes only
29
+ `sport=`, and returns `None` when there is no activity (it raised `StopIteration`).
30
+ - **BREAKING: keyword-only arguments** where they were positional: `metric=` on
31
+ `activities.mean_max()` and `activities.awd()`, `segmentation_on=` and `metrics=` on
32
+ `activities.data()`, `first_name=` and `last_name=` on `users.create()`, `only_root=` on
33
+ `profile.sports()`, `scopes=` on `teams.authorize()`, and every argument of
34
+ `oauth.authorization_url()` and `oauth.exchange_code()`.
35
+ - **BREAKING: longitudinal `date=` / `window_days=`** (deprecated since 0.70) are gone; use
36
+ `start=` and `end=`, typed as `date` objects (`start=date(2026, 1, 1)`).
37
+ `sport=` is a required argument of all three longitudinal methods; the API rejected a
38
+ request without one.
39
+ - **BREAKING: `activities.upload()`** returns the processing status of each file
40
+ (`list[SourceResponse]`) instead of a raw dict, and no longer takes `sport=`: the server never
41
+ read it. A CSV carries its sport in a `sport` column (an Open Sport Taxonomy code such as
42
+ `cycling.road`).
43
+ - **BREAKING: `oauth.exchange_code()` no longer takes `redirect_uri=`**; the token endpoint never
44
+ read it.
45
+ - **BREAKING: `users.retrieve(user_id)`** is the server's `GET /users/{id}`, which only finds
46
+ users you manage. Find anyone else with `users.list(name=...)`, which returns every user whose
47
+ name contains the text, ignoring case.
48
+ - **BREAKING: `switch_user()` and `switch_back()` are removed.** They changed which user a shared
49
+ client acted as, invisibly to everything else holding it. `client.delegated_client(user)` does
50
+ the same job and returns a new client. It takes a user ID or a `UserSummary`, not a
51
+ name: `switch_user("Carla")` becomes a lookup with `users.list(name="Carla")` first.
52
+ - `StreamlitAuth.select_activity()` takes `sport=` instead of `sports=`.
53
+ - Clients from `delegated_client()` and `principal_client()` keep the app's `client_id` and
54
+ `client_secret`, so `portal.sessions.create()` works on them.
55
+
56
+ - **Automatic retries**: `GET`, `PUT` and `DELETE` requests are retried up to twice after a
57
+ connection error, a timeout, or a 408, 429 or 5xx response, with exponential backoff and the
58
+ server's `Retry-After` (at most 30 s of waiting per call). `POST` is never retried, so a create
59
+ can't happen twice. `Client(max_retries=0)` turns retries off, e.g. inside a web request.
60
+
61
+ ### Added
62
+
63
+ - `Client(timeout=60.0, max_retries=2)`: the timeout in seconds, and how often to retry.
64
+ Clients from `delegated_client()` and `principal_client()` keep both.
65
+ - One connection pool per client, reused across requests (each request opened a new
66
+ connection). `client.close()` releases it; `with Client() as client:` closes on exit.
67
+ - `users.retrieve()`, `users.update()` (changes only the fields you pass) and `users.delete()`
68
+ for managed users.
69
+ - `users.list(include_managed=, include_shared=, name=)` and `teams.users(team_id, name=)`.
70
+ - `SourceResponse` and `SourceError`, the upload status models.
71
+ - `vo2=` on `traces.create()` and `traces.replace()`; the field was already on `TraceDetails`.
72
+
73
+ ### Fixed
74
+
75
+ - `activities.watch_backfill_status(auto_reconnect=True)` reconnects after a dropped
76
+ connection; it raised instead.
77
+ - `activities.backfill_status()` and `watch_backfill_status()` return the server's updates. Its
78
+ timestamps carry no UTC offset, which failed validation, and every update was skipped
79
+ silently. A line the client cannot parse is now logged as a warning.
80
+ - `StreamlitAuth.select_user()` stores the selected user's own refresh token. It kept the
81
+ signed-in user's, so after the first token refresh the app silently showed the signed-in
82
+ user's data under the selected user's name.
83
+ - `StreamlitAuth` keeps the app's `client_id` and `client_secret` after switching users.
84
+ - Threads sharing a client refresh an expired token once; they all refreshed, which fails with
85
+ rotating refresh tokens.
86
+ - `activities.watch_backfill_status()` no longer times out on a quiet stream: streams have no
87
+ read timeout.
88
+ - Searching users by name ignores case; `get_user("Carla")` found nobody where `"carla"` worked.
89
+
90
+ ### Removed
91
+
92
+ - **BREAKING:** the `sweatlab` and `sweatshell` commands, their example notebook, and the
93
+ `[jupyter]` extra. In any notebook: `uv add "sweatstack[pandas]" jupyterlab`, then
94
+ `sweatstack.authenticate()` in the first cell.
95
+ - **BREAKING:** `client.jwt`; use `client.api_key`.
96
+
97
+ <!-- --8<-- [start:upgrading-0-91] -->
98
+ ### Upgrading
99
+
100
+ Removed names raise an `AttributeError` naming the replacement, so running your code points at
101
+ each change. The module-level functions moved the same way: `sweatstack.get_activities()` is
102
+ `sweatstack.activities.list()`.
103
+
104
+ | 0.90 | 0.91 |
105
+ |---|---|
106
+ | `get_activities` | `client.activities.list()` |
107
+ | `get_activity` | `client.activities.retrieve(activity_id)` |
108
+ | `get_latest_activity` | `client.activities.latest()` |
109
+ | `get_activity_data` | `client.activities.data(activity_id)` |
110
+ | `get_activity_mean_max` | `client.activities.mean_max(activity_id, metric=...)` |
111
+ | `get_activity_awd` | `client.activities.awd(activity_id)` |
112
+ | `get_latest_activity_data` | `client.activities.data(client.activities.latest().id)` |
113
+ | `get_latest_activity_mean_max` | `client.activities.mean_max(client.activities.latest().id, metric=...)` |
114
+ | `get_longitudinal_data` | `client.activities.longitudinal.data(...)` |
115
+ | `get_longitudinal_mean_max` | `client.activities.longitudinal.mean_max(...)` |
116
+ | `get_longitudinal_awd` | `client.activities.longitudinal.awd(...)` |
117
+ | `upload` | `client.activities.upload(files)` |
118
+ | `get_backfill_status` | `client.activities.backfill_status()` |
119
+ | `watch_backfill_status` | `client.activities.watch_backfill_status()` |
120
+ | `set_activity_app_metadata` | `client.activities.app_metadata.set(activity_id, data=...)` |
121
+ | `delete_activity_app_metadata` | `client.activities.app_metadata.delete(activity_id)` |
122
+ | `get_traces` | `client.traces.list()` |
123
+ | `create_trace` | `client.traces.create(...)` |
124
+ | `update_trace` | `client.traces.replace(trace_id, ...)` |
125
+ | `delete_trace` | `client.traces.delete(trace_id)` |
126
+ | `set_trace_app_metadata` | `client.traces.app_metadata.set(trace_id, data=...)` |
127
+ | `delete_trace_app_metadata` | `client.traces.app_metadata.delete(trace_id)` |
128
+ | `get_tests` | `client.tests.list()` |
129
+ | `get_test` | `client.tests.retrieve(test_id)` |
130
+ | `create_test` | `client.tests.create(...)` |
131
+ | `update_test` | `client.tests.replace(test_id, ...)` |
132
+ | `delete_test` | `client.tests.delete(test_id)` |
133
+ | `set_test_app_metadata` | `client.tests.app_metadata.set(test_id, data=...)` |
134
+ | `delete_test_app_metadata` | `client.tests.app_metadata.delete(test_id)` |
135
+ | `get_dailies` | `client.dailies.list(measure, start=..., end=...)` |
136
+ | `set_daily` | `client.dailies.set(measure, date=..., value=...)` |
137
+ | `delete_daily` | `client.dailies.delete(measure, date=...)` |
138
+ | `get_profile_status` | `client.profile.status()` |
139
+ | `get_sports` | `client.profile.sports()` |
140
+ | `get_tags` | `client.profile.tags()` |
141
+ | `set_user_app_metadata` | `client.profile.app_metadata.set(data=...)` |
142
+ | `delete_user_app_metadata` | `client.profile.app_metadata.delete()` |
143
+ | `get_users` | `client.users.list()` |
144
+ | `get_user` | `client.users.list(name=...)`: every match, so check the length |
145
+ | `create_user` | `client.users.create(first_name=...)` |
146
+ | `get_teams` | `client.teams.list()` |
147
+ | `get_team_users` | `client.teams.users(team_id)` |
148
+ | `get_team_user` | `client.teams.users(team_id, name=...)`: every match |
149
+ | `get_authorized_teams` | `client.teams.authorized()` |
150
+ | `authorize_team` | `client.teams.authorize(team_id)` |
151
+ | `create_portal_session` | `client.portal.sessions.create(destination)` |
152
+ | `get_userinfo` | `client.oauth.userinfo()` |
153
+ | `get_authorization_url` | `client.oauth.authorization_url(...)` |
154
+ | `exchange_code_for_token` | `client.oauth.exchange_code(...)` |
155
+ | `generate_pkce_params` | `client.oauth.generate_pkce_params()` |
156
+ | `switch_user` | `client.delegated_client(user)`: a new client; this one is left unchanged |
157
+ | `switch_back` | keep the original client, or `client.principal_client()` |
158
+ | `client.jwt` | `client.api_key` |
159
+
160
+ Changes that a rename alone doesn't cover. Most raise a `TypeError` naming the argument; the
161
+ ones marked **silent** don't fail loudly, so check for them.
162
+
163
+ | 0.90 | 0.91 |
164
+ |---|---|
165
+ | `sports=["cycling"]` (every filter, and `StreamlitAuth.select_activity`) | `sport="cycling"` or `sport=["cycling", "running"]` |
166
+ | `get_activity_mean_max(id, "power")`, `get_activity_awd(id, "power")` | `activities.mean_max(id, metric="power")`, `activities.awd(id, metric="power")` |
167
+ | `get_activity_data(id, "power", ["power"])` | `activities.data(id, segmentation_on="power", metrics=["power"])` |
168
+ | `create_user("Carla", "Smith")` | `users.create(first_name="Carla", last_name="Smith")` |
169
+ | `get_sports(True)`, `authorize_team(id, scopes)` | `profile.sports(only_root=True)`, `teams.authorize(id, scopes=scopes)` |
170
+ | Positional arguments to `get_authorization_url()` / `exchange_code_for_token()` | Keywords: `oauth.authorization_url(client_id=..., ...)` |
171
+ | `get_latest_activity(start=, end=, tag=)` | `activities.latest(sport=)`; for the rest, `activities.list(start=..., end=..., tags=..., limit=1)` |
172
+ | `get_latest_activity()` raising `StopIteration` when there is none | **Silent:** `activities.latest()` returns `None`; check before using `.id` |
173
+ | `get_longitudinal_*(date=..., window_days=...)` | `start=` and `end=` |
174
+ | `get_longitudinal_*(...)` without `sport` | `sport=` is required (the API already rejected a request without one) |
175
+ | `upload(files, sport=...)` | `activities.upload(files)`: no `sport=`; a CSV needs a `sport` column |
176
+ | `upload(...)` returning a dict | **Silent:** `activities.upload(...)` returns `list[SourceResponse]`; read `.status` / `.error` |
177
+ | `exchange_code_for_token(..., redirect_uri=...)` | `oauth.exchange_code(...)` without `redirect_uri=` |
178
+ | `get_user("Carla")` returning one `UserSummary` | **Silent:** `users.list(name="Carla")` returns a list, every match; `search_mode=` is gone |
179
+ | `switch_user("Carla")` (a name) | `client.delegated_client(users.list(name="Carla")[0])`: a name is not accepted |
180
+ | `client.jwt` | `client.api_key` |
181
+ | `sweatlab`, `sweatshell`, `pip install "sweatstack[jupyter]"` | `uv add "sweatstack[pandas]" jupyterlab`; `sweatstack.authenticate()` in the first cell |
182
+
183
+ New defaults that change behaviour without changing code: `GET`, `PUT` and `DELETE` requests are
184
+ retried (`Client(max_retries=0)` restores single attempts), and requests time out after 60 s
185
+ (`Client(timeout=...)`).
186
+
187
+ **Migration prompt.** Paste this into your coding agent:
188
+
189
+ ```text
190
+ Upgrade this codebase to sweatstack 0.91, which moved every method to a resource namespace.
191
+ The full upgrade guide, with both tables, is the "Upgrading" section of the 0.91.0 entry in
192
+ https://github.com/SweatStack/sweatstack-python/blob/main/CHANGELOG.md
193
+ (also at https://docs.sweatstack.no/learn/python/upgrading/). Read it first.
194
+
195
+ Find every use of the sweatstack client (a Client instance, the sweatstack module, and
196
+ StreamlitAuth or FastAPI user clients) and:
197
+
198
+ 1. Rename calls per the first table, e.g. get_activities() -> activities.list(),
199
+ get_activity_data(id) -> activities.data(id),
200
+ get_longitudinal_mean_max(...) -> activities.longitudinal.mean_max(...),
201
+ update_trace(id, ...) -> traces.replace(id, ...), get_users() -> users.list().
202
+ 2. Apply every row of the second table. In particular:
203
+ - sports= -> sport= (one value or a list).
204
+ - Pass by keyword: metric= (mean_max, awd), segmentation_on= and metrics= (data),
205
+ first_name= and last_name= (users.create), only_root= (profile.sports),
206
+ scopes= (teams.authorize), and all arguments of oauth.authorization_url() and
207
+ oauth.exchange_code().
208
+ - Longitudinal methods: sport= is required, and date=/window_days= become start=/end=
209
+ (datetime.date objects).
210
+ - activities.latest() takes only sport= and returns None when there is no activity:
211
+ handle None.
212
+ - activities.upload() takes no sport= (a CSV needs a sport column) and returns
213
+ list[SourceResponse], not a dict.
214
+ - oauth.exchange_code() takes no redirect_uri=.
215
+ - get_user(x) -> users.list(name=x), which returns a list of every match.
216
+ - switch_user(user) -> athlete = client.delegated_client(user_id_or_summary); use that
217
+ new client for the athlete's calls. It doesn't accept a name: look the user up first.
218
+ switch_back() -> keep using the original client.
219
+ - client.jwt -> client.api_key.
220
+ 3. Replace get_latest_activity_data(...) with activities.data(activities.latest().id, ...),
221
+ after checking that latest() returned an activity.
222
+ 4. If the project used sweatlab, sweatshell or the [jupyter] extra, depend on
223
+ "sweatstack[pandas]" (or [polars]) and jupyterlab instead.
224
+
225
+ Then run the code and the tests. Fix any AttributeError (its message names the replacement)
226
+ and TypeError (a renamed or keyword-only argument). Do not add compatibility shims.
227
+ ```
228
+ <!-- --8<-- [end:upgrading-0-91] -->
229
+
230
+ ## [0.90.0] - 2026-09-28
231
+
232
+ ### Changed
233
+
234
+ - **BREAKING: mean-max curves are one row per duration**.
235
+ `get_activity_mean_max`, `get_latest_activity_mean_max` and `get_longitudinal_mean_max` return
236
+ `duration`, the metric (W or m/s) and `start` (UTC timestamp of the best effort), plus
237
+ `activity_id`, `sport` and `after` on the longitudinal curve. By default 19 durations from 1 s to
238
+ 6 h; the curve can rise again at longer durations and is returned as it is.
239
+
240
+ ### Added
241
+
242
+ - `durations=` on all three mean-max methods: `None` for the 19 defaults, `"all"` for the full grid,
243
+ or a list of seconds. Keyword-only.
244
+
245
+ ### Removed
246
+
247
+ - **BREAKING:** `segmentation` on `get_activity_mean_max` and `get_latest_activity_mean_max` (it never
248
+ reduced the payload; the server ignores it). It was positional: passing `True` in that slot now
249
+ raises `TypeError`.
250
+ - **BREAKING:** `by` on `get_longitudinal_mean_max`, and its `DeprecationWarning`. Every curve is
251
+ duration-oriented.
252
+
9
253
  ## [0.89.0] - 2026-09-28
10
254
 
11
255
  Frames on your terms. Every method that returns a collection takes `output=`:
@@ -89,7 +333,7 @@ Four breaking changes come with it; the upgrade is mechanical, see **Upgrading**
89
333
 
90
334
  - **Breaking:** `create_trace`, `update_trace`, `create_test`, and `update_test` now require timezone-aware datetimes for `timestamp`, `start`, and `end`. A naive datetime raises `ValueError` before the request is sent. Attach a zone, e.g. `datetime(..., tzinfo=ZoneInfo("Europe/Amsterdam"))` or `datetime.now(timezone.utc)`; the offset is stored alongside the instant.
91
335
  - `start`, `end`, and `timestamp` on responses are absolute UTC instants (ISO 8601 with a `Z` suffix), no longer a fixed per-record local offset. For wall-clock display use the companion `start_local` / `end_local` / `timestamp_local` fields, which are always present. The Streamlit activity selector now labels activities by their local date.
92
- - Requires a SweatStack server with offset-based timezone handling (plan 033). Against an older server the aware-datetime writes still work, but responses keep the previous fixed-offset `start`/`end`/`timestamp`.
336
+ - Requires a SweatStack server with offset-based timezone handling. Against an older server the aware-datetime writes still work, but responses keep the previous fixed-offset `start`/`end`/`timestamp`.
93
337
 
94
338
  ### Removed
95
339