sweatstack 0.76.2__tar.gz → 0.77.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 (58) hide show
  1. {sweatstack-0.76.2 → sweatstack-0.77.0}/CHANGELOG.md +10 -0
  2. {sweatstack-0.76.2 → sweatstack-0.77.0}/CONTRIBUTING.md +1 -1
  3. {sweatstack-0.76.2 → sweatstack-0.77.0}/PKG-INFO +3 -3
  4. {sweatstack-0.76.2 → sweatstack-0.77.0}/README.md +1 -1
  5. sweatstack-0.77.0/plans/003_trace_test_linking.md +300 -0
  6. {sweatstack-0.76.2 → sweatstack-0.77.0}/pyproject.toml +2 -2
  7. {sweatstack-0.76.2 → sweatstack-0.77.0}/src/sweatstack/client.py +43 -5
  8. {sweatstack-0.76.2 → sweatstack-0.77.0}/src/sweatstack/openapi_schemas.py +28 -22
  9. {sweatstack-0.76.2 → sweatstack-0.77.0}/src/sweatstack/schemas.py +1 -1
  10. {sweatstack-0.76.2 → sweatstack-0.77.0}/tests/test_tests.py +4 -4
  11. sweatstack-0.77.0/tests/test_trace_test_linking.py +90 -0
  12. {sweatstack-0.76.2 → sweatstack-0.77.0}/uv.lock +1 -1
  13. {sweatstack-0.76.2 → sweatstack-0.77.0}/.claude/settings.local.json +0 -0
  14. {sweatstack-0.76.2 → sweatstack-0.77.0}/.claude/skills/sweatstack-python/SKILL.md +0 -0
  15. {sweatstack-0.76.2 → sweatstack-0.77.0}/.claude/skills/sweatstack-python/client.md +0 -0
  16. {sweatstack-0.76.2 → sweatstack-0.77.0}/.claude/skills/sweatstack-python/data-models.md +0 -0
  17. {sweatstack-0.76.2 → sweatstack-0.77.0}/.claude/skills/sweatstack-python/fastapi.md +0 -0
  18. {sweatstack-0.76.2 → sweatstack-0.77.0}/.claude/skills/sweatstack-python/streamlit.md +0 -0
  19. {sweatstack-0.76.2 → sweatstack-0.77.0}/.gitignore +0 -0
  20. {sweatstack-0.76.2 → sweatstack-0.77.0}/.python-version +0 -0
  21. {sweatstack-0.76.2 → sweatstack-0.77.0}/DEVELOPMENT.md +0 -0
  22. {sweatstack-0.76.2 → sweatstack-0.77.0}/LICENSE +0 -0
  23. {sweatstack-0.76.2 → sweatstack-0.77.0}/Makefile +0 -0
  24. {sweatstack-0.76.2 → sweatstack-0.77.0}/docs/conf.py +0 -0
  25. {sweatstack-0.76.2 → sweatstack-0.77.0}/docs/everything.rst +0 -0
  26. {sweatstack-0.76.2 → sweatstack-0.77.0}/docs/index.rst +0 -0
  27. {sweatstack-0.76.2 → sweatstack-0.77.0}/examples/fastapi_webhooks_example.py +0 -0
  28. {sweatstack-0.76.2 → sweatstack-0.77.0}/examples/send_webhook.py +0 -0
  29. {sweatstack-0.76.2 → sweatstack-0.77.0}/plans/001a_tests.md +0 -0
  30. {sweatstack-0.76.2 → sweatstack-0.77.0}/plans/001b_metadata.md +0 -0
  31. {sweatstack-0.76.2 → sweatstack-0.77.0}/plans/001c_dailies.md +0 -0
  32. {sweatstack-0.76.2 → sweatstack-0.77.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
  33. {sweatstack-0.76.2 → sweatstack-0.77.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
  34. {sweatstack-0.76.2 → sweatstack-0.77.0}/src/sweatstack/__init__.py +0 -0
  35. {sweatstack-0.76.2 → sweatstack-0.77.0}/src/sweatstack/cli.py +0 -0
  36. {sweatstack-0.76.2 → sweatstack-0.77.0}/src/sweatstack/constants.py +0 -0
  37. {sweatstack-0.76.2 → sweatstack-0.77.0}/src/sweatstack/exceptions.py +0 -0
  38. {sweatstack-0.76.2 → sweatstack-0.77.0}/src/sweatstack/fastapi/__init__.py +0 -0
  39. {sweatstack-0.76.2 → sweatstack-0.77.0}/src/sweatstack/fastapi/config.py +0 -0
  40. {sweatstack-0.76.2 → sweatstack-0.77.0}/src/sweatstack/fastapi/dependencies.py +0 -0
  41. {sweatstack-0.76.2 → sweatstack-0.77.0}/src/sweatstack/fastapi/models.py +0 -0
  42. {sweatstack-0.76.2 → sweatstack-0.77.0}/src/sweatstack/fastapi/routes.py +0 -0
  43. {sweatstack-0.76.2 → sweatstack-0.77.0}/src/sweatstack/fastapi/session.py +0 -0
  44. {sweatstack-0.76.2 → sweatstack-0.77.0}/src/sweatstack/fastapi/token_stores.py +0 -0
  45. {sweatstack-0.76.2 → sweatstack-0.77.0}/src/sweatstack/fastapi/webhooks.py +0 -0
  46. {sweatstack-0.76.2 → sweatstack-0.77.0}/src/sweatstack/ipython_init.py +0 -0
  47. {sweatstack-0.76.2 → sweatstack-0.77.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
  48. {sweatstack-0.76.2 → sweatstack-0.77.0}/src/sweatstack/py.typed +0 -0
  49. {sweatstack-0.76.2 → sweatstack-0.77.0}/src/sweatstack/streamlit.py +0 -0
  50. {sweatstack-0.76.2 → sweatstack-0.77.0}/src/sweatstack/sweatshell.py +0 -0
  51. {sweatstack-0.76.2 → sweatstack-0.77.0}/src/sweatstack/utils.py +0 -0
  52. {sweatstack-0.76.2 → sweatstack-0.77.0}/tests/__init__.py +0 -0
  53. {sweatstack-0.76.2 → sweatstack-0.77.0}/tests/test_dailies.py +0 -0
  54. {sweatstack-0.76.2 → sweatstack-0.77.0}/tests/test_dtype_conversion.py +0 -0
  55. {sweatstack-0.76.2 → sweatstack-0.77.0}/tests/test_exceptions.py +0 -0
  56. {sweatstack-0.76.2 → sweatstack-0.77.0}/tests/test_metadata.py +0 -0
  57. {sweatstack-0.76.2 → sweatstack-0.77.0}/tests/test_teams.py +0 -0
  58. {sweatstack-0.76.2 → sweatstack-0.77.0}/tests/test_webhooks.py +0 -0
@@ -6,6 +6,16 @@ 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.77.0] - 2026-05-19
10
+
11
+ ### Added
12
+ - Link a trace to a test via the new `test_id` argument on `create_trace()` and `update_trace()`. A linked trace appears in the test's traces regardless of its timestamp.
13
+ - `get_test()` accepts `trace_resolution=TraceResolution.linked` to return only traces explicitly linked to the test. Defaults to `TraceResolution.auto` (unchanged behaviour).
14
+
15
+ ### Changed
16
+ - `update_trace()` replaces all fields, including `test_id`. Callers that omit `test_id` will clear any existing link — pass it back in to keep the trace linked.
17
+
18
+
9
19
  ## [0.76.2] - 2026-04-27
10
20
 
11
21
  ### Fixed
@@ -6,4 +6,4 @@ This repository is open-sourced primarily to make it easier for developers and A
6
6
 
7
7
  If you've found a bug or have a question about using the library, feel free to [open an issue](https://github.com/SweatStack/sweatstack-python/issues).
8
8
 
9
- For general questions about SweatStack, please refer to the [developer documentation](https://developer.sweatstack.no/getting-started/).
9
+ For general questions about SweatStack, please refer to the [developer documentation](https://docs.sweatstack.no/getting-started/).
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sweatstack
3
- Version: 0.76.2
3
+ Version: 0.77.0
4
4
  Summary: The official Python client for SweatStack
5
5
  Project-URL: Homepage, https://sweatstack.no
6
- Project-URL: Documentation, https://developer.sweatstack.no/getting-started/
6
+ Project-URL: Documentation, https://docs.sweatstack.no/getting-started/
7
7
  Project-URL: Repository, https://github.com/SweatStack/sweatstack-python
8
8
  Project-URL: Issues, https://github.com/SweatStack/sweatstack-python/issues
9
9
  Project-URL: Changelog, https://github.com/SweatStack/sweatstack-python/blob/main/CHANGELOG.md
@@ -43,4 +43,4 @@ Description-Content-Type: text/markdown
43
43
 
44
44
  This is the official Python client library for SweatStack.
45
45
 
46
- Documentation can be found [here](https://developer.sweatstack.no/getting-started/).
46
+ Documentation can be found [here](https://docs.sweatstack.no/getting-started/).
@@ -2,4 +2,4 @@
2
2
 
3
3
  This is the official Python client library for SweatStack.
4
4
 
5
- Documentation can be found [here](https://developer.sweatstack.no/getting-started/).
5
+ Documentation can be found [here](https://docs.sweatstack.no/getting-started/).
@@ -0,0 +1,300 @@
1
+ # Plan: Trace ↔ Test linking + `TraceResolution`
2
+
3
+ ## Summary
4
+
5
+ Mirror upstream `sweatstack@93cd588` ("Adds trace to test optional foreign key
6
+ relationship") in the Python client:
7
+
8
+ - Traces gain an optional `test_id` FK that *explicitly* links them to a test
9
+ (independent of timestamp).
10
+ - `GET /api/v1/tests/{test_id}` gains a `traces` query parameter
11
+ (`TraceResolution` enum: `auto` | `linked`) that controls how the returned
12
+ `traces` list is resolved.
13
+ - `POST /api/v1/traces/` and `PUT /api/v1/traces/{trace_id}` may now return
14
+ `404` when a referenced `test_id` does not exist.
15
+
16
+ Server-side behaviour:
17
+
18
+ - `auto` (default): traces whose timestamp falls in the test's window, **plus**
19
+ traces explicitly linked to this test, **minus** traces explicitly linked to a
20
+ *different* test.
21
+ - `linked`: only traces explicitly linked via `test_id`; the time window is
22
+ ignored.
23
+
24
+ The `activities` list is unaffected — always time-overlap matched.
25
+
26
+ ## Code Quality Requirements
27
+
28
+ Same bar as plan 001a:
29
+
30
+ - Follow existing patterns exactly (keyword-only params, enum handling,
31
+ `_enums_to_strings`, error surface via `_raise_for_status`, docstring style).
32
+ - Comprehensive type hints. No `Any`. No string-typed enums on the surface.
33
+ - Concise Google-style docstrings (Args / Returns / Raises).
34
+ - No dead code, no TODOs, no half-finished branches.
35
+
36
+ ## Step 1: Regenerate `openapi_schemas.py`
37
+
38
+ The **user will run the SweatStack server locally** at
39
+ `http://localhost:8080`. Wait for confirmation that the server is up
40
+ before running the regen, and do not attempt to start the server.
41
+
42
+ Then:
43
+
44
+ ```bash
45
+ uv run generate-response-models
46
+ ```
47
+
48
+ Expected diff in `src/sweatstack/openapi_schemas.py`:
49
+
50
+ - `TraceDetails`: new `test_id: str | None = Field(None, title='Test Id')`.
51
+ - `TraceCreateOrUpdate`: new `test_id: str | None = Field(None, title='Test Id')`.
52
+ - New top-level enum:
53
+
54
+ ```python
55
+ class TraceResolution(Enum):
56
+ auto = 'auto'
57
+ linked = 'linked'
58
+ ```
59
+
60
+ **Caveat — regen pulls more than this feature.** Two intervening upstream
61
+ commits since `openapi_schemas.py` was last regenerated also touch the
62
+ schema/API surface: `feebe56` ("Adds security.txt …") and `3f29c1f`
63
+ ("Better handling of tags=null"). The regenerated diff will not be
64
+ confined to this plan's scope.
65
+
66
+ Action: review the full diff. If unrelated changes appear, **split them
67
+ into a separate commit** ahead of the trace-linking commit so the
68
+ trace-linking commit stays reviewable. Do not merge regen-noise as part
69
+ of this feature.
70
+
71
+ Also verify on the running server before committing: open
72
+ `http://localhost:8080/openapi.json`, search for `TraceResolution`,
73
+ confirm wire values are `"auto"` and `"linked"` (lowercase). This guards
74
+ against an upstream `BaseEnum` casing change.
75
+
76
+ ## Step 2: Re-export `TraceResolution`
77
+
78
+ Two-step exposure (mirrors `Sport`):
79
+
80
+ 1. `src/sweatstack/schemas.py` — add `TraceResolution` to the import from
81
+ `openapi_schemas`.
82
+ 2. `src/sweatstack/client.py` — add `TraceResolution` to the import block
83
+ pulling enums/schemas from `.schemas` (≈ line 45, where `Sport`,
84
+ `TraceDetails`, etc. are imported).
85
+
86
+ `__init__.py` is `from .client import *`, so step 2 is what actually makes
87
+ `from sweatstack import TraceResolution` work. Verify with a smoke test
88
+ in the REPL after the change. (This is the step that's easy to skip and
89
+ breaks the public surface.)
90
+
91
+ ## Step 3: Update `src/sweatstack/client.py`
92
+
93
+ Import `TraceResolution` from `schemas` in the existing imports block
94
+ (≈ line 45, alongside `TraceDetails`).
95
+
96
+ ### 3a. `create_trace` — add `test_id` keyword arg
97
+
98
+ Location: ≈ line 1708.
99
+
100
+ - Add `test_id: str | None = None` as the **last** keyword-only param
101
+ (preserves existing call-site compatibility — every existing caller uses
102
+ keyword args, but appending keeps the visual ordering stable).
103
+ - Add `"test_id": test_id` to the JSON body. Do not omit when `None`; the
104
+ server accepts `null` and other optional fields are already passed as
105
+ `None` (consistent with `lactate`, `rpe`, etc.).
106
+ - Docstring:
107
+ - Add `test_id: Optional ID of a test to explicitly link this trace to.
108
+ If the test does not exist, the API raises 404.` to Args.
109
+ - Add a `Raises:` note that an `HTTPStatusError(404)` is raised when
110
+ `test_id` references a non-existent test (existing `_raise_for_status`
111
+ already surfaces this; just document it).
112
+
113
+ ### 3b. `update_trace` — add `test_id` keyword arg
114
+
115
+ Location: ≈ line 1762.
116
+
117
+ Identical treatment to `create_trace`. Same docstring additions.
118
+
119
+ **Behavioural-change callout (required in both docstring and CHANGELOG):**
120
+ `update_trace` is full-replace. Once `test_id` is in the signature with a
121
+ default of `None`, **existing callers that don't pass `test_id` will silently
122
+ unlink any previously linked test** the next time they call
123
+ `update_trace`. This is consistent with how every other optional field on
124
+ `update_trace` already behaves (e.g. `lactate=None` nulls lactate), but
125
+ it's a real, observable change for upgraders.
126
+
127
+ Docstring wording suggestion:
128
+
129
+ > Note: This is a full-replace operation. If a trace was previously linked
130
+ > to a test via `test_id`, you must pass that `test_id` again to preserve
131
+ > the link — otherwise it is cleared.
132
+
133
+ Do not try to work around this with "preserve if omitted" sentinels — that
134
+ would diverge from how every other field on this method behaves and rot
135
+ the contract.
136
+
137
+ ### 3c. `get_test` — add `trace_resolution` keyword arg
138
+
139
+ Location: ≈ line 1934.
140
+
141
+ Current signature:
142
+
143
+ ```python
144
+ def get_test(self, test_id: str) -> TestDetails:
145
+ ```
146
+
147
+ New signature:
148
+
149
+ ```python
150
+ def get_test(
151
+ self,
152
+ test_id: str,
153
+ *,
154
+ trace_resolution: TraceResolution = TraceResolution.auto,
155
+ ) -> TestDetails:
156
+ ```
157
+
158
+ Naming decision: **`trace_resolution`, not `traces`.** The wire name is
159
+ `traces=` (server query param), but on the Python method
160
+ `client.get_test("t1", traces=TraceResolution.linked)` reads as "give me
161
+ the test and traces=X" — ambiguous. `trace_resolution=` is
162
+ self-documenting. Note the wire mapping in the docstring so future
163
+ maintainers know they don't match.
164
+
165
+ Typing decision: **strict `TraceResolution`, not `TraceResolution | str`.**
166
+ This diverges from the `Sport | str` pattern used elsewhere in the
167
+ client. The deviation is deliberate: the enum has exactly two values, it
168
+ is a brand-new surface (no historical string callers to support), and
169
+ the brief asks for "extremely clean". Document the deviation in a code
170
+ review note when the PR goes up; don't surprise the reviewer.
171
+
172
+ Implementation:
173
+
174
+ ```python
175
+ with self._http_client() as client:
176
+ params = {}
177
+ if trace_resolution is not TraceResolution.auto:
178
+ params["traces"] = trace_resolution.value
179
+ response = client.get(
180
+ url=f"/api/v1/tests/{test_id}",
181
+ params=params,
182
+ )
183
+ self._raise_for_status(response)
184
+ return TestDetails.model_validate(response.json())
185
+ ```
186
+
187
+ Notes:
188
+
189
+ - Use `.value` (lowercase `"auto"` / `"linked"`) explicitly. `httpx` does
190
+ not unwrap `Enum.value` on its own — it `str()`s the value, which gives
191
+ `"TraceResolution.linked"`. Verified pattern: `_get_tests_generator` and
192
+ every other enum query usage in this file calls `.value`.
193
+ - **Omit the param when at default** — matches the convention used by
194
+ `_get_activities_generator`, `_get_tests_generator`, etc., which only
195
+ add optional query params when the caller supplied a non-default.
196
+ Sending `?traces=auto` would deviate from this pattern.
197
+ - Keep `test_id` positional (existing call sites pass it positionally);
198
+ only `trace_resolution` is keyword-only. Strictly additive.
199
+ - Docstring: explain `auto` vs `linked` in user terms. Copy the
200
+ server-side wording verbatim from `app/routers/api.py` — it is the
201
+ canonical description and survives doc drift.
202
+
203
+ ### 3d. No other method changes
204
+
205
+ - `get_traces()` is unchanged. Upstream does not yet expose a `test_id`
206
+ filter on the list endpoint; do not add a client-side filter
207
+ (out of scope, would lie about API support).
208
+ - `_get_traces_generator()` is unchanged. The new `test_id` field rides
209
+ along automatically in `TraceDetails`.
210
+
211
+ ## Step 4: Tests
212
+
213
+ Add `tests/test_trace_test_linking.py`. Follow the no-network pattern
214
+ established by the other tests in this repo.
215
+
216
+ Coverage:
217
+
218
+ - **Schema round-trip**: build a `TraceDetails` with `test_id="t_123"`,
219
+ `.model_dump()` → `.model_validate()` round-trip preserves it.
220
+ - **TraceCreateOrUpdate accepts `test_id`**: `TraceCreateOrUpdate(timestamp=...,
221
+ test_id="t_123")` validates; `.model_dump()` includes the field.
222
+ - **`TraceResolution` enum values**: assert `TraceResolution.auto.value ==
223
+ "auto"` and `TraceResolution.linked.value == "linked"` (guards against the
224
+ generator regenerating the enum with different casing on the wire).
225
+ - **Re-export**: `from sweatstack import TraceResolution` resolves, *and*
226
+ `import sweatstack; sweatstack.TraceResolution` resolves (catches the
227
+ client-level import step easy to skip).
228
+
229
+ If the repo already has an httpx-mock based test harness (it currently does
230
+ not, judging by the existing files), add a single integration-style test for
231
+ `get_test(test_id, traces=TraceResolution.linked)` that asserts the request
232
+ URL carries `?traces=linked`. If not, skip — the enum-value test above
233
+ provides the same guarantee without inventing a test infrastructure for this
234
+ plan alone.
235
+
236
+ ## Step 5: Examples / docs
237
+
238
+ - Quick scan of `examples/` — if there is an existing "tests" example, append
239
+ a short snippet showing:
240
+
241
+ ```python
242
+ test = client.create_test(sport=Sport.cycling, start=...)
243
+ client.create_trace(timestamp=..., lactate=4.0, test_id=test.id)
244
+ detail = client.get_test(test.id, traces=TraceResolution.linked)
245
+ ```
246
+
247
+ If none exists, do **not** add a new example file for this alone.
248
+
249
+ - `docs/everything.rst` — `TraceDetails` and `TraceCreateOrUpdate` autoclass
250
+ entries pick up the new field automatically. Add a `.. autoclass::
251
+ sweatstack.openapi_schemas.TraceResolution` entry in the same enums block
252
+ where `Sport` lives (if `Sport` is documented there — check before adding).
253
+
254
+ - `.claude/skills/sweatstack-python/client.md` — update the `create_trace`,
255
+ `update_trace`, and `get_test` signatures, and add `TraceResolution` to
256
+ the list of exported enums.
257
+
258
+ ## Step 6: Changelog + version bump
259
+
260
+ - `CHANGELOG.md`: new entry under the next minor version. Must include
261
+ both the additive feature and the behavioural-change callout.
262
+ Wording suggestion:
263
+
264
+ > **Added** — Traces can be explicitly linked to a test via the new
265
+ > `test_id` parameter on `create_trace` and `update_trace`. `get_test`
266
+ > accepts a `trace_resolution` parameter (`TraceResolution.auto` or
267
+ > `TraceResolution.linked`) controlling how the returned traces list is
268
+ > resolved.
269
+ >
270
+ > **Behaviour change** — `update_trace` is full-replace. Callers that
271
+ > previously linked a trace to a test (e.g. via the SweatStack UI or
272
+ > another client) and then call `update_trace` without passing
273
+ > `test_id` will now unlink that trace. Pass the existing `test_id`
274
+ > back in to preserve the link.
275
+
276
+ - `pyproject.toml`: bump `0.76.2 → 0.77.0` (minor — additive feature, no
277
+ breaking change). Confirm the version cadence with the maintainer if
278
+ unsure; this is the only judgement call in the plan.
279
+
280
+ ## API Reference
281
+
282
+ | Method | Path | New parameter | Notes |
283
+ |--------|------|---------------|-------|
284
+ | POST | `/api/v1/traces/` | body `test_id?: str` | 404 if test missing |
285
+ | PUT | `/api/v1/traces/{id}` | body `test_id?: str` | full-replace; `null` unlinks; 404 if test missing |
286
+ | GET | `/api/v1/tests/{id}` | query `traces=auto\|linked` | default `auto` |
287
+
288
+ ### Resolution semantics (server-side, reference only)
289
+
290
+ - `auto`: `(traces in window AND not claimed by another test) ∪ (traces with test_id == this test)`
291
+ - `linked`: `traces with test_id == this test` (window ignored)
292
+
293
+ ## Out of Scope
294
+
295
+ - Listing/filtering traces by `test_id` on `GET /traces/` (upstream does not
296
+ expose this).
297
+ - Bulk attach/detach helpers (`attach_traces_to_test(...)`). Not requested by
298
+ upstream; would be premature abstraction.
299
+ - A `linked_only: bool` convenience on `get_test`. Rejected in favour of the
300
+ enum to keep a single, faithful surface.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sweatstack"
3
- version = "0.76.2"
3
+ version = "0.77.0"
4
4
  description = "The official Python client for SweatStack"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -32,7 +32,7 @@ dependencies = [
32
32
 
33
33
  [project.urls]
34
34
  Homepage = "https://sweatstack.no"
35
- Documentation = "https://developer.sweatstack.no/getting-started/"
35
+ Documentation = "https://docs.sweatstack.no/getting-started/"
36
36
  Repository = "https://github.com/SweatStack/sweatstack-python"
37
37
  Issues = "https://github.com/SweatStack/sweatstack-python/issues"
38
38
  Changelog = "https://github.com/SweatStack/sweatstack-python/blob/main/CHANGELOG.md"
@@ -43,7 +43,7 @@ from .schemas import (
43
43
  BackfillStatus, DailyMeasure, DailyResponse,
44
44
  Marker, Metric, Scope, Sport,
45
45
  TeamResponse, TestDetails, TestResults, TestSummary, TokenResponse, TraceDetails,
46
- UserInfoResponse, UserResponse, UserSummary
46
+ TraceResolution, UserInfoResponse, UserResponse, UserSummary
47
47
  )
48
48
  from .utils import convert_to_standard_dtypes, decode_jwt_body, make_dataframe_streamlit_compatible
49
49
 
@@ -1717,6 +1717,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1717
1717
  heart_rate: int | None = None,
1718
1718
  tags: list[str] | None = None,
1719
1719
  sport: Sport | str | None = None,
1720
+ test_id: str | None = None,
1720
1721
  ) -> TraceDetails:
1721
1722
  """Creates a new trace with the specified parameters.
1722
1723
 
@@ -1733,12 +1734,18 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1733
1734
  heart_rate: Optional heart rate measurement in beats per minute.
1734
1735
  tags: Optional list of tags to associate with this trace.
1735
1736
  sport: Optional sport to associate with this trace.
1737
+ test_id: Optional ID of a test to explicitly link this trace to.
1738
+ The link is independent of timestamp — a linked trace appears
1739
+ in the test's traces list regardless of whether its timestamp
1740
+ falls inside the test window.
1736
1741
 
1737
1742
  Returns:
1738
1743
  TraceDetails: The created trace object with all details.
1739
1744
 
1740
1745
  Raises:
1741
- HTTPStatusError: If the API request fails.
1746
+ SweatStackNotFoundError: If ``test_id`` references a test that
1747
+ does not exist.
1748
+ SweatStackAPIError: If the API request fails for any other reason.
1742
1749
  """
1743
1750
  sport = self._enums_to_strings([sport])[0] if sport else None
1744
1751
  with self._http_client() as client:
@@ -1754,6 +1761,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1754
1761
  "heart_rate": heart_rate,
1755
1762
  "tags": tags,
1756
1763
  "sport": sport,
1764
+ "test_id": test_id,
1757
1765
  },
1758
1766
  )
1759
1767
  self._raise_for_status(response)
@@ -1772,6 +1780,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1772
1780
  heart_rate: int | None = None,
1773
1781
  tags: list[str] | None = None,
1774
1782
  sport: Sport | str | None = None,
1783
+ test_id: str | None = None,
1775
1784
  ) -> None:
1776
1785
  """Updates a trace by replacing all fields.
1777
1786
 
@@ -1779,6 +1788,10 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1779
1788
  server-side. To modify a single field, first fetch the trace with
1780
1789
  ``get_traces()``, then pass all fields back.
1781
1790
 
1791
+ In particular: if the trace was previously linked to a test via
1792
+ ``test_id`` and you do not pass ``test_id`` here, the link is cleared.
1793
+ Pass the existing ``test_id`` back in to preserve it.
1794
+
1782
1795
  Args:
1783
1796
  trace_id: The unique identifier of the trace to update.
1784
1797
  timestamp: The date and time when the trace was recorded.
@@ -1790,9 +1803,13 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1790
1803
  heart_rate: Optional heart rate measurement in beats per minute.
1791
1804
  tags: Optional list of tags to associate with this trace.
1792
1805
  sport: Optional sport to associate with this trace.
1806
+ test_id: Optional ID of a test to explicitly link this trace to.
1807
+ Pass ``None`` (or omit) to leave the trace unlinked.
1793
1808
 
1794
1809
  Raises:
1795
- HTTPStatusError: If the API request fails.
1810
+ SweatStackNotFoundError: If ``trace_id`` does not exist, or if
1811
+ ``test_id`` references a test that does not exist.
1812
+ SweatStackAPIError: If the API request fails for any other reason.
1796
1813
  """
1797
1814
  sport = self._enums_to_strings([sport])[0] if sport else None
1798
1815
  with self._http_client() as client:
@@ -1808,6 +1825,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1808
1825
  "heart_rate": heart_rate,
1809
1826
  "tags": tags,
1810
1827
  "sport": sport,
1828
+ "test_id": test_id,
1811
1829
  },
1812
1830
  )
1813
1831
  self._raise_for_status(response)
@@ -1931,11 +1949,29 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1931
1949
  else:
1932
1950
  return tests
1933
1951
 
1934
- def get_test(self, test_id: str) -> TestDetails:
1952
+ def get_test(
1953
+ self,
1954
+ test_id: str,
1955
+ *,
1956
+ trace_resolution: TraceResolution | str = TraceResolution.auto,
1957
+ ) -> TestDetails:
1935
1958
  """Gets details for a specific test by ID.
1936
1959
 
1937
1960
  Args:
1938
1961
  test_id: The unique identifier of the test to retrieve.
1962
+ trace_resolution: How traces are matched to this test. Affects only
1963
+ the ``traces`` list on the response; ``activities`` is always
1964
+ time-overlap matched. Accepts a ``TraceResolution`` enum or
1965
+ its string value (``"auto"`` or ``"linked"``).
1966
+
1967
+ - ``"auto"`` (default): traces whose timestamp falls in the
1968
+ test's time range, plus any traces explicitly linked to
1969
+ this test, minus any traces explicitly linked to a
1970
+ different test.
1971
+ - ``"linked"``: only traces explicitly linked to this test
1972
+ via ``test_id``, regardless of timestamp.
1973
+
1974
+ Maps to the ``traces`` query parameter on the wire.
1939
1975
 
1940
1976
  Returns:
1941
1977
  TestDetails: The test details including resolved traces and overlapping activities.
@@ -1943,8 +1979,10 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1943
1979
  Raises:
1944
1980
  HTTPStatusError: If the API request fails.
1945
1981
  """
1982
+ resolution = self._enums_to_strings([trace_resolution])[0]
1983
+ params = {} if resolution == TraceResolution.auto.value else {"traces": resolution}
1946
1984
  with self._http_client() as client:
1947
- response = client.get(url=f"/api/v1/tests/{test_id}")
1985
+ response = client.get(url=f"/api/v1/tests/{test_id}", params=params)
1948
1986
  self._raise_for_status(response)
1949
1987
  return TestDetails.model_validate(response.json())
1950
1988
 
@@ -1,6 +1,6 @@
1
1
  # generated by datamodel-codegen:
2
2
  # filename: openapi.json
3
- # timestamp: 2026-04-27T08:28:54+00:00
3
+ # timestamp: 2026-05-19T10:00:53+00:00
4
4
 
5
5
  from __future__ import annotations
6
6
 
@@ -13,7 +13,6 @@ from pydantic import (
13
13
  AnyUrl,
14
14
  AwareDatetime,
15
15
  BaseModel,
16
- NaiveDatetime,
17
16
  EmailStr,
18
17
  Field,
19
18
  RootModel,
@@ -26,7 +25,7 @@ from pydantic import (
26
25
 
27
26
  class Activity(BaseModel):
28
27
  id: str = Field(..., title='Id')
29
- start_date_local: NaiveDatetime | None = Field(None, title='Start Date Local')
28
+ start_date_local: AwareDatetime | None = Field(None, title='Start Date Local')
30
29
 
31
30
 
32
31
  class ActivityUpdate(BaseModel):
@@ -1116,8 +1115,8 @@ class TestSummary(BaseModel):
1116
1115
  end: AwareDatetime | None = Field(None, title='End')
1117
1116
  results: TestResults | None = None
1118
1117
  app_metadata: dict[str, Any] | None = Field(None, title='App Metadata')
1119
- start_local: NaiveDatetime = Field(..., title='Start Local')
1120
- end_local: NaiveDatetime | None = Field(..., title='End Local')
1118
+ start_local: AwareDatetime = Field(..., title='Start Local')
1119
+ end_local: AwareDatetime | None = Field(..., title='End Local')
1121
1120
 
1122
1121
 
1123
1122
  class TestUpdate(BaseModel):
@@ -1150,6 +1149,7 @@ class TokenResponse(BaseModel):
1150
1149
 
1151
1150
  class TraceCreateOrUpdate(BaseModel):
1152
1151
  timestamp: AwareDatetime = Field(..., title='Timestamp')
1152
+ test_id: str | None = Field(None, title='Test Id')
1153
1153
  lactate: float | None = Field(None, title='Lactate')
1154
1154
  rpe: int | None = Field(None, title='Rpe')
1155
1155
  notes: str | None = Field(None, title='Notes')
@@ -1161,6 +1161,11 @@ class TraceCreateOrUpdate(BaseModel):
1161
1161
  sport: Sport | None = None
1162
1162
 
1163
1163
 
1164
+ class TraceResolution(Enum):
1165
+ auto = 'auto'
1166
+ linked = 'linked'
1167
+
1168
+
1164
1169
  class UserFlow(Enum):
1165
1170
  session = 'session'
1166
1171
  signup = 'signup'
@@ -1172,7 +1177,7 @@ class UserInfoResponse(BaseModel):
1172
1177
  given_name: str | None = Field(None, title='Given Name')
1173
1178
  family_name: str | None = Field(None, title='Family Name')
1174
1179
  email: str | None = Field(None, title='Email')
1175
- registered_at: AwareDatetime | NaiveDatetime = Field(..., title='Registered At')
1180
+ registered_at: AwareDatetime = Field(..., title='Registered At')
1176
1181
  name: str = Field(..., title='Name')
1177
1182
 
1178
1183
 
@@ -1181,7 +1186,7 @@ class UserResponse(BaseModel):
1181
1186
  first_name: str | None = Field(..., title='First Name')
1182
1187
  last_name: str | None = Field(..., title='Last Name')
1183
1188
  admin: bool = Field(..., title='Admin')
1184
- registered_at: AwareDatetime | NaiveDatetime = Field(..., title='Registered At')
1189
+ registered_at: AwareDatetime = Field(..., title='Registered At')
1185
1190
  display_name: str = Field(..., title='Display Name')
1186
1191
  is_managed: bool = Field(..., title='Is Managed')
1187
1192
 
@@ -1294,6 +1299,12 @@ class BodyAuthorizeOauthAuthorizePost(BaseModel):
1294
1299
  code_challenge_method: str | None = Field(None, title='Code Challenge Method')
1295
1300
 
1296
1301
 
1302
+ class BodyConnectAddEmailPostConnectAddEmailPost(BaseModel):
1303
+ email: EmailStr = Field(..., title='Email')
1304
+ state: str | None = Field(None, title='State')
1305
+ user_flow: UserFlow | None = 'session'
1306
+
1307
+
1297
1308
  class BodyCreateCheckoutSessionApiV1PaymentStripeCheckoutPost(BaseModel):
1298
1309
  subscription_plan: SubscriptionPlan
1299
1310
 
@@ -1304,12 +1315,6 @@ class BodyCreateDailyDataDailiesPost(BaseModel):
1304
1315
  value: float = Field(..., title='Value')
1305
1316
 
1306
1317
 
1307
- class BodyExpressAddEmailPostExpressAddEmailPost(BaseModel):
1308
- email: EmailStr = Field(..., title='Email')
1309
- state: str | None = Field(None, title='State')
1310
- user_flow: UserFlow | None = 'session'
1311
-
1312
-
1313
1318
  class BodyGenerateApiKeyPartialsGenerateApiKeyPost(BaseModel):
1314
1319
  scopes: list[Scope] = Field(..., title='Scopes')
1315
1320
 
@@ -1381,8 +1386,8 @@ class Lap(BaseModel):
1381
1386
  start: AwareDatetime = Field(..., title='Start')
1382
1387
  end: AwareDatetime = Field(..., title='End')
1383
1388
  duration: timedelta = Field(..., title='Duration')
1384
- start_local: NaiveDatetime = Field(..., title='Start Local')
1385
- end_local: NaiveDatetime = Field(..., title='End Local')
1389
+ start_local: AwareDatetime = Field(..., title='Start Local')
1390
+ end_local: AwareDatetime = Field(..., title='End Local')
1386
1391
 
1387
1392
 
1388
1393
  class RampValueInput(BaseModel):
@@ -1563,8 +1568,8 @@ class ActivityDetails(BaseModel):
1563
1568
  distance: float | None = Field(None, title='Distance')
1564
1569
  devices: list[str] | None = Field(None, title='Devices')
1565
1570
  duration: timedelta = Field(..., title='Duration')
1566
- start_local: NaiveDatetime = Field(..., title='Start Local')
1567
- end_local: NaiveDatetime = Field(..., title='End Local')
1571
+ start_local: AwareDatetime = Field(..., title='Start Local')
1572
+ end_local: AwareDatetime = Field(..., title='End Local')
1568
1573
 
1569
1574
 
1570
1575
  class ActivitySummary(BaseModel):
@@ -1580,8 +1585,8 @@ class ActivitySummary(BaseModel):
1580
1585
  traces: list[TraceDetails] | None = Field(None, title='Traces')
1581
1586
  app_metadata: dict[str, Any] | None = Field(None, title='App Metadata')
1582
1587
  duration: timedelta = Field(..., title='Duration')
1583
- start_local: NaiveDatetime = Field(..., title='Start Local')
1584
- end_local: NaiveDatetime = Field(..., title='End Local')
1588
+ start_local: AwareDatetime = Field(..., title='Start Local')
1589
+ end_local: AwareDatetime = Field(..., title='End Local')
1585
1590
 
1586
1591
 
1587
1592
  class TestDetails(BaseModel):
@@ -1596,13 +1601,14 @@ class TestDetails(BaseModel):
1596
1601
  app_metadata: dict[str, Any] | None = Field(None, title='App Metadata')
1597
1602
  traces: list[TraceDetails] | None = Field(None, title='Traces')
1598
1603
  activities: list[ActivitySummary] | None = Field(None, title='Activities')
1599
- start_local: NaiveDatetime = Field(..., title='Start Local')
1600
- end_local: NaiveDatetime | None = Field(..., title='End Local')
1604
+ start_local: AwareDatetime = Field(..., title='Start Local')
1605
+ end_local: AwareDatetime | None = Field(..., title='End Local')
1601
1606
 
1602
1607
 
1603
1608
  class TraceDetails(BaseModel):
1604
1609
  tags: list[str] | None = Field(None, title='Tags')
1605
1610
  id: str = Field(..., title='Id')
1611
+ test_id: str | None = Field(None, title='Test Id')
1606
1612
  timestamp: AwareDatetime = Field(..., title='Timestamp')
1607
1613
  lactate: float | None = Field(None, title='Lactate')
1608
1614
  rpe: int | None = Field(None, title='Rpe')
@@ -1615,7 +1621,7 @@ class TraceDetails(BaseModel):
1615
1621
  activity: ActivitySummary | None = None
1616
1622
  sport: Sport | None = None
1617
1623
  app_metadata: dict[str, Any] | None = Field(None, title='App Metadata')
1618
- timestamp_local: NaiveDatetime = Field(..., title='Timestamp Local')
1624
+ timestamp_local: AwareDatetime = Field(..., title='Timestamp Local')
1619
1625
 
1620
1626
 
1621
1627
  RepeatInput.model_rebuild()
@@ -18,7 +18,7 @@ from .openapi_schemas import (
18
18
  BackfillStatus, DailyMeasure, DailyResponse,
19
19
  Marker, Metric, Scope, Sport,
20
20
  TeamResponse, TestDetails, TestResults, TestSummary, TokenResponse, TraceDetails,
21
- UserInfoResponse, UserResponse, UserSummary
21
+ TraceResolution, UserInfoResponse, UserResponse, UserSummary
22
22
  )
23
23
 
24
24
 
@@ -39,8 +39,8 @@ def sample_test_summary(sample_results: TestResults) -> TestSummary:
39
39
  end=datetime(2026, 3, 15, 11, 0, tzinfo=timezone.utc),
40
40
  results=sample_results,
41
41
  tags=["lab", "cycling"],
42
- start_local=datetime(2026, 3, 15, 10, 0),
43
- end_local=datetime(2026, 3, 15, 12, 0),
42
+ start_local=datetime(2026, 3, 15, 10, 0, tzinfo=timezone.utc),
43
+ end_local=datetime(2026, 3, 15, 12, 0, tzinfo=timezone.utc),
44
44
  )
45
45
 
46
46
 
@@ -152,8 +152,8 @@ class TestDataFrameConversion:
152
152
  end=datetime(2026, 1, i + 1, 11, 0, tzinfo=timezone.utc),
153
153
  results=sample_results if i == 0 else None,
154
154
  tags=[],
155
- start_local=datetime(2026, 1, i + 1, 10, 0),
156
- end_local=datetime(2026, 1, i + 1, 12, 0),
155
+ start_local=datetime(2026, 1, i + 1, 10, 0, tzinfo=timezone.utc),
156
+ end_local=datetime(2026, 1, i + 1, 12, 0, tzinfo=timezone.utc),
157
157
  )
158
158
  for i in range(3)
159
159
  ]
@@ -0,0 +1,90 @@
1
+ """Tests for the trace <-> test linking surface.
2
+
3
+ Covers:
4
+
5
+ - ``test_id`` round-trips through ``TraceDetails`` and ``TraceCreateOrUpdate``.
6
+ - ``TraceResolution`` enum wire values are exactly ``"auto"`` / ``"linked"``
7
+ (these are what the server's query parameter accepts).
8
+ - ``TraceResolution`` is exposed on the public package surface.
9
+ """
10
+
11
+ from datetime import datetime, timezone
12
+
13
+ import sweatstack
14
+ from sweatstack import TraceDetails, TraceResolution
15
+ from sweatstack.openapi_schemas import TraceCreateOrUpdate
16
+
17
+
18
+ def _trace_details(**overrides) -> TraceDetails:
19
+ base = dict(
20
+ id="trace_001",
21
+ timestamp=datetime(2026, 3, 15, 9, 30, tzinfo=timezone.utc),
22
+ timestamp_local=datetime(2026, 3, 15, 10, 30, tzinfo=timezone.utc),
23
+ )
24
+ base.update(overrides)
25
+ return TraceDetails(**base)
26
+
27
+
28
+ class TestTestIdField:
29
+ def test_trace_details_round_trip_with_test_id(self):
30
+ original = _trace_details(test_id="test_123", lactate=4.2)
31
+ restored = TraceDetails.model_validate(original.model_dump())
32
+
33
+ assert restored.test_id == "test_123"
34
+ assert restored.lactate == 4.2
35
+
36
+ def test_trace_details_test_id_defaults_to_none(self):
37
+ trace = _trace_details()
38
+ assert trace.test_id is None
39
+
40
+ def test_trace_create_or_update_accepts_test_id(self):
41
+ payload = TraceCreateOrUpdate(
42
+ timestamp=datetime(2026, 3, 15, 9, 30, tzinfo=timezone.utc),
43
+ lactate=4.2,
44
+ test_id="test_123",
45
+ )
46
+
47
+ dumped = payload.model_dump()
48
+ assert dumped["test_id"] == "test_123"
49
+
50
+ def test_trace_create_or_update_test_id_defaults_to_none(self):
51
+ payload = TraceCreateOrUpdate(
52
+ timestamp=datetime(2026, 3, 15, 9, 30, tzinfo=timezone.utc),
53
+ )
54
+
55
+ assert payload.test_id is None
56
+ assert payload.model_dump()["test_id"] is None
57
+
58
+
59
+ class TestGetTestTraceResolution:
60
+ """``get_test`` should accept both the enum and the bare string for
61
+ ``trace_resolution`` (mirroring how ``sport``, ``measure`` etc. work
62
+ elsewhere in the client)."""
63
+
64
+ def _build_client(self):
65
+ from sweatstack.client import Client
66
+
67
+ return Client.__new__(Client)
68
+
69
+ def test_enum_input(self):
70
+ client = self._build_client()
71
+ assert client._enums_to_strings([TraceResolution.linked]) == ["linked"]
72
+
73
+ def test_string_input(self):
74
+ client = self._build_client()
75
+ assert client._enums_to_strings(["linked"]) == ["linked"]
76
+
77
+
78
+ class TestTraceResolutionEnum:
79
+ def test_wire_values(self):
80
+ """The wire values must match the server's query parameter exactly."""
81
+ assert TraceResolution.auto.value == "auto"
82
+ assert TraceResolution.linked.value == "linked"
83
+
84
+ def test_membership(self):
85
+ """Guards against accidental additions/removals on regen."""
86
+ assert {m.value for m in TraceResolution} == {"auto", "linked"}
87
+
88
+ def test_publicly_exported(self):
89
+ """Importable from the top-level package, like other enums (e.g. Sport)."""
90
+ assert sweatstack.TraceResolution is TraceResolution
@@ -2537,7 +2537,7 @@ wheels = [
2537
2537
 
2538
2538
  [[package]]
2539
2539
  name = "sweatstack"
2540
- version = "0.76.0"
2540
+ version = "0.77.0"
2541
2541
  source = { editable = "." }
2542
2542
  dependencies = [
2543
2543
  { name = "email-validator" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes