sweatstack 0.98.0__tar.gz → 0.99.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.98.0 → sweatstack-0.99.0}/AGENTS.md +7 -2
- {sweatstack-0.98.0 → sweatstack-0.99.0}/CHANGELOG.md +89 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/PKG-INFO +1 -1
- sweatstack-0.99.0/plans/012_writer_and_platform.md +391 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/pyproject.toml +1 -1
- {sweatstack-0.98.0 → sweatstack-0.99.0}/skills/sweatstack-python/api.md +6 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/skills/sweatstack-python/data-models.md +9 -4
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/_renames.py +2 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/client.py +2 -2
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/openapi_schemas.py +231 -40
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/activities.py +40 -4
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/dailies.py +24 -9
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/schemas.py +6 -7
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_changelog.py +33 -6
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_dailies.py +92 -35
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_frames.py +11 -3
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_output.py +4 -8
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_renames.py +5 -7
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_resources.py +82 -12
- {sweatstack-0.98.0 → sweatstack-0.99.0}/uv.lock +1 -1
- {sweatstack-0.98.0 → sweatstack-0.99.0}/.git-blame-ignore-revs +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/.github/workflows/ci.yml +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/.gitignore +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/.gitleaks.toml +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/.python-version +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/CONTRIBUTING.md +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/DEVELOPMENT.md +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/LICENSE +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/Makefile +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/README.md +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/UPGRADING.md +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/evals/__init__.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/evals/agent/__init__.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/evals/agent/agent.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/evals/agent/cases.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/evals/agent/evaluators.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/evals/agent/harness.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/evals/agent/lifecycle.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/evals/agent/run.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/examples/fastapi_webhooks_example.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/examples/send_webhook.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/001a_tests.md +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/001b_metadata.md +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/001c_dailies.md +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/003_trace_test_linking.md +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/004_codebase_hygiene.md +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/005_ost_sport_bridge.md +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/006_output_backends.md +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/007_account_status_userinfo_portal.md +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/008_sdk_documentation.md +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/009_road_to_1_0.md +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/010_credentials_one_source.md +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/011_user_agent_on_every_request.md +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/skills/sweatstack-python/SKILL.md +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/skills/sweatstack-python/fastapi.md +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/skills/sweatstack-python/streamlit.md +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/__init__.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/_docs.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/_frames.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/_transport.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/cli.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/constants.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/exceptions.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/fastapi/__init__.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/fastapi/config.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/fastapi/dependencies.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/fastapi/models.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/fastapi/routes.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/fastapi/session.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/fastapi/token_stores.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/fastapi/webhooks.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/py.typed +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/__init__.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/_app_metadata.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/_base.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/oauth.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/portal.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/profile.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/teams.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/tests.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/traces.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/users.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/streamlit.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/utils.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/__init__.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/conftest.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_access_token_cache.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_account_status.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_authorization_url.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_credentials.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_docs_check.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_docstrings.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_dtype_conversion.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_exceptions.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_identity.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_longitudinal_mean_max_after.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_metadata.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_portal.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_public_hygiene.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_public_surface.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_segmentation.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_skill.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_sport_ost.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_streamlit_auth.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_teams.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_tests.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_trace_test_linking.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_transport.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_user_agent.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_webhooks.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_wire_names.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_write_timezone_validation.py +0 -0
- {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/typing/output_types.py +0 -0
|
@@ -121,6 +121,9 @@ it automatically.
|
|
|
121
121
|
|
|
122
122
|
## Hard rules
|
|
123
123
|
|
|
124
|
+
- **Work on `main`, never on a branch.** Every commit goes straight onto `main`; don't create
|
|
125
|
+
branches in this repository. Work that must wait for a server release stays uncommitted or
|
|
126
|
+
unpushed, or is committed without being published, rather than parked on a branch.
|
|
124
127
|
- **`uv`, never `pip`.** Every command goes through `uv run` / `uv add`.
|
|
125
128
|
- **`make check` before every commit.** Format, lint, types and tests; `make
|
|
126
129
|
publish` runs it too. Don't silence a finding without a comment saying why.
|
|
@@ -137,9 +140,11 @@ it automatically.
|
|
|
137
140
|
- **Enum-typed params accept `Enum | str`** and route through
|
|
138
141
|
`_enums_to_strings`. Don't introduce strict-enum-only parameters.
|
|
139
142
|
- **Open enums stay open.** When the server documents an enum as an open
|
|
140
|
-
set (metrics, scopes, daily measures,
|
|
143
|
+
set (metrics, scopes, daily measures, status codes, capabilities),
|
|
141
144
|
register it with `_open_enum(...)` in `schemas.py` so unknown values
|
|
142
|
-
parse as pseudo-members. Leave closed sets strict.
|
|
145
|
+
parse as pseudo-members. Leave closed sets strict. `WriterKind` is closed
|
|
146
|
+
by the server's contract: a new writer is a breaking release, so keep it
|
|
147
|
+
strict and let the parse fail.
|
|
143
148
|
- **Never retry a request the server can't safely repeat.** `_transport.RETRYABLE_METHODS`
|
|
144
149
|
is `GET`, `PUT` and `DELETE`; a `POST` is sent once (no idempotency keys). Don't add
|
|
145
150
|
per-method retry logic: everything goes through `_request`.
|
|
@@ -11,6 +11,95 @@ for a coding agent, are in `UPGRADING.md` next to this file and at
|
|
|
11
11
|
https://docs.sweatstack.no/learn/python/upgrading/.
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
## [0.99.0] - 2026-11-09
|
|
15
|
+
|
|
16
|
+
Records say who wrote them and which platform their data came from, as two separate answers.
|
|
17
|
+
Breaking: `DailyResponse.source`, `SourceResponse.origin` and `DailySource` are gone; see
|
|
18
|
+
**Upgrading** below, or hand the migration prompt to your coding agent.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Dailies, uploaded sources, markers and max efforts carry `writer` (`WriterKind`: `athlete`,
|
|
23
|
+
`application` or `sweatstack`), `application_id` (the app, when the writer is an app) and
|
|
24
|
+
`platform` (where the data came from: `"garmin_connect"`, `"strava"`, ..., or `None`). They
|
|
25
|
+
replace `DailyResponse.source` and `SourceResponse.origin`, which answered both at once. On a
|
|
26
|
+
daily, all three are `None` on an estimated or missing day.
|
|
27
|
+
- The frames from `dailies.list()` have `writer`, `application_id` and `platform` columns instead
|
|
28
|
+
of `source`. In pandas, `writer` holds `WriterKind` members, as every enum column does; in
|
|
29
|
+
Polars and Arrow it holds strings.
|
|
30
|
+
- `DailySource` is gone, and so is `SourceOrigin` from `sweatstack.openapi_schemas`.
|
|
31
|
+
`WriterKind` is a closed set: a value outside the three is an error, not a pseudo-member.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- `dailies.set()` and `activities.upload()` take `platform=` to say where the data came from.
|
|
36
|
+
SweatStack accepts it with a token from `authenticate()` or one issued to your app, and answers
|
|
37
|
+
`SweatStackBadRequestError` for a personal API key or a malformed name. It stores the canonical
|
|
38
|
+
spelling: `"garmin"` reads back as `"garmin_connect"`. One `platform` applies to every file in
|
|
39
|
+
an upload.
|
|
40
|
+
- `activities.upload()` takes `external_id=`: your app's own ID for each file, one per file in
|
|
41
|
+
order, with a token issued to your app. Uploading again with the same IDs is safe: an unchanged
|
|
42
|
+
file returns its existing source, and a changed one is processed again under the same source ID,
|
|
43
|
+
with new activities. The source echoes the ID as `SourceResponse.external_id`.
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- The `dailies.list()` docstring said a missing day has `source="missing"`; it has
|
|
48
|
+
`status="missing"`.
|
|
49
|
+
|
|
50
|
+
<!-- --8<-- [start:upgrading-0-99] -->
|
|
51
|
+
### Upgrading
|
|
52
|
+
|
|
53
|
+
**Upgrade on the day SweatStack's API makes this change** (the platform changelog has the date).
|
|
54
|
+
0.99 needs that API, and from that day SDKs before 0.99 behave like this:
|
|
55
|
+
|
|
56
|
+
- `activities.upload()` raises a pydantic `ValidationError` *after* the server has accepted the
|
|
57
|
+
files. The files are uploaded, so don't retry the call.
|
|
58
|
+
- `dailies.list()` keeps working, but **Silent:** `source` is `None` on every day.
|
|
59
|
+
|
|
60
|
+
| 0.98 | 0.99 | Fails how |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `daily.source == DailySource.manual` | `daily.writer == WriterKind.athlete` | `AttributeError` on `.source` |
|
|
63
|
+
| `daily.source == DailySource.application` | `daily.writer == WriterKind.application`; `daily.application_id` names the app | `AttributeError` |
|
|
64
|
+
| `daily.source == DailySource.garmin_connect` (or `intervals_icu`, `wahoo`) | `daily.writer == WriterKind.sweatstack and daily.platform == "garmin_connect"`. For "from Garmin, whoever wrote it": `daily.platform == "garmin_connect"` | `AttributeError` |
|
|
65
|
+
| `daily.source is None` (an estimated or missing day) | `daily.writer is None`, or `daily.status != DailyStatus.stored` | `AttributeError` |
|
|
66
|
+
| `source.origin == "manual_upload"` | `source.writer == WriterKind.athlete` | `AttributeError` on `.origin` |
|
|
67
|
+
| `source.origin == "garmin_connect"` (or another sync) | `source.writer == WriterKind.sweatstack and source.platform == "garmin_connect"` | `AttributeError` |
|
|
68
|
+
| `df["source"] == DailySource.manual` on a pandas frame from `dailies.list()` | `df["writer"] == WriterKind.athlete` | `KeyError` |
|
|
69
|
+
| `pl.col("source") == "manual"` on a Polars frame from `dailies.list()` | `pl.col("writer") == "athlete"` | `ColumnNotFoundError` |
|
|
70
|
+
| `.get("source")` on a `model_dump()` or a frame row | `.get("writer")`, `.get("platform")` | **Silent:** returns `None` |
|
|
71
|
+
| `from sweatstack import DailySource` | `from sweatstack import WriterKind` | `ImportError` |
|
|
72
|
+
| `from sweatstack.openapi_schemas import SourceOrigin` | `from sweatstack import WriterKind` | `ImportError` |
|
|
73
|
+
|
|
74
|
+
**Migration prompt.** Paste this into your coding agent:
|
|
75
|
+
|
|
76
|
+
```text
|
|
77
|
+
Upgrade this codebase to sweatstack 0.99, which splits "who wrote a record" from "which
|
|
78
|
+
platform its data came from". The upgrade table is the "Upgrading" section of the 0.99.0 entry
|
|
79
|
+
in https://github.com/SweatStack/sweatstack-python/blob/main/CHANGELOG.md (also at
|
|
80
|
+
https://docs.sweatstack.no/learn/python/upgrading/). Read it first.
|
|
81
|
+
|
|
82
|
+
Find every read of `.source` on a daily, marker or max effort, of `.origin` on an uploaded
|
|
83
|
+
source, every use of DailySource or SourceOrigin, and every "source" column or key on dailies
|
|
84
|
+
data (frames, model_dump(), dicts). For each, decide which question the code asks:
|
|
85
|
+
|
|
86
|
+
1. Who wrote it (the athlete, an app, or SweatStack's sync)? Use `.writer` and `WriterKind`;
|
|
87
|
+
`.application_id` names the app.
|
|
88
|
+
2. Where did the data come from (Garmin, Strava, ...)? Use `.platform`, a plain string such as
|
|
89
|
+
"garmin_connect", or None.
|
|
90
|
+
3. Both, as the old garmin_connect value did? Combine them:
|
|
91
|
+
writer == WriterKind.sweatstack and platform == "garmin_connect". Ask whether the code
|
|
92
|
+
really means "from Garmin, whoever wrote it": then platform alone.
|
|
93
|
+
|
|
94
|
+
In pandas frames compare `writer` with WriterKind members (df["writer"] == WriterKind.athlete);
|
|
95
|
+
comparing with the string "athlete" is always False. In Polars and Arrow, compare with strings.
|
|
96
|
+
Code that checked `source is None` to find estimated or missing days should check
|
|
97
|
+
`status != DailyStatus.stored`. Search for `.get("source")` and "source" in column lists: those
|
|
98
|
+
return None or drop the column without an error. Then run the code and the tests.
|
|
99
|
+
```
|
|
100
|
+
<!-- --8<-- [end:upgrading-0-99] -->
|
|
101
|
+
|
|
102
|
+
|
|
14
103
|
## [0.98.0] - 2026-10-30
|
|
15
104
|
|
|
16
105
|
### Added
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
# Plan 012: Who wrote a record, and which platform it came from
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
- **READY, 2026-11-09.** Mirrors a server release that is planned but not yet live. Every server
|
|
6
|
+
fact below was checked against the server's public contract as it will be after that release,
|
|
7
|
+
not as it is today. Where today's production differs, both are given.
|
|
8
|
+
- **Ships as 0.99.0**, a breaking minor, on the day of the server release and after it. See
|
|
9
|
+
[Release](#release).
|
|
10
|
+
- **Effort:** under a day, plus a short release-day step.
|
|
11
|
+
- **Progress, 2026-11-09.** Commit 1 is on `main`. Commit 2 is complete on `writer-and-platform`,
|
|
12
|
+
regenerated from the server's finished code (see [Checked before release](#checked-before-release)).
|
|
13
|
+
What's left is the release: commit 3 and [Release](#release).
|
|
14
|
+
- **Process:** red-green. Write the tests in [Tests](#tests) first.
|
|
15
|
+
- **The server dropped the upload's `replace` flag** and tells a retry from a correction by the
|
|
16
|
+
file's bytes (see [Relaying files](#relaying-files)), so S11's first branch applies: there is
|
|
17
|
+
nothing to mirror.
|
|
18
|
+
|
|
19
|
+
> **Public repository.** This file is published with the code. It describes the server by its
|
|
20
|
+
> public contract (the OpenAPI document and docs.sweatstack.no) and names no app, no user and
|
|
21
|
+
> nothing learned from server traffic (AGENTS.md, "Public repository").
|
|
22
|
+
|
|
23
|
+
## Summary
|
|
24
|
+
|
|
25
|
+
Until now one field answered two questions. `DailyResponse.source` and `SourceResponse.origin`
|
|
26
|
+
said `manual`, `application` or `garmin_connect`, and the last value meant both "SweatStack's sync
|
|
27
|
+
wrote it" and "it came from Garmin". That works until an app relays Garmin data: the writer is then
|
|
28
|
+
the app and the platform is Garmin, and one field cannot hold both.
|
|
29
|
+
|
|
30
|
+
After the server release, every record a writer creates carries the same three fields:
|
|
31
|
+
|
|
32
|
+
| Field | Type | Meaning |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| `writer` | `WriterKind`: `athlete`, `application`, `sweatstack` | Who is accountable for the write. Comes from the token; no request can set it. **A closed set.** |
|
|
35
|
+
| `application_id` | `str \| None` | Which app, when the writer is `application`. |
|
|
36
|
+
| `platform` | `str \| None` | The platform the data came from: `garmin_connect`, `strava`, ... or none. **An open set**: a plain string, stored and echoed in one canonical spelling. |
|
|
37
|
+
|
|
38
|
+
The five cases this model distinguishes (today's model can express three of them):
|
|
39
|
+
|
|
40
|
+
| Case | `writer` | `application_id` | `platform` |
|
|
41
|
+
|---|---|---|---|
|
|
42
|
+
| The athlete types a value or uploads a file | `athlete` | `None` | `None` |
|
|
43
|
+
| SweatStack's own Garmin sync | `sweatstack` | `None` | `"garmin_connect"` |
|
|
44
|
+
| An app relays Garmin data | `application` | `"app_123"` | `"garmin_connect"` |
|
|
45
|
+
| An app computes its own value | `application` | `"app_123"` | `None` |
|
|
46
|
+
| The athlete imports a Strava export through this SDK's sign-in | `athlete` | `None` | `"strava"` |
|
|
47
|
+
|
|
48
|
+
## The server contract this mirrors
|
|
49
|
+
|
|
50
|
+
The server's next release is breaking. As it will appear in its OpenAPI document:
|
|
51
|
+
|
|
52
|
+
**Responses.** `DailyResponse`, `SourceResponse`, `MarkerRowResponse` and `MaxEffortRowResponse`
|
|
53
|
+
carry `writer`, `application_id` and `platform`. `source`, `origin` and `provider` disappear from
|
|
54
|
+
them, and `DailySource` and `SourceOrigin` disappear from the document. On `DailyResponse`, `writer`
|
|
55
|
+
is optional and is set only on a stored day. An `estimated` or `missing` day has no writer, so all
|
|
56
|
+
three fields are `null`, just as `source` was. On the other three models, `writer` is required.
|
|
57
|
+
|
|
58
|
+
**Requests.** These writes take an optional `platform`: the upload form (`POST /activities/upload`),
|
|
59
|
+
`POST /dailies/{measure}` and its `/batch`, `POST /profile/markers` and `POST /profile/max-efforts`.
|
|
60
|
+
Who may send it:
|
|
61
|
+
|
|
62
|
+
| The client's token | Writer recorded | `platform` |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| Issued to an app (a client ID of the developer's own) | `application`, with that app's ID | accepted |
|
|
65
|
+
| Issued to one of SweatStack's own clients: this SDK's `authenticate()` sign-in | `athlete` | accepted |
|
|
66
|
+
| A personal API key from SweatStack's settings, or a web session | `athlete` | **`422`**, never ignored |
|
|
67
|
+
|
|
68
|
+
A platform name must match `^[a-z][a-z0-9_]{0,39}$`, or the server returns a `422` whose message
|
|
69
|
+
states that rule. A known alias resolves to its canonical name, so `"garmin"` is stored and read
|
|
70
|
+
back as `"garmin_connect"`. An unknown name of the right shape is stored as given. The upload
|
|
71
|
+
form's `provider` field goes away with no alias, and `platform` replaces it.
|
|
72
|
+
|
|
73
|
+
### Relaying files
|
|
74
|
+
|
|
75
|
+
The upload also takes `external_id`, which production already accepts and the SDK doesn't send
|
|
76
|
+
yet. It is for an app that relays files it received from another platform: the app's own ID for
|
|
77
|
+
each file, one per file in the files' order. It needs a token issued to an app, and any other token
|
|
78
|
+
gets a `400`. The number of IDs must equal the number of files, or the server returns a `422`. The
|
|
79
|
+
source echoes the ID back as `SourceResponse.external_id`.
|
|
80
|
+
|
|
81
|
+
When a file comes again with an `external_id` the server has seen from that app:
|
|
82
|
+
|
|
83
|
+
- **Same bytes**: it is a retry. The existing source comes back untouched.
|
|
84
|
+
- **Different bytes**: it is a correction. The server processes the new file under the same source
|
|
85
|
+
ID. The source's old activities are deleted (each with an `activity_deleted` webhook) and new
|
|
86
|
+
ones, with new IDs, take their place.
|
|
87
|
+
|
|
88
|
+
So retrying a lost upload is always safe. Before the release, production keeps the source unless
|
|
89
|
+
the request also sends `replace=true`. The release removes that flag.
|
|
90
|
+
|
|
91
|
+
### What a writer may replace
|
|
92
|
+
|
|
93
|
+
**It does not change.** The athlete and an app may replace anything. A sync
|
|
94
|
+
replaces only what a sync wrote. The platform never affects this. So the `dailies.set()` docstring
|
|
95
|
+
"A value you set is never overwritten by an integration import" stays true.
|
|
96
|
+
|
|
97
|
+
## Where the SDK starts from
|
|
98
|
+
|
|
99
|
+
The bundled `openapi_schemas.py` is older than today's production as well as the coming release.
|
|
100
|
+
Regenerating from today's production document gives about 180 changed lines that have nothing to
|
|
101
|
+
do with this plan: `DailySource.application`, `SourceOrigin.application`,
|
|
102
|
+
`SourceResponse.application_id`, `external_id` and `provider`, `DailyResponse.application_id`, the
|
|
103
|
+
upload form's `external_id`, `provider` and `replace`, the daily batch models, app-managed users,
|
|
104
|
+
connection declarations, and more. That backlog must not land inside this plan's diff
|
|
105
|
+
(DEVELOPMENT.md, "Regenerating", step 5), so it lands first as its own commit. See
|
|
106
|
+
[Commits](#commits).
|
|
107
|
+
|
|
108
|
+
Two consequences of starting from an old schema:
|
|
109
|
+
|
|
110
|
+
- **0.98 never had `SourceResponse.provider` or `DailySource.application` as typed fields.** No SDK
|
|
111
|
+
user can have code that reads them through the model, so the Upgrading table has no rows for
|
|
112
|
+
them.
|
|
113
|
+
- **0.98's `DailySource` is an open enum** (`schemas.py`, `_open_enum`). An `application` value in
|
|
114
|
+
production today parses as a pseudo-member. That registration goes along with the enum.
|
|
115
|
+
|
|
116
|
+
## Decisions
|
|
117
|
+
|
|
118
|
+
| | Decision | Why |
|
|
119
|
+
|---|---|---|
|
|
120
|
+
| S1 | **Mirror the three fields as generated. No derived `source`, no shim.** | AGENTS.md: "Don't add compatibility shims". A derived field would keep two vocabularies alive in users' code and in the docs, which is exactly what the server release removes. |
|
|
121
|
+
| S2 | **`WriterKind` is closed: not registered with `_open_enum`.** `DailySource` leaves the open-enum list. | The server documents `WriterKind` as closed. Three principals is a fact of the model, and a fourth would be a breaking release, not a routine addition. AGENTS.md: "Leave closed sets strict." `platform` is a `str` and is open by type, so it needs nothing. |
|
|
122
|
+
| S3 | **`platform=` on the two write methods the SDK has**: `dailies.set(measure, *, date, value, platform=None)` and `activities.upload(files, *, platform=None)`. Keyword-only, wire name, and `None` sends nothing (R8). | They are the SDK's only methods for the writes that take it. The batch, marker and max-effort writes have no SDK method today, and adding them is a separate change (see [Not doing](#not-doing)). |
|
|
123
|
+
| S4 | **The SDK does not validate or normalise a platform name.** It sends the string as given, and the server's `422` is the message. | The server owns the pattern and the alias table, and both will grow. A client-side copy would drift from them, and an SDK that refuses a name the server accepts is worse than no check. |
|
|
124
|
+
| S5 | **On upload, one `platform` per call covers every file.** It is sent once, as a multipart form field next to the files, in the call's single request. | That is the wire shape: one form field per request. Files from different platforms need separate calls, and the docstring says so. |
|
|
125
|
+
| S6 | **Docstrings describe the token, not the `Client` argument.** In this SDK, `api_key=` holds whatever access token the client was given, so "an API key is refused" would mislead. They say instead: accepted with a token from `authenticate()` or one issued to your app, refused with a personal API key from SweatStack's settings. | The outcome depends on where the token came from, and a user knows that, not which argument they passed it through. |
|
|
126
|
+
| S7 | **A `_renames` entry for `DailySource` only, not for `SourceOrigin`.** | `_renames` turns a removed *public* name into an error that names its replacement. `sweatstack.DailySource` was public (exported since 0.98). `SourceOrigin` was never exported, only reachable through `sweatstack.openapi_schemas`, so a hint for `sweatstack.SourceOrigin` would describe a name that never existed. The CHANGELOG covers it. |
|
|
127
|
+
| S8 | **The SDK never retries an upload by itself, even with `external_id`.** The docstring tells the caller that retrying with the same IDs is safe. | AGENTS.md: a `POST` is sent once, with no per-method retry logic. Whether a retry is wanted is the caller's decision. The server's contract makes that decision safe. |
|
|
128
|
+
| S9 | **Nothing is released while the SDK carries a schema production doesn't serve.** (This plan parked the work on a branch; the repository has since ruled that out, AGENTS.md: work stays on `main`, unpublished, until the server is live.) | 0.99's `SourceResponse` requires `writer`, so it cannot parse today's production. A patch release cut from `main` in between would break every upload. |
|
|
129
|
+
| S10 | **`external_id=` on `activities.upload()`: one string or a list, matched to `files` by position.** It goes through `_wire` and is sent as a repeated form field, and only when given. The SDK doesn't check that the counts match: the server's `422` says so (as S4). | R8: a repeatable parameter takes one value or a list under the wire name. A single string for a single file reads naturally: `upload("ride.fit", external_id="12345")`. It ships with `platform=` because a relaying app needs both. |
|
|
130
|
+
| S11 | **`replace` follows whichever contract ships.** If commit 2's regen shows the upload form without `replace` (the expected outcome), there is nothing to mirror. If the form still has it, mirror it: `replace: bool = False`, keyword-only, sent only when `True`. Its docstring says it applies only with `external_id`, and that it deletes the source's activities and creates new ones with new IDs. The [Relaying files](#relaying-files) wording then becomes "a known ID returns the existing source unless `replace=True`". | The SDK mirrors the API (AGENTS.md) and does not redesign it. The client-side burden this flag creates is the server's to remove, and that has been proposed. Hiding a parameter the server still has would make a correction impossible from the SDK. |
|
|
131
|
+
|
|
132
|
+
## Changes
|
|
133
|
+
|
|
134
|
+
### Commit 1: regenerate from today's production (lands now, on `main`)
|
|
135
|
+
|
|
136
|
+
1. `uv run generate-response-models` against a server that serves **today's production document**
|
|
137
|
+
(DEVELOPMENT.md). Production's document as of 2026-11-09 is version 0.154.2.
|
|
138
|
+
2. Wire-name check: `SWEATSTACK_OPENAPI_JSON=... uv run pytest tests/test_wire_names.py`. Against
|
|
139
|
+
that document it reports two methods:
|
|
140
|
+
- `Activities.upload`: `external_id`, `provider`, `replace`. Add
|
|
141
|
+
`"Activities.upload": {"external_id", "replace", "provider"}` to `NOT_EXPOSED`, with the
|
|
142
|
+
comment "file relay: mirrored in plan 012, commit 2". The entry is temporary, and commit 2
|
|
143
|
+
removes it.
|
|
144
|
+
- `OAuth.authorization_url`: `login_hint`. **Not this plan.** Mirror it in its own small
|
|
145
|
+
change before or after. It must not ride along here.
|
|
146
|
+
3. `make check`. The existing tests must pass unchanged. If one fails, the backlog contains a
|
|
147
|
+
breaking change that needs its own CHANGELOG line. Stop and write that line; don't fix the test
|
|
148
|
+
quietly.
|
|
149
|
+
4. CHANGELOG under `## [Unreleased]`, **Added**: `DailyResponse.application_id` (also a new
|
|
150
|
+
column of the `dailies.list()` frames, which `tests/test_dailies.py` and `tests/test_output.py`
|
|
151
|
+
pin), `SourceResponse.application_id` and `SourceResponse.external_id`. That keeps `main`
|
|
152
|
+
accurate if anything is released before commit 2. Commit 2 folds these lines into its own entry.
|
|
153
|
+
`provider` and `DailySource.application` get no line: commit 2 removes them.
|
|
154
|
+
|
|
155
|
+
### Commit 2: writer and platform (branch `writer-and-platform`)
|
|
156
|
+
|
|
157
|
+
1. **Regenerate** from a local server running the release's code. The expected diff is exactly the
|
|
158
|
+
contract above:
|
|
159
|
+
- `WriterKind` (`athlete`, `application`, `sweatstack`) appears. `DailySource` and
|
|
160
|
+
`SourceOrigin` go.
|
|
161
|
+
- `DailyResponse` gains `writer: WriterKind | None = None` and
|
|
162
|
+
`platform: str | None = None` (it already has `application_id`) and loses `source`.
|
|
163
|
+
- `SourceResponse` gains `writer: WriterKind` (required) and `platform` and loses `origin` and
|
|
164
|
+
`provider`.
|
|
165
|
+
- `MarkerRowResponse` and `MaxEffortRowResponse` gain `writer` and `platform` and lose `source`.
|
|
166
|
+
- The upload form, `DailyCreate`, `DailyBatchCreate`, `MarkerWrite` and `MaxEffortWrite` gain
|
|
167
|
+
`platform`. The upload form loses `provider`, and (expected, S11) `replace`.
|
|
168
|
+
|
|
169
|
+
Anything else in the diff is a server change outside this plan. Commit it separately, as in
|
|
170
|
+
commit 1.
|
|
171
|
+
2. **Exports.** `schemas.py` and `client.py` (its import and `__all__`) re-export `WriterKind`
|
|
172
|
+
where they now re-export `DailySource`. Remove `DailySource` from `_open_enum`'s list in
|
|
173
|
+
`schemas.py`, and change that function's docstring from "sources of a value" to the open sets
|
|
174
|
+
that remain. Check that nothing re-exports `SourceOrigin` (true on 2026-11-09).
|
|
175
|
+
3. **`_renames.REMOVED_IN`**, under a `# 0.99` comment:
|
|
176
|
+
`"DailySource": ("0.99", "sweatstack.WriterKind (a record's writer) and its platform field (where the data came from)")`.
|
|
177
|
+
The hint names `sweatstack.` literally rather than `{c}.`, because `WriterKind` is a
|
|
178
|
+
module-level name, never `client.WriterKind`. Know the limit: the hint reaches
|
|
179
|
+
`sweatstack.DailySource`, while `from sweatstack import DailySource` raises Python's plain
|
|
180
|
+
`ImportError` without it. UPGRADING.md, step 4, already says so, and the Upgrading table has the
|
|
181
|
+
row.
|
|
182
|
+
4. **`dailies.set(..., platform=None)`.** The body is
|
|
183
|
+
`{"date": ..., "value": ..., "platform": platform}`, with `"platform"` present only when it is
|
|
184
|
+
not `None`. Docstring:
|
|
185
|
+
- `Args: platform:` "Where the value came from, when another platform measured it:
|
|
186
|
+
`"garmin_connect"`, `"strava"`, ... Leave it out for a value you measured or entered yourself.
|
|
187
|
+
It is accepted with a token from `authenticate()` (the value's writer is then `athlete`) or one
|
|
188
|
+
issued to your app (`application`), and refused with a personal API key from SweatStack's
|
|
189
|
+
settings. The server stores the canonical spelling: `"garmin"` reads back as
|
|
190
|
+
`"garmin_connect"`."
|
|
191
|
+
- `Returns:` "The stored value, with its `writer`, `application_id` and `platform`."
|
|
192
|
+
- `Raises: SweatStackBadRequestError:` "If `platform` is not a platform name (a lower-case
|
|
193
|
+
letter, then up to 39 lower-case letters, digits or underscores), or the token may not name
|
|
194
|
+
one."
|
|
195
|
+
- Keep the `Examples:` block as it is: a plain call is the common case.
|
|
196
|
+
5. **`activities.upload(files, *, platform=None, external_id=None)`**, with
|
|
197
|
+
`external_id: str | Sequence[str] | None`. Build `data` from the parameters given:
|
|
198
|
+
`"platform": platform` when it is not `None`, and `"external_id": self._wire(external_id)` when
|
|
199
|
+
it is not `None`. Send `data=` next to `files=` only when it is non-empty. Docstring:
|
|
200
|
+
- `platform`: the `dailies.set` wording, plus "One platform per call: it applies to every file;
|
|
201
|
+
upload files from different platforms in separate calls."
|
|
202
|
+
- `external_id`: "Your app's own ID for each file, one per file in the same order (a single
|
|
203
|
+
string for a single file). Needs a token issued to your app. A file sent again with an ID
|
|
204
|
+
SweatStack has seen from your app returns the existing source if the file is unchanged, so
|
|
205
|
+
retrying a lost upload is safe. If the file changed, SweatStack processes it again under the
|
|
206
|
+
same source ID: the old activities are deleted and new ones, with new IDs, take their place."
|
|
207
|
+
- `Returns:` "One entry per file, each with its `writer`, `platform` and `external_id`."
|
|
208
|
+
- `Raises: SweatStackBadRequestError:` "If `platform` is not a platform name or the token may
|
|
209
|
+
not name one, if `external_id` is sent with a token not issued to an app, or if the number of
|
|
210
|
+
IDs differs from the number of files."
|
|
211
|
+
- A second `Examples:` snippet for the relaying app:
|
|
212
|
+
`client.activities.upload(["a.fit", "b.fit"], platform="garmin_connect", external_id=["1001", "1002"])`.
|
|
213
|
+
6. **`tests/test_wire_names.py`**: delete the `NOT_EXPOSED["Activities.upload"]` entry
|
|
214
|
+
(`{"provider", "replace"}` since `external_id=` landed). Every upload field is now a parameter: `platform` and `external_id`, plus `replace` if
|
|
215
|
+
S11's fallback applies.
|
|
216
|
+
7. **The release in progress is checkable before it has a version** (`tests/test_renames.py`,
|
|
217
|
+
`tests/test_changelog.py`). Both tests look for a `## [0.99.0] - <date>` heading, but the entry
|
|
218
|
+
sits under `## [Unreleased]` until release day. Without this step, `make check` fails on commit 2
|
|
219
|
+
and on every commit until the release.
|
|
220
|
+
`test_changelog`'s parser counts `## [Unreleased]` as the next minor release, so its
|
|
221
|
+
"breaking", its `### Upgrading` and its `upgrading-0-99` markers are held to the same rules
|
|
222
|
+
before release. `test_renames` reads its entries from that parser instead of keeping its own
|
|
223
|
+
regex. (Done.)
|
|
224
|
+
8. **Skills.** `skills/sweatstack-python/data-models.md`:
|
|
225
|
+
- **DailyResponse**: `date`, `value`, `status`, `writer` (`WriterKind`: `athlete`,
|
|
226
|
+
`application`, `sweatstack`; `None` on an estimated or missing day), `application_id`,
|
|
227
|
+
`platform` (a string such as `garmin_connect`, or `None`). Markers and max efforts carry the
|
|
228
|
+
same three fields.
|
|
229
|
+
- **SourceResponse**: replace `origin` with `writer`, `application_id`, `platform`; add
|
|
230
|
+
`external_id`.
|
|
231
|
+
|
|
232
|
+
In `api.md`, the `upload` and `dailies.set` lines show `platform="strava"` and
|
|
233
|
+
`platform="garmin_connect"` in a trailing comment, with a single line saying who may send it.
|
|
234
|
+
A line for the relay: `upload(files, platform=..., external_id=[...])`, an app's token, and
|
|
235
|
+
"retry with the same IDs is safe".
|
|
236
|
+
9. **AGENTS.md**, "Open enums stay open": drop "sources" from the example list, and add "`WriterKind`
|
|
237
|
+
is closed by the server's contract; keep it strict." The next person to regen should not
|
|
238
|
+
"fix" it.
|
|
239
|
+
10. **CHANGELOG** under `## [Unreleased]`: the entry in [CHANGELOG entry](#changelog-entry).
|
|
240
|
+
|
|
241
|
+
Nothing else changes: no client-side derivation of the old `source` (S1), and no validation of the
|
|
242
|
+
name (S4).
|
|
243
|
+
|
|
244
|
+
## Tests
|
|
245
|
+
|
|
246
|
+
Red-green: write these first. All offline (AGENTS.md).
|
|
247
|
+
|
|
248
|
+
**New**
|
|
249
|
+
- `dailies.set(..., platform="garmin_connect")` puts `"platform": "garmin_connect"` in the JSON
|
|
250
|
+
body. Without `platform=`, the body has no `"platform"` key, not one set to `None`.
|
|
251
|
+
- `activities.upload(fit, platform="strava")` sends `data={"platform": "strava"}` next to the files,
|
|
252
|
+
in one request for two files. Without `platform=`, it passes no `data`.
|
|
253
|
+
- `activities.upload([a, b], external_id=["1001", "1002"])` sends
|
|
254
|
+
`data={"external_id": ["1001", "1002"]}` in one request. `upload(a, external_id="1001")` sends
|
|
255
|
+
`["1001"]`. With both parameters, both keys are present. A mismatched count is sent as given:
|
|
256
|
+
the SDK doesn't check it (S10).
|
|
257
|
+
- A relayed `SourceResponse` with `writer: "application"`, `application_id: "app_123"`,
|
|
258
|
+
`platform: "garmin_connect"` and `external_id: "1001"` parses, and `external_id` reads back.
|
|
259
|
+
- Parsing, one row per case in [Summary](#summary): a recorded `DailyResponse` and `SourceResponse`
|
|
260
|
+
for `athlete`/`None`/`None`, `application`/`"app_123"`/`"garmin_connect"` and
|
|
261
|
+
`sweatstack`/`None`/`"garmin_connect"`. An estimated and a missing `DailyResponse` parse with
|
|
262
|
+
`writer`, `application_id` and `platform` all `None`.
|
|
263
|
+
- **`WriterKind` is closed**, parametrised over `DailyResponse`, `SourceResponse`,
|
|
264
|
+
`MarkerRowResponse` and `MaxEffortRowResponse`: `writer: "coach"` raises `ValidationError`. This
|
|
265
|
+
replaces `test_source_is_an_open_set` (`tests/test_dailies.py`) and its rows, which carry over.
|
|
266
|
+
- **`platform` is open**: `platform: "a_platform_added_later"` parses as that string on all four
|
|
267
|
+
models.
|
|
268
|
+
- **Frames**: `dailies.list(output="polars")` and `"pandas"` have the columns `writer`,
|
|
269
|
+
`application_id` and `platform`, and no `source`. Polars and Arrow hold `writer` as the string
|
|
270
|
+
`"athlete"`; pandas holds the `WriterKind` member, as for every enum column, so
|
|
271
|
+
`df["writer"] == "athlete"` is always `False` there. The CHANGELOG and the migration prompt say
|
|
272
|
+
so. `tests/test_frames.py` already turns a `FrameSchemaWarning` into a failure for every public
|
|
273
|
+
model, so the regen's new enum is covered.
|
|
274
|
+
- `tests/test_renames.py` picks up the `DailySource` entry by its parametrisation, against the
|
|
275
|
+
`[Unreleased]` entry (commit 2, step 7).
|
|
276
|
+
|
|
277
|
+
**Updated**, because they construct or read the removed fields:
|
|
278
|
+
- `tests/test_dailies.py`: imports `DailySource`, builds dailies with `source=`, asserts
|
|
279
|
+
`source == DailySource.manual` (lines 12–107), and has the open-set test above.
|
|
280
|
+
- `tests/test_frames.py:190`, `tests/test_output.py:295`: `source=` becomes `writer=`.
|
|
281
|
+
- `tests/test_resources.py:114`: the upload fixture's `"origin": "manual_upload"` becomes
|
|
282
|
+
`"writer": "athlete", "application_id": None, "platform": None`.
|
|
283
|
+
|
|
284
|
+
**Against the document, by hand.** `tests/test_wire_names.py` with `SWEATSTACK_OPENAPI_JSON`
|
|
285
|
+
pointing at the new document: green except `OAuth.authorization_url`'s `login_hint`, which has its
|
|
286
|
+
own change (commit 1, step 2).
|
|
287
|
+
|
|
288
|
+
`tests/test_public_hygiene.py` runs in `make check` and covers this plan and the CHANGELOG entry.
|
|
289
|
+
|
|
290
|
+
## CHANGELOG entry
|
|
291
|
+
|
|
292
|
+
The entry is in `CHANGELOG.md` under `## [Unreleased]`, renamed to `## [0.99.0] - <date>` on
|
|
293
|
+
release day. It is the only copy: a draft here would drift from it. Two things in it differ from
|
|
294
|
+
this plan's first draft, both found against the generated code:
|
|
295
|
+
|
|
296
|
+
- **pandas frames hold `WriterKind` members**, Polars and Arrow hold strings. The table has a row
|
|
297
|
+
for each, and the prompt says which comparison works where.
|
|
298
|
+
- **0.99 against an older server doesn't fail on dailies.** `DailyResponse.writer` is optional, so
|
|
299
|
+
dailies parse with `writer=None`; only the upload fails. The entry says "0.99 needs that API"
|
|
300
|
+
and doesn't claim more.
|
|
301
|
+
|
|
302
|
+
The text names no release date ("the platform changelog has the date"), so there is no
|
|
303
|
+
placeholder to forget. `tests/test_changelog.py` wants every "silent" in the section written as
|
|
304
|
+
`**Silent:**` in bold.
|
|
305
|
+
|
|
306
|
+
## Checked before release
|
|
307
|
+
|
|
308
|
+
On 2026-11-09, against the server's finished code, before production served it:
|
|
309
|
+
|
|
310
|
+
- **The document was rendered in-process** by the server's test image, with throwaway keys (no
|
|
311
|
+
secrets, no telemetry) and a tmpfs `/data`, under a compose project of its own. The same method
|
|
312
|
+
on the server's last release produced production's document exactly (deep equality), so the
|
|
313
|
+
render is a faithful preview.
|
|
314
|
+
- **The regen's diff is the [contract](#the-server-contract-this-mirrors) and nothing else**:
|
|
315
|
+
`WriterKind` with three members; `DailySource` and `SourceOrigin` gone; the three fields on all
|
|
316
|
+
four response models (`writer` optional only on `DailyResponse`); `platform` on the upload,
|
|
317
|
+
`DailyCreate`, `DailyBatchCreate`, `MarkerWrite` and `MaxEffortWrite`; `provider` and `replace`
|
|
318
|
+
gone from the upload.
|
|
319
|
+
- **The wire-name test** against that document reports only `login_hint` (its own change).
|
|
320
|
+
- **`make check`** is green on the branch.
|
|
321
|
+
- **Release, 2026-11-09.** The server's release has both changes asked after the check (column
|
|
322
|
+
order, the `external_id` description), and nothing else: the render of its released code
|
|
323
|
+
regenerates to exactly those two differences (commit 3).
|
|
324
|
+
|
|
325
|
+
One thing was settled at the server before release: **the column order of a daily**. The server
|
|
326
|
+
first added the three fields through a base class, so they came first, and `dailies.list()` frames
|
|
327
|
+
started `writer, application_id, platform, date, value, status`. The SDK doesn't reorder a model's
|
|
328
|
+
columns (AGENTS.md, "Never add a per-model special case"), so the fix went into the server's model
|
|
329
|
+
(the record's own fields first). Its release has it, and commit 3 pins the order in the tests.
|
|
330
|
+
|
|
331
|
+
## Commits
|
|
332
|
+
|
|
333
|
+
| # | Where | What | Done when |
|
|
334
|
+
|---|---|---|---|
|
|
335
|
+
| 1 | `main`, now | Regenerate from today's production, plus the temporary `NOT_EXPOSED` entry for the upload | `make check` green; the wire test reports only `login_hint` |
|
|
336
|
+
| 2 | branch `writer-and-platform` | Everything in [Commit 2](#commit-2-writer-and-platform-branch-writer-and-platform), regenerated from the server's finished code | Done 2026-11-09: `make check` green; the wire test reports only `login_hint` |
|
|
337
|
+
| 3 | the same branch, release day | Regenerate from production (see Release), bump `pyproject.toml` to 0.99.0, rename the heading | The regen shows only the two expected differences (Release, step 2) |
|
|
338
|
+
|
|
339
|
+
## Release
|
|
340
|
+
|
|
341
|
+
The server ships first, and the SDK follows the same day.
|
|
342
|
+
|
|
343
|
+
1. **Wait until the server release is live**: production's `/openapi.json` has `WriterKind` and no
|
|
344
|
+
`DailySource`. Until then, nothing is released from this branch, and nothing that needs it is
|
|
345
|
+
released from `main` (S9).
|
|
346
|
+
2. **Regenerate from production** (commit 3). Two differences from commit 2 are expected, both
|
|
347
|
+
asked of the server after the check: the record's own fields before `writer`, `application_id`
|
|
348
|
+
and `platform` in the four models, and a corrected description of the upload's `external_id`.
|
|
349
|
+
With the first, pin the column order in `tests/test_dailies.py` (`DAILY_COLUMNS` becomes a list
|
|
350
|
+
starting `date, value, status`). Anything else means the server changed after commit 2:
|
|
351
|
+
reconcile it like a regen diff (DEVELOPMENT.md), and don't release until the branch matches what
|
|
352
|
+
production serves.
|
|
353
|
+
3. Rebase onto `main`, rename `## [Unreleased]` to `## [0.99.0] - <release date>`, bump the version,
|
|
354
|
+
then run `make check` and `make publish`.
|
|
355
|
+
4. **Docs**: on the docs site's Upgrading page, add a `## 0.99` section with the
|
|
356
|
+
`upgrading-0-99` include, above `## 0.94`. The generated reference follows the regen.
|
|
357
|
+
5. Check against production with a token from `authenticate()`:
|
|
358
|
+
- `client.dailies.list("body_mass", start=..., end=...)` parses, and `writer` is set on the
|
|
359
|
+
stored days. With `output="polars"`, the columns start `date, value, status`. If they start
|
|
360
|
+
with `writer`, the server's order fix hasn't shipped: release anyway (nothing breaks), and
|
|
361
|
+
pin the order in `tests/test_dailies.py` once it does.
|
|
362
|
+
- `client.dailies.set(..., platform="garmin")` reads back `platform == "garmin_connect"` and
|
|
363
|
+
`writer == WriterKind.athlete`. Then delete that test value.
|
|
364
|
+
|
|
365
|
+
## Not doing
|
|
366
|
+
|
|
367
|
+
- **A derived `source` or `origin`** (S1).
|
|
368
|
+
- **Client-side platform validation, an alias table or a `Platform` enum** (S4). The server owns
|
|
369
|
+
all three, and `platform` is open.
|
|
370
|
+
- **SDK methods for `POST /dailies/{measure}/batch`, `POST /profile/markers` and
|
|
371
|
+
`POST /profile/max-efforts`.** They take `platform` too, but the SDK has no methods for them yet.
|
|
372
|
+
Adding them is an endpoint change with its own plan, named by R1–R8.
|
|
373
|
+
- **Automatic retries of an upload** (S8).
|
|
374
|
+
- **A client-side check that `external_id` and `files` have the same length** (S10). The server's
|
|
375
|
+
`422` names both counts.
|
|
376
|
+
- **`login_hint` on `oauth.authorization_url()`**: found while regenerating; its own change.
|
|
377
|
+
|
|
378
|
+
## Maintainer's notes
|
|
379
|
+
|
|
380
|
+
- **`writer` is a fact about the token, `platform` is a claim.** The SDK sends a platform only when
|
|
381
|
+
the caller passes one. It never sends a default and never infers one from a file.
|
|
382
|
+
- **If the server ever adds a fourth `WriterKind`, that is a breaking server release, and this
|
|
383
|
+
SDK's parse will fail on purpose.** Regenerate, add the member to the Upgrading table, and keep
|
|
384
|
+
the enum closed. Opening it would hide a change in who is accountable for data.
|
|
385
|
+
- **New platforms need no SDK change.** If a regen ever turns `platform` into an enum, the server's
|
|
386
|
+
contract has changed. Open it with `_open_enum` and ask why.
|
|
387
|
+
- **`external_id` is what makes an upload safe to repeat.** If the server's rule for a known ID
|
|
388
|
+
ever changes (for example, back to a flag), the docstring's retry sentence is the first thing to
|
|
389
|
+
check. It is a promise users build retry loops on.
|
|
390
|
+
- **A new write endpoint that takes `platform`** gets the same parameter, docstring wording and
|
|
391
|
+
tests as `dailies.set()`. Copy them; don't re-derive them.
|
|
@@ -21,6 +21,7 @@ client.activities.data("act_123", segmentation_on=None, metrics=None, output=Non
|
|
|
21
21
|
client.activities.mean_max("act_123", metric="power", durations=None, output=None) # best average per duration
|
|
22
22
|
client.activities.awd("act_123", metric=None, output=None) # accumulated work duration
|
|
23
23
|
client.activities.upload(["ride.fit"]) # list[SourceResponse]; a CSV needs a sport column
|
|
24
|
+
client.activities.upload(["1001.fit"], platform="garmin_connect", external_id=["1001"]) # relay: app token; same IDs again are safe
|
|
24
25
|
client.activities.backfill_status() # BackfillStatus
|
|
25
26
|
client.activities.watch_backfill_status(auto_reconnect=False) # generator of BackfillStatus
|
|
26
27
|
client.activities.app_metadata.set("act_123", data={"k": "v"}) # app token only; replaces the dict
|
|
@@ -68,9 +69,14 @@ client.tests.app_metadata.delete("test_123")
|
|
|
68
69
|
```python
|
|
69
70
|
client.dailies.list("hrv", start=date(2026, 4, 1), end=date(2026, 5, 1), interpolate=True, output=None)
|
|
70
71
|
client.dailies.set("body_mass", date=date(2026, 5, 1), value=71.4) # upsert
|
|
72
|
+
client.dailies.set("hrv", date=date(2026, 5, 1), value=62, platform="garmin_connect") # measured elsewhere
|
|
71
73
|
client.dailies.delete("body_mass", date=date(2026, 5, 1))
|
|
72
74
|
```
|
|
73
75
|
|
|
76
|
+
`platform=` on `dailies.set()` and `activities.upload()` names where the data came from. It is
|
|
77
|
+
accepted with a token from `authenticate()` or one issued to your app, and refused
|
|
78
|
+
(`SweatStackBadRequestError`) with a personal API key.
|
|
79
|
+
|
|
74
80
|
## profile: the user the client acts as
|
|
75
81
|
|
|
76
82
|
```python
|
|
@@ -48,14 +48,19 @@ taxonomy is preserved (`is_standard` is `False`) instead of failing.
|
|
|
48
48
|
`end_local`, `results` (`TestResults`: thresholds `lt1`, `lt2`, `vt1`, `vt2`, `mlss`, `fatmax`
|
|
49
49
|
as `Marker`s, plus `vo2max`, `critical_power`, `w_prime`, ...), `tags`, `created_by`;
|
|
50
50
|
`TestDetails` adds the resolved `traces` and overlapping `activities`.
|
|
51
|
-
- **DailyResponse**: `date`, `value`, `status`,
|
|
52
|
-
`
|
|
51
|
+
- **DailyResponse**: `date`, `value`, `status`, and on a stored day who wrote it and where it came
|
|
52
|
+
from: `writer` (`WriterKind`, closed: `athlete`, `application`, `sweatstack`), `application_id`
|
|
53
|
+
(the app, when the writer is one) and `platform` (an open string such as `garmin_connect`, or
|
|
54
|
+
`None`). All three are `None` on an estimated or missing day. `SourceResponse`, markers and max
|
|
55
|
+
efforts carry the same three fields. In a pandas frame `writer` holds `WriterKind` members;
|
|
56
|
+
in Polars and Arrow, strings.
|
|
53
57
|
- **UserSummary** (`users.list()`): `id`, `first_name`, `last_name`, `display_name`, `scopes`,
|
|
54
58
|
`is_managed`.
|
|
55
59
|
- **UserResponse** (`users.create()`, `retrieve()`, `update()`): `id`, `first_name`,
|
|
56
60
|
`last_name`, `display_name`, `admin`, `is_managed`, `registered_at`.
|
|
57
|
-
- **SourceResponse** (`activities.upload()`): `id`, `type`, `
|
|
58
|
-
|
|
61
|
+
- **SourceResponse** (`activities.upload()`): `id`, `type`, `filename`, `status` (`processing`,
|
|
62
|
+
`processed`, `failed`), `error`, `activity_ids`, `created_at`, `writer`, `application_id`,
|
|
63
|
+
`platform`, `external_id` (set on a file an app relayed).
|
|
59
64
|
- **UserInfoResponse** (`oauth.userinfo()`): `sub`, `name`, `given_name`, `family_name`,
|
|
60
65
|
`email`, `registered_at`, `issue`.
|
|
61
66
|
- **AccountStatusResponse** (`profile.status()`, beta): `issue` (`StatusIssueResponse | None`:
|
|
@@ -11,6 +11,8 @@ used. Entries are permanent; removing one turns a helpful error back into a bare
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
REMOVED_IN = {
|
|
14
|
+
# 0.99: who wrote a record and where its data came from are two fields (plan 012).
|
|
15
|
+
"DailySource": ("0.99", "the record's writer field (sweatstack.WriterKind) and platform field"),
|
|
14
16
|
# 0.91: flat methods became resource namespaces (plan 009, M3).
|
|
15
17
|
"get_activities": ("0.91", "{c}.activities.list()"),
|
|
16
18
|
"get_activity": ("0.91", "{c}.activities.retrieve(activity_id)"),
|
|
@@ -59,7 +59,6 @@ from .schemas import (
|
|
|
59
59
|
CapabilityStatus,
|
|
60
60
|
DailyMeasure,
|
|
61
61
|
DailyResponse,
|
|
62
|
-
DailySource,
|
|
63
62
|
IssueStatus,
|
|
64
63
|
Marker,
|
|
65
64
|
Metric,
|
|
@@ -82,6 +81,7 @@ from .schemas import (
|
|
|
82
81
|
UserInfoResponse,
|
|
83
82
|
UserResponse,
|
|
84
83
|
UserSummary,
|
|
84
|
+
WriterKind,
|
|
85
85
|
)
|
|
86
86
|
from .utils import decode_jwt_body, make_dataframe_streamlit_compatible
|
|
87
87
|
|
|
@@ -1223,7 +1223,6 @@ __all__ = sorted(
|
|
|
1223
1223
|
"CapabilityStatus",
|
|
1224
1224
|
"DailyMeasure",
|
|
1225
1225
|
"DailyResponse",
|
|
1226
|
-
"DailySource",
|
|
1227
1226
|
"IssueStatus",
|
|
1228
1227
|
"Marker",
|
|
1229
1228
|
"Metric",
|
|
@@ -1246,6 +1245,7 @@ __all__ = sorted(
|
|
|
1246
1245
|
"UserInfoResponse",
|
|
1247
1246
|
"UserResponse",
|
|
1248
1247
|
"UserSummary",
|
|
1248
|
+
"WriterKind",
|
|
1249
1249
|
*_SINGLETON_METHODS,
|
|
1250
1250
|
*RESOURCES,
|
|
1251
1251
|
]
|