sweatstack 0.72.0__tar.gz → 0.74.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sweatstack-0.72.0 → sweatstack-0.74.0}/.claude/settings.local.json +2 -1
- {sweatstack-0.72.0 → sweatstack-0.74.0}/.claude/skills/sweatstack-python/client.md +111 -2
- {sweatstack-0.72.0 → sweatstack-0.74.0}/CHANGELOG.md +14 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/PKG-INFO +1 -1
- sweatstack-0.74.0/plans/001a_tests.md +190 -0
- sweatstack-0.74.0/plans/001b_metadata.md +121 -0
- sweatstack-0.74.0/plans/001c_dailies.md +165 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/pyproject.toml +1 -1
- {sweatstack-0.72.0 → sweatstack-0.74.0}/src/sweatstack/client.py +494 -10
- {sweatstack-0.72.0 → sweatstack-0.74.0}/src/sweatstack/openapi_schemas.py +237 -8
- {sweatstack-0.72.0 → sweatstack-0.74.0}/src/sweatstack/schemas.py +34 -3
- sweatstack-0.74.0/tests/test_dailies.py +117 -0
- sweatstack-0.74.0/tests/test_metadata.py +59 -0
- sweatstack-0.74.0/tests/test_teams.py +113 -0
- sweatstack-0.74.0/tests/test_tests.py +191 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/uv.lock +1 -1
- {sweatstack-0.72.0 → sweatstack-0.74.0}/.claude/skills/sweatstack-python/SKILL.md +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/.claude/skills/sweatstack-python/data-models.md +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/.claude/skills/sweatstack-python/fastapi.md +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/.claude/skills/sweatstack-python/streamlit.md +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/.gitignore +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/.python-version +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/CONTRIBUTING.md +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/DEVELOPMENT.md +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/LICENSE +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/Makefile +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/README.md +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/docs/conf.py +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/docs/everything.rst +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/docs/index.rst +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/examples/fastapi_webhooks_example.py +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/examples/send_webhook.py +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/src/sweatstack/__init__.py +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/src/sweatstack/cli.py +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/src/sweatstack/constants.py +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/src/sweatstack/fastapi/__init__.py +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/src/sweatstack/fastapi/config.py +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/src/sweatstack/fastapi/dependencies.py +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/src/sweatstack/fastapi/models.py +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/src/sweatstack/fastapi/routes.py +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/src/sweatstack/fastapi/session.py +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/src/sweatstack/fastapi/token_stores.py +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/src/sweatstack/fastapi/webhooks.py +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/src/sweatstack/ipython_init.py +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/src/sweatstack/py.typed +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/src/sweatstack/streamlit.py +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/src/sweatstack/sweatshell.py +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/src/sweatstack/utils.py +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/tests/__init__.py +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/tests/test_dtype_conversion.py +0 -0
- {sweatstack-0.72.0 → sweatstack-0.74.0}/tests/test_webhooks.py +0 -0
|
@@ -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
|
|
@@ -184,7 +286,13 @@ user = client.get_user("abc123", search_mode="id")
|
|
|
184
286
|
# Create a managed user (no login credentials)
|
|
185
287
|
user = client.create_user(first_name="John", last_name="Doe")
|
|
186
288
|
|
|
187
|
-
#
|
|
289
|
+
# List teams you're a member/owner of
|
|
290
|
+
teams = client.get_teams() # list[TeamResponse] with .role
|
|
291
|
+
|
|
292
|
+
# List teams you've authorized to access your data
|
|
293
|
+
authorized = client.get_authorized_teams() # list[AuthorizedTeamResponse] with .scopes
|
|
294
|
+
|
|
295
|
+
# Team user management
|
|
188
296
|
team_users = client.get_team_users(team_id="team_abc")
|
|
189
297
|
athlete = client.get_team_user(team_id="team_abc", user="john") # by name or ID
|
|
190
298
|
client.authorize_team(team_id="team_abc", scopes=[Scope.data_read])
|
|
@@ -264,6 +372,7 @@ df = sweatstack.get_latest_activity_data()
|
|
|
264
372
|
- **`start` is required for longitudinal endpoints.** Unlike `get_activities()` where all filters are optional.
|
|
265
373
|
- **`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
374
|
- **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 `
|
|
375
|
+
- **`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.
|
|
376
|
+
- **`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
377
|
- **`summary` fields are optional.** Always null-check: `activity.summary.power.mean if activity.summary and activity.summary.power else None`.
|
|
269
378
|
- **`metrics` on ActivitySummary** lists available data streams, not the data itself. Use to check availability before calling `get_activity_data()`.
|
|
@@ -6,6 +6,20 @@ 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.74.0] - 2026-04-22
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- Team listing — `get_teams()` returns teams you own or belong to, `get_authorized_teams()` returns teams you've granted data access to.
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
## [0.73.0] - 2026-04-09
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- Full support for fitness tests — create, retrieve, update, and delete physiological assessments (threshold tests, VO2max tests, etc.) and their results.
|
|
19
|
+
- App metadata — store and retrieve per-app JSON data on activities, traces, tests, and users.
|
|
20
|
+
- Dailies — get, set, and delete daily health metrics (body mass, HRV, resting HR, etc.) with optional server-side interpolation.
|
|
21
|
+
|
|
22
|
+
|
|
9
23
|
## [0.72.0] - 2026-03-13
|
|
10
24
|
|
|
11
25
|
### Added
|
|
@@ -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
|