sweatstack 0.72.0__tar.gz → 0.73.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 (52) hide show
  1. {sweatstack-0.72.0 → sweatstack-0.73.0}/.claude/settings.local.json +2 -1
  2. {sweatstack-0.72.0 → sweatstack-0.73.0}/.claude/skills/sweatstack-python/client.md +104 -1
  3. {sweatstack-0.72.0 → sweatstack-0.73.0}/CHANGELOG.md +8 -0
  4. {sweatstack-0.72.0 → sweatstack-0.73.0}/PKG-INFO +1 -1
  5. sweatstack-0.73.0/plans/001a_tests.md +190 -0
  6. sweatstack-0.73.0/plans/001b_metadata.md +121 -0
  7. sweatstack-0.73.0/plans/001c_dailies.md +165 -0
  8. {sweatstack-0.72.0 → sweatstack-0.73.0}/pyproject.toml +1 -1
  9. {sweatstack-0.72.0 → sweatstack-0.73.0}/src/sweatstack/client.py +463 -10
  10. {sweatstack-0.72.0 → sweatstack-0.73.0}/src/sweatstack/openapi_schemas.py +212 -8
  11. {sweatstack-0.72.0 → sweatstack-0.73.0}/src/sweatstack/schemas.py +33 -3
  12. sweatstack-0.73.0/tests/test_dailies.py +117 -0
  13. sweatstack-0.73.0/tests/test_metadata.py +59 -0
  14. sweatstack-0.73.0/tests/test_tests.py +191 -0
  15. {sweatstack-0.72.0 → sweatstack-0.73.0}/uv.lock +1 -1
  16. {sweatstack-0.72.0 → sweatstack-0.73.0}/.claude/skills/sweatstack-python/SKILL.md +0 -0
  17. {sweatstack-0.72.0 → sweatstack-0.73.0}/.claude/skills/sweatstack-python/data-models.md +0 -0
  18. {sweatstack-0.72.0 → sweatstack-0.73.0}/.claude/skills/sweatstack-python/fastapi.md +0 -0
  19. {sweatstack-0.72.0 → sweatstack-0.73.0}/.claude/skills/sweatstack-python/streamlit.md +0 -0
  20. {sweatstack-0.72.0 → sweatstack-0.73.0}/.gitignore +0 -0
  21. {sweatstack-0.72.0 → sweatstack-0.73.0}/.python-version +0 -0
  22. {sweatstack-0.72.0 → sweatstack-0.73.0}/CONTRIBUTING.md +0 -0
  23. {sweatstack-0.72.0 → sweatstack-0.73.0}/DEVELOPMENT.md +0 -0
  24. {sweatstack-0.72.0 → sweatstack-0.73.0}/LICENSE +0 -0
  25. {sweatstack-0.72.0 → sweatstack-0.73.0}/Makefile +0 -0
  26. {sweatstack-0.72.0 → sweatstack-0.73.0}/README.md +0 -0
  27. {sweatstack-0.72.0 → sweatstack-0.73.0}/docs/conf.py +0 -0
  28. {sweatstack-0.72.0 → sweatstack-0.73.0}/docs/everything.rst +0 -0
  29. {sweatstack-0.72.0 → sweatstack-0.73.0}/docs/index.rst +0 -0
  30. {sweatstack-0.72.0 → sweatstack-0.73.0}/examples/fastapi_webhooks_example.py +0 -0
  31. {sweatstack-0.72.0 → sweatstack-0.73.0}/examples/send_webhook.py +0 -0
  32. {sweatstack-0.72.0 → sweatstack-0.73.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
  33. {sweatstack-0.72.0 → sweatstack-0.73.0}/src/sweatstack/__init__.py +0 -0
  34. {sweatstack-0.72.0 → sweatstack-0.73.0}/src/sweatstack/cli.py +0 -0
  35. {sweatstack-0.72.0 → sweatstack-0.73.0}/src/sweatstack/constants.py +0 -0
  36. {sweatstack-0.72.0 → sweatstack-0.73.0}/src/sweatstack/fastapi/__init__.py +0 -0
  37. {sweatstack-0.72.0 → sweatstack-0.73.0}/src/sweatstack/fastapi/config.py +0 -0
  38. {sweatstack-0.72.0 → sweatstack-0.73.0}/src/sweatstack/fastapi/dependencies.py +0 -0
  39. {sweatstack-0.72.0 → sweatstack-0.73.0}/src/sweatstack/fastapi/models.py +0 -0
  40. {sweatstack-0.72.0 → sweatstack-0.73.0}/src/sweatstack/fastapi/routes.py +0 -0
  41. {sweatstack-0.72.0 → sweatstack-0.73.0}/src/sweatstack/fastapi/session.py +0 -0
  42. {sweatstack-0.72.0 → sweatstack-0.73.0}/src/sweatstack/fastapi/token_stores.py +0 -0
  43. {sweatstack-0.72.0 → sweatstack-0.73.0}/src/sweatstack/fastapi/webhooks.py +0 -0
  44. {sweatstack-0.72.0 → sweatstack-0.73.0}/src/sweatstack/ipython_init.py +0 -0
  45. {sweatstack-0.72.0 → sweatstack-0.73.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
  46. {sweatstack-0.72.0 → sweatstack-0.73.0}/src/sweatstack/py.typed +0 -0
  47. {sweatstack-0.72.0 → sweatstack-0.73.0}/src/sweatstack/streamlit.py +0 -0
  48. {sweatstack-0.72.0 → sweatstack-0.73.0}/src/sweatstack/sweatshell.py +0 -0
  49. {sweatstack-0.72.0 → sweatstack-0.73.0}/src/sweatstack/utils.py +0 -0
  50. {sweatstack-0.72.0 → sweatstack-0.73.0}/tests/__init__.py +0 -0
  51. {sweatstack-0.72.0 → sweatstack-0.73.0}/tests/test_dtype_conversion.py +0 -0
  52. {sweatstack-0.72.0 → sweatstack-0.73.0}/tests/test_webhooks.py +0 -0
@@ -18,7 +18,8 @@
18
18
  "Bash(ls:*)",
19
19
  "Bash(git:*)",
20
20
  "Bash(python3:*)",
21
- "Bash(test:*)"
21
+ "Bash(test:*)",
22
+ "Bash(uv run:*)"
22
23
  ],
23
24
  "deny": []
24
25
  }
@@ -8,6 +8,9 @@
8
8
  - [Mean-Max and AWD](#mean-max-and-awd)
9
9
  - [Longitudinal Data](#longitudinal-data)
10
10
  - [Traces](#traces)
11
+ - [Tests](#tests)
12
+ - [Dailies](#dailies-daily-health-metrics)
13
+ - [App Metadata](#app-metadata)
11
14
  - [Profile](#profile)
12
15
  - [Users and Teams](#users-and-teams)
13
16
  - [User Delegation](#user-delegation)
@@ -136,6 +139,7 @@ The DataFrame has a timezone-aware datetime index and includes an `activity_id`
136
139
  import sweatstack
137
140
  sweatstack.enable_cache() # platform cache dir
138
141
  sweatstack.enable_cache(path="./my_cache") # custom dir
142
+ client.clear_cache() # remove all cached data for current user
139
143
  # Use fixed end dates (not "today") to get stable cache hits
140
144
  df = client.get_longitudinal_data(sports=[Sport.cycling], start=date(2025, 1, 1), end=date(2025, 3, 31))
141
145
  df = client.get_longitudinal_mean_max(sports=[Sport.cycling], metric="power", start=date(2025, 1, 1))
@@ -161,6 +165,104 @@ trace = client.create_trace(
161
165
  )
162
166
  ```
163
167
 
168
+ ## Tests
169
+
170
+ Fitness assessments/evaluations with structured physiological results.
171
+
172
+ ```python
173
+ # List tests (returns list[TestSummary])
174
+ tests = client.get_tests(
175
+ start=date(2025, 1, 1), # optional
176
+ end=date(2025, 12, 31), # optional
177
+ sports=[Sport.cycling], # optional
178
+ tags=["lab"], # optional
179
+ created_by="app_id", # optional, filter by creator app
180
+ limit=50, # default 50
181
+ )
182
+
183
+ # As DataFrame (results column gets normalized into flat columns like results.vo2max)
184
+ df = client.get_tests(as_dataframe=True)
185
+
186
+ # Single test by ID (returns TestDetails with resolved traces + overlapping activities)
187
+ test = client.get_test("test_id")
188
+
189
+ # Create a test
190
+ from sweatstack import TestResults, Marker
191
+ test = client.create_test(
192
+ sport=Sport.cycling,
193
+ start=datetime(2025, 6, 1, 9, 0),
194
+ title="Lab test Q2",
195
+ results=TestResults(
196
+ first_threshold=Marker(power=200, heart_rate=140),
197
+ second_threshold=Marker(power=280, heart_rate=170),
198
+ vo2max=4500.0,
199
+ critical_power=260,
200
+ ),
201
+ tags=["lab"],
202
+ )
203
+
204
+ # Update a test (full replace — fields not provided are set to null)
205
+ client.update_test(
206
+ test.id,
207
+ sport=Sport.cycling,
208
+ start=test.start,
209
+ title="Lab test Q2 (revised)",
210
+ results=test.results, # must re-pass to keep existing results
211
+ )
212
+
213
+ # Delete a test
214
+ client.delete_test("test_id")
215
+ ```
216
+
217
+ ## Dailies (Daily Health Metrics)
218
+
219
+ ```python
220
+ from sweatstack import DailyMeasure
221
+
222
+ # Get daily values over a date range (returns list[DailyResponse])
223
+ dailies = client.get_dailies(
224
+ DailyMeasure.body_mass,
225
+ start=date(2026, 1, 1),
226
+ end=date(2026, 3, 31),
227
+ interpolate=True, # default; server fills gaps
228
+ )
229
+
230
+ # As DataFrame (date as index)
231
+ df = client.get_dailies(DailyMeasure.body_mass, start=date(2026, 1, 1), end=date(2026, 3, 31), as_dataframe=True)
232
+
233
+ # Set a daily value (upsert — creates or updates)
234
+ daily = client.set_daily(DailyMeasure.body_mass, date=date(2026, 4, 1), value=75.2)
235
+
236
+ # Delete a daily value
237
+ client.delete_daily(DailyMeasure.body_mass, date=date(2026, 4, 1))
238
+ ```
239
+
240
+ Available measures: `body_mass`, `body_fat_pct`, `resting_hr`, `hrv`, `sleep_duration`, `sleep_altitude`, `menstrual_cycle_day`. The `measure` parameter is positional (part of the URL path). With `interpolate=False`, missing dates return `value=None, source="missing"`.
241
+
242
+ ## App Metadata
243
+
244
+ Store arbitrary JSON data on entities, scoped per app. Requires an app token (token with `aud` claim).
245
+
246
+ ```python
247
+ # Set metadata on an activity (full replace, max 1KB)
248
+ client.set_activity_app_metadata("activity_id", data={"score": 8.5, "notes": "good"})
249
+
250
+ # Delete it
251
+ client.delete_activity_app_metadata("activity_id")
252
+
253
+ # Same pattern for traces, tests, and the current user
254
+ client.set_trace_app_metadata("trace_id", data={"source": "lab"})
255
+ client.set_test_app_metadata("test_id", data={"protocol": "ramp"})
256
+ client.set_user_app_metadata(data={"preferences": {"unit": "metric"}}) # max 4KB
257
+
258
+ # Delete
259
+ client.delete_trace_app_metadata("trace_id")
260
+ client.delete_test_app_metadata("test_id")
261
+ client.delete_user_app_metadata()
262
+ ```
263
+
264
+ Metadata appears as `app_metadata` on entity responses when accessed via app token.
265
+
164
266
  ## Profile
165
267
 
166
268
  ```python
@@ -264,6 +366,7 @@ df = sweatstack.get_latest_activity_data()
264
366
  - **`start` is required for longitudinal endpoints.** Unlike `get_activities()` where all filters are optional.
265
367
  - **`sport` (singular) vs `sports` (list):** `get_latest_activity(sport=...)` and `create_trace(sport=...)` take a single sport. All other methods that filter by sport use `sports=[...]` (list). The singular `sport` parameter on longitudinal methods is deprecated.
266
368
  - **DataFrames have standard dtypes.** The library converts API-optimized types (Int16, float16) to float64/datetime64[ns] automatically.
267
- - **`as_dataframe=True`** is available on `get_activities()` and `get_traces()`. Time-series methods (`get_activity_data`, `get_longitudinal_data`, etc.) always return DataFrames.
369
+ - **`as_dataframe=True`** is available on `get_activities()`, `get_traces()`, `get_tests()`, and `get_dailies()`. Time-series methods (`get_activity_data`, `get_longitudinal_data`, etc.) always return DataFrames.
370
+ - **`update_test()` is a full replace.** Omitted optional fields are set to null. Always re-pass all fields you want to keep (e.g. `results=test.results`).
268
371
  - **`summary` fields are optional.** Always null-check: `activity.summary.power.mean if activity.summary and activity.summary.power else None`.
269
372
  - **`metrics` on ActivitySummary** lists available data streams, not the data itself. Use to check availability before calling `get_activity_data()`.
@@ -6,6 +6,14 @@ 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.73.0] - 2026-04-09
10
+
11
+ ### Added
12
+ - Full support for fitness tests — create, retrieve, update, and delete physiological assessments (threshold tests, VO2max tests, etc.) and their results.
13
+ - App metadata — store and retrieve per-app JSON data on activities, traces, tests, and users.
14
+ - Dailies — get, set, and delete daily health metrics (body mass, HRV, resting HR, etc.) with optional server-side interpolation.
15
+
16
+
9
17
  ## [0.72.0] - 2026-03-13
10
18
 
11
19
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sweatstack
3
- Version: 0.72.0
3
+ Version: 0.73.0
4
4
  Summary: The official Python client for SweatStack
5
5
  Project-URL: Homepage, https://sweatstack.no
6
6
  Project-URL: Documentation, https://developer.sweatstack.no/getting-started/
@@ -0,0 +1,190 @@
1
+ # Plan: Add Tests (Fitness Assessments) to Python Client
2
+
3
+ ## Summary
4
+
5
+ Add full CRUD support for the Tests resource - fitness assessments/evaluations containing structured physiological results (thresholds, VO2max, critical power, etc.).
6
+
7
+ ## Code Quality Requirements
8
+
9
+ All code must be extremely clean, robust and maintainable:
10
+ - Follow existing patterns and conventions exactly (keyword-only params, enum handling, error handling, docstrings)
11
+ - Comprehensive type hints throughout
12
+ - Clear, concise docstrings matching the existing style (Args, Returns, Raises)
13
+ - No shortcuts, no dead code, no TODOs left behind
14
+
15
+ ## Step 1: Generate schemas
16
+
17
+ Regenerate `openapi_schemas.py` from the running backend (must be running at `localhost:8080`):
18
+
19
+ ```bash
20
+ uv run generate-response-models
21
+ ```
22
+
23
+ This runs `datamodel-code-generator` against the OpenAPI spec (see `src/sweatstack/cli.py`). It auto-generates all Pydantic models including `TestSummary`, `TestDetails`, `TestResults`, `Marker`, `DailyMeasure`, `DailyResponse`, etc.
24
+
25
+ This step is shared across all three plans (001a/b/c). Only needs to run once.
26
+
27
+ ## Step 2: Re-export new schemas from `schemas.py`
28
+
29
+ Add new models to the imports in `schemas.py` (which re-exports from `openapi_schemas.py`).
30
+
31
+ New exports needed:
32
+ - `TestSummary`, `TestDetails`, `TestResults`, `Marker`
33
+
34
+ These then become available via `from sweatstack import TestSummary` etc. since `__init__.py` is just `from .client import *` and `client.py` imports from `schemas.py`.
35
+
36
+ No `DailyMeasure` enum enhancements here - that belongs to plan 001c.
37
+
38
+ ## Step 3: Add Tests methods to `client.py`
39
+
40
+ Follow the exact patterns established by activities and traces. Import new schemas in `client.py` from `schemas.py`.
41
+
42
+ ### List with pagination generator (follows `_get_activities_generator` / `get_activities` pattern)
43
+
44
+ ```python
45
+ def _get_tests_generator(
46
+ self,
47
+ *,
48
+ start: date | None = None,
49
+ end: date | None = None,
50
+ sports: list[Sport | str] | None = None,
51
+ tags: list[str] | None = None,
52
+ created_by: str | None = None,
53
+ limit: int = 50,
54
+ offset: int = 0,
55
+ ) -> Generator[TestSummary, None, None]:
56
+ # Same structure as _get_activities_generator.
57
+ # default_limit = 50 (matches API default, unlike activities which use 100)
58
+ # Query params: start, end, sport (list), tags (list), created_by, limit, offset
59
+ # Yields TestSummary.model_validate(item) for each item
60
+ ```
61
+
62
+ ```python
63
+ def get_tests(
64
+ self,
65
+ *,
66
+ start: date | None = None,
67
+ end: date | None = None,
68
+ sports: list[Sport | str] | None = None,
69
+ tags: list[str] | None = None,
70
+ created_by: str | None = None,
71
+ limit: int = 50,
72
+ offset: int = 0,
73
+ as_dataframe: bool = False,
74
+ ) -> list[TestSummary] | pd.DataFrame:
75
+ # Consumes generator into list.
76
+ # DataFrame conversion: model_dump() each item, then normalize the "results" column
77
+ # using _normalize_dataframe_column (same pattern as activities normalizing "summary").
78
+ # Empty result: use _create_empty_dataframe_from_model(TestSummary, normalize_columns=["results"])
79
+ ```
80
+
81
+ ### Get single test (follows `get_activity` pattern)
82
+
83
+ ```python
84
+ def get_test(self, test_id: str) -> TestDetails:
85
+ # GET /api/v1/tests/{test_id}
86
+ # Returns TestDetails (includes resolved traces + overlapping activities)
87
+ ```
88
+
89
+ ### Create (follows `create_trace` pattern)
90
+
91
+ ```python
92
+ def create_test(
93
+ self,
94
+ *,
95
+ sport: Sport | str,
96
+ start: datetime,
97
+ title: str | None = None,
98
+ end: datetime | None = None,
99
+ results: TestResults | None = None,
100
+ tags: list[str] | None = None,
101
+ ) -> TestSummary:
102
+ # POST /api/v1/tests/
103
+ # sport and start are required (matching the API)
104
+ # Convert sport enum via _enums_to_strings()
105
+ # Serialize: start/end via .isoformat(), results via .model_dump() if not None
106
+ # Return TestSummary.model_validate(response.json())
107
+ ```
108
+
109
+ ### Update (new pattern - first PUT/update in this client)
110
+
111
+ ```python
112
+ def update_test(
113
+ self,
114
+ test_id: str,
115
+ *,
116
+ sport: Sport | str,
117
+ start: datetime,
118
+ title: str | None = None,
119
+ end: datetime | None = None,
120
+ results: TestResults | None = None,
121
+ tags: list[str] | None = None,
122
+ ) -> None:
123
+ # PUT /api/v1/tests/{test_id}
124
+ # Returns None (API returns {"message": "..."})
125
+ #
126
+ # Docstring must clearly state full-replace semantics:
127
+ # "Updates a test by replacing all fields. Fields not provided are set
128
+ # to null. To modify a single field, first fetch the test with
129
+ # get_test(), then pass all fields back."
130
+ ```
131
+
132
+ ### Delete (new pattern - first DELETE in this client)
133
+
134
+ ```python
135
+ def delete_test(self, test_id: str) -> None:
136
+ # DELETE /api/v1/tests/{test_id}
137
+ # Returns None
138
+ ```
139
+
140
+ ## Step 4: Register singleton methods
141
+
142
+ Add to `_generate_singleton_methods()`:
143
+ ```python
144
+ "get_tests",
145
+ "get_test",
146
+ "create_test",
147
+ "update_test",
148
+ "delete_test",
149
+ ```
150
+
151
+ ## Step 5: Tests (pytest)
152
+
153
+ Add `tests/test_tests.py`. Follow the existing testing style (see `test_dtype_conversion.py`, `test_webhooks.py`) - test pure logic without hitting the API.
154
+
155
+ Focus on:
156
+ - **Serialization round-trip**: Create a `TestResults` with `Marker` objects, serialize via `.model_dump()`, validate back via `TestSummary.model_validate()`. Verify nested structures survive the round-trip.
157
+ - **DataFrame conversion**: Build a list of `TestSummary` objects, convert to DataFrame the same way `get_tests(as_dataframe=True)` would, verify the `results` column gets normalized into `results.first_threshold.power`, `results.vo2max` etc.
158
+ - **Empty DataFrame**: Verify `_create_empty_dataframe_from_model(TestSummary, normalize_columns=["results"])` produces a valid empty DataFrame with the right column structure.
159
+ - **Enum handling**: Verify `_enums_to_strings([Sport.cycling])` works for the sports param, and that string pass-through works.
160
+
161
+ Don't test: HTTP calls, authentication, pagination logic (that's integration testing).
162
+
163
+ ## Step 6: Update skill docs
164
+
165
+ Update `.claude/skills/sweatstack-python/client.md` with the new Tests methods.
166
+
167
+ ## API Reference
168
+
169
+ | Method | Path | Query/Body | Response |
170
+ |--------|------|------------|----------|
171
+ | POST | `/api/v1/tests/` | Body: `{title?, sport, start, end?, results?, tags?}` | `TestSummary` |
172
+ | GET | `/api/v1/tests/` | Query: `start?, end?, sport[]?, tags[]?, created_by?, limit, offset` | `TestSummary[]` |
173
+ | GET | `/api/v1/tests/{test_id}` | - | `TestDetails` |
174
+ | PUT | `/api/v1/tests/{test_id}` | Body: `{title?, sport, start, end?, results?, tags?}` (full replace) | `{"message": "..."}` |
175
+ | DELETE | `/api/v1/tests/{test_id}` | - | `{"message": "..."}` |
176
+
177
+ ### Key Schema Details
178
+
179
+ **TestResults** contains all-optional fields:
180
+ - Thresholds: `first_threshold`, `second_threshold`, `fatmax`, `lt1`, `lt2`, `vt1`, `vt2`, `mlss` (each a `Marker`)
181
+ - Capacity: `vo2max`, `vo2peak` (mL/min), `vlamax` (mmol/L/s), `heart_rate_max` (bpm), `critical_power` (W), `critical_speed` (m/s), `w_prime` (kJ), `d_prime` (m)
182
+ - Economy: `economy` (mL O2/kg/km), `efficiency` (%)
183
+
184
+ **Marker**: `power` (W), `speed` (m/s), `heart_rate` (bpm), `lactate` (mmol/L), `vo2` (mL/min)
185
+
186
+ ### Key Behaviors
187
+ - `end` defaults to `start + 3h` server-side if omitted
188
+ - TestDetails resolves traces by timestamp within window, activities by time overlap
189
+ - App ownership: tests created via app token can only be modified/deleted by that app
190
+ - Tags use AND logic for filtering, sports use OR logic
@@ -0,0 +1,121 @@
1
+ # Plan: Add App Metadata to Python Client
2
+
3
+ ## Summary
4
+
5
+ Add methods for managing per-app JSON metadata on activities, traces, tests, and users. These endpoints require an app token (a token with an `aud` claim) and allow apps to store arbitrary JSON data scoped to their application.
6
+
7
+ ## Code Quality Requirements
8
+
9
+ All code must be extremely clean, robust and maintainable:
10
+ - Follow existing patterns and conventions exactly
11
+ - Consistent naming across the four entity types
12
+ - DRY implementation via shared private helpers
13
+ - Comprehensive type hints and docstrings
14
+
15
+ ## Prerequisites
16
+
17
+ - Schema generation (`uv run generate-response-models`) must have been run (see 001a step 1). The `app_metadata` field appears on entity response schemas.
18
+ - Plan 001a (Tests) should be completed first since test metadata endpoints reference the tests resource.
19
+
20
+ ## Step 1: Add private helpers to `client.py`
21
+
22
+ All eight public methods do the same thing with different paths. Extract two private helpers to keep the implementation DRY:
23
+
24
+ ```python
25
+ def _set_app_metadata(self, path: str, data: dict) -> None:
26
+ """PUT arbitrary JSON dict to an app-metadata endpoint."""
27
+ with self._http_client() as client:
28
+ response = client.put(url=path, json=data)
29
+ self._raise_for_status(response)
30
+
31
+ def _delete_app_metadata(self, path: str) -> None:
32
+ """DELETE an app-metadata endpoint."""
33
+ with self._http_client() as client:
34
+ response = client.delete(url=path)
35
+ self._raise_for_status(response)
36
+ ```
37
+
38
+ ## Step 2: Add public App Metadata methods to `client.py`
39
+
40
+ Eight methods, four entity types x two operations (set/delete). Each is a thin wrapper over the helpers.
41
+
42
+ Naming convention: `set_*_app_metadata` / `delete_*_app_metadata` - uses "set" since PUT has full-replace semantics (not a partial merge), and includes "app" to match the API path (`/app-metadata`) and the response field name (`app_metadata`).
43
+
44
+ ```python
45
+ def set_activity_app_metadata(self, activity_id: str, *, data: dict) -> None:
46
+ # PUT /api/v1/activities/{activity_id}/app-metadata
47
+ self._set_app_metadata(f"/api/v1/activities/{activity_id}/app-metadata", data)
48
+
49
+ def delete_activity_app_metadata(self, activity_id: str) -> None:
50
+ # DELETE /api/v1/activities/{activity_id}/app-metadata
51
+ self._delete_app_metadata(f"/api/v1/activities/{activity_id}/app-metadata")
52
+
53
+ def set_trace_app_metadata(self, trace_id: str, *, data: dict) -> None:
54
+ def delete_trace_app_metadata(self, trace_id: str) -> None:
55
+
56
+ def set_test_app_metadata(self, test_id: str, *, data: dict) -> None:
57
+ def delete_test_app_metadata(self, test_id: str) -> None:
58
+
59
+ def set_user_app_metadata(self, *, data: dict) -> None:
60
+ # PUT /api/v1/profile/app-metadata (no entity_id, operates on authenticated user)
61
+
62
+ def delete_user_app_metadata(self) -> None:
63
+ # DELETE /api/v1/profile/app-metadata
64
+ ```
65
+
66
+ **Implementation notes:**
67
+ - `data` is keyword-only (after `*`) to be explicit about what's being sent
68
+ - For entity methods, `entity_id` is positional (consistent with `get_activity(activity_id)` etc.)
69
+ - All error handling delegated to `_raise_for_status`: 403 (no app token), 413 (over size limit), 422 (nesting too deep)
70
+ - Each method gets a proper docstring despite being thin - the user-facing API should be self-documenting
71
+
72
+ ## Step 3: Register singleton methods
73
+
74
+ Add all eight to `_generate_singleton_methods()`:
75
+ ```python
76
+ "set_activity_app_metadata",
77
+ "delete_activity_app_metadata",
78
+ "set_trace_app_metadata",
79
+ "delete_trace_app_metadata",
80
+ "set_test_app_metadata",
81
+ "delete_test_app_metadata",
82
+ "set_user_app_metadata",
83
+ "delete_user_app_metadata",
84
+ ```
85
+
86
+ ## Step 4: Tests (pytest)
87
+
88
+ Add `tests/test_metadata.py`. Lightweight - the methods are thin wrappers, so there's not much pure logic to test.
89
+
90
+ Focus on:
91
+ - **Path construction**: Verify the correct API path is built for each entity type. Can test by checking the URL passed to the helper (or by testing the helpers directly with a mock/spy on `_http_client`).
92
+
93
+ Don't over-test: these are simple PUT/DELETE wrappers. If the paths are correct and `_raise_for_status` is called, the methods are correct.
94
+
95
+ ## Step 5: Update skill docs
96
+
97
+ Update `.claude/skills/sweatstack-python/client.md` with the new metadata methods.
98
+
99
+ ## Step 6: Changelog
100
+
101
+ Add entries to the `[Unreleased]` section of `CHANGELOG.md`.
102
+
103
+ ## API Reference
104
+
105
+ | Method | Path | Body | Size Limit | Response |
106
+ |--------|------|------|------------|----------|
107
+ | PUT | `/api/v1/activities/{id}/app-metadata` | JSON dict | 1KB | `{"message": "..."}` |
108
+ | DELETE | `/api/v1/activities/{id}/app-metadata` | - | - | 204 |
109
+ | PUT | `/api/v1/traces/{id}/app-metadata` | JSON dict | 1KB | `{"message": "..."}` |
110
+ | DELETE | `/api/v1/traces/{id}/app-metadata` | - | - | 204 |
111
+ | PUT | `/api/v1/tests/{id}/app-metadata` | JSON dict | 1KB | `{"message": "..."}` |
112
+ | DELETE | `/api/v1/tests/{id}/app-metadata` | - | - | 204 |
113
+ | PUT | `/api/v1/profile/app-metadata` | JSON dict | 4KB | `{"message": "..."}` |
114
+ | DELETE | `/api/v1/profile/app-metadata` | - | - | 204 |
115
+
116
+ ### Key Behaviors
117
+ - All require app token (token with `aud` claim) - 403 otherwise
118
+ - PUT replaces the entire metadata dict (no partial merge)
119
+ - Each app's metadata is isolated (apps can't see each other's metadata)
120
+ - `app_metadata` field appears on entity responses only when accessed via app token
121
+ - Metadata cascades on entity or app deletion
@@ -0,0 +1,165 @@
1
+ # Plan: Add Dailies (Daily Health Metrics) to Python Client
2
+
3
+ ## Summary
4
+
5
+ Add support for daily health/body metrics - time-series data like body mass, HRV, resting HR, etc. with server-side interpolation/estimation. Simple CRUD per measure with smart gap-filling on read.
6
+
7
+ ## Code Quality Requirements
8
+
9
+ All code must be extremely clean, robust and maintainable:
10
+ - Follow existing patterns and conventions exactly
11
+ - Clean enum handling matching Sport/Metric patterns
12
+ - Comprehensive type hints and docstrings
13
+
14
+ ## Step 1: Schemas
15
+
16
+ ### Generated schemas
17
+
18
+ Schema generation (`uv run generate-response-models`) must have been run (see 001a step 1). This produces:
19
+
20
+ - `DailyMeasure` enum: `body_mass`, `body_fat_pct`, `resting_hr`, `hrv`, `sleep_duration`, `sleep_altitude`, `menstrual_cycle_day`
21
+ - `DailyResponse`: `date`, `value` (nullable float), `source` (str)
22
+
23
+ ### Enum enhancements in `schemas.py`
24
+
25
+ Add `DailyMeasure` enhancements following the existing `Sport`/`Metric` pattern:
26
+ - `display_name()` method (replace underscores with spaces)
27
+ - `_missing_` classmethod handler for forward compatibility with new measures
28
+
29
+ ### Re-exports
30
+
31
+ Add `DailyMeasure` and `DailyResponse` to the imports in `schemas.py` (from `openapi_schemas`) and to the imports in `client.py` (from `schemas`). They become available to users via `from sweatstack import DailyMeasure` since `__init__.py` is `from .client import *`.
32
+
33
+ ## Step 2: Add Dailies methods to `client.py`
34
+
35
+ Three methods matching the three API endpoints.
36
+
37
+ **Design decision: `measure` is positional.** This breaks the convention that list/create methods use keyword-only params, but it's justified: `measure` is part of the URL path (`/dailies/{measure}`), identifying which time-series resource to operate on. It's analogous to `get_activity(activity_id)` rather than to a filter parameter.
38
+
39
+ **Design decision: `set_daily` not `create_daily`.** The API has upsert semantics (creates or updates). `create_daily` implies it would fail if the entry exists; `set_daily` honestly communicates "set this value for this date" regardless of prior state. This also aligns with `set_*_app_metadata` for other upsert operations in 001b.
40
+
41
+ ```python
42
+ def get_dailies(
43
+ self,
44
+ measure: DailyMeasure | str,
45
+ *,
46
+ start: date,
47
+ end: date,
48
+ interpolate: bool = True,
49
+ as_dataframe: bool = False,
50
+ ) -> list[DailyResponse] | pd.DataFrame:
51
+ """Gets daily values for a measure over a date range.
52
+
53
+ Args:
54
+ measure: The daily measure to retrieve (e.g. DailyMeasure.body_mass).
55
+ start: Start date (inclusive).
56
+ end: End date (inclusive).
57
+ interpolate: Whether to apply server-side estimation/interpolation.
58
+ Defaults to True. When False, missing dates return value=None
59
+ with source="missing".
60
+ as_dataframe: Whether to return results as a pandas DataFrame.
61
+ Defaults to False.
62
+
63
+ Returns:
64
+ Either a list of DailyResponse objects or a pandas DataFrame with
65
+ date as index. Always returns one entry per date in the range.
66
+ """
67
+ # GET /api/v1/dailies/{measure}
68
+ # Convert measure enum to string for URL path
69
+ # Query: start, end, interpolate
70
+ # Parse response as list of DailyResponse
71
+ # DataFrame mode: set date as index for natural time-series usage
72
+
73
+ def set_daily(
74
+ self,
75
+ measure: DailyMeasure | str,
76
+ *,
77
+ date: date,
78
+ value: float,
79
+ ) -> DailyResponse:
80
+ """Sets a daily value (creates or updates).
81
+
82
+ Args:
83
+ measure: The daily measure (e.g. DailyMeasure.body_mass).
84
+ date: The date for the measurement.
85
+ value: The measurement value.
86
+
87
+ Returns:
88
+ DailyResponse: The created/updated daily entry.
89
+ """
90
+ # POST /api/v1/dailies/{measure}
91
+ # Body: {date: date.isoformat(), value: value}
92
+
93
+ def delete_daily(
94
+ self,
95
+ measure: DailyMeasure | str,
96
+ *,
97
+ date: date,
98
+ ) -> None:
99
+ """Deletes a daily value.
100
+
101
+ Args:
102
+ measure: The daily measure to delete.
103
+ date: The date of the entry to delete.
104
+
105
+ Raises:
106
+ HTTPStatusError: 404 if entry does not exist.
107
+ """
108
+ # DELETE /api/v1/dailies/{measure}?date={date.isoformat()}
109
+ # Returns None (API returns 204)
110
+ ```
111
+
112
+ ## Step 3: Register singleton methods
113
+
114
+ Add to `_generate_singleton_methods()`:
115
+ ```python
116
+ "get_dailies",
117
+ "set_daily",
118
+ "delete_daily",
119
+ ```
120
+
121
+ ## Step 4: Tests (pytest)
122
+
123
+ Add `tests/test_dailies.py`. Follow existing style - test pure logic without hitting the API.
124
+
125
+ Focus on:
126
+ - **DailyMeasure enum enhancements**: Verify `display_name()` returns expected strings (e.g. `DailyMeasure.body_fat_pct.display_name()` -> `"body fat pct"`). Verify `_missing_` handles unknown values gracefully.
127
+ - **DataFrame conversion**: Build a list of `DailyResponse` objects, convert to DataFrame the same way `get_dailies(as_dataframe=True)` would, verify date is set as index and columns are correct.
128
+ - **Enum to string conversion**: Verify `DailyMeasure.body_mass` serializes to `"body_mass"` for the URL path.
129
+
130
+ Don't test: HTTP calls, interpolation logic (that's server-side).
131
+
132
+ ## Step 5: Update skill docs
133
+
134
+ Update `.claude/skills/sweatstack-python/client.md` with the new Dailies methods.
135
+
136
+ ## Step 6: Changelog
137
+
138
+ Add entries to the `[Unreleased]` section of `CHANGELOG.md`.
139
+
140
+ ## API Reference
141
+
142
+ | Method | Path | Query/Body | Response |
143
+ |--------|------|------------|----------|
144
+ | GET | `/api/v1/dailies/{measure}` | Query: `start` (date, required), `end` (date, required), `interpolate` (bool, default true) | `DailyResponse[]` |
145
+ | POST | `/api/v1/dailies/{measure}` | Body: `{date, value}` | `DailyResponse` |
146
+ | DELETE | `/api/v1/dailies/{measure}` | Query: `date` (date, required) | 204 |
147
+
148
+ ### Supported Measures
149
+
150
+ | Measure | Unit | Interpolation Strategy |
151
+ |---------|------|------------------------|
152
+ | `body_mass` | kg | Linear interpolation + fill |
153
+ | `body_fat_pct` | % | Linear interpolation + fill |
154
+ | `resting_hr` | bpm | None (gaps shown) |
155
+ | `hrv` | ms | None (gaps shown) |
156
+ | `sleep_duration` | seconds | None (gaps shown) |
157
+ | `sleep_altitude` | meters | Forward/backward fill |
158
+ | `menstrual_cycle_day` | day | Auto-increment from 0 |
159
+
160
+ ### Key Behaviors
161
+ - Composite key: (user, date, measure) - one value per measure per day
162
+ - Source precedence: manual entries are never overwritten by integration imports
163
+ - `interpolate=True`: server fills gaps using measure-specific strategy
164
+ - `interpolate=False`: returns raw data with `{value: None, source: "missing"}` for gaps
165
+ - Always returns exactly one entry per date in the requested range
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sweatstack"
3
- version = "0.72.0"
3
+ version = "0.73.0"
4
4
  description = "The official Python client for SweatStack"
5
5
  readme = "README.md"
6
6
  license = "MIT"