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.
Files changed (115) hide show
  1. {sweatstack-0.98.0 → sweatstack-0.99.0}/AGENTS.md +7 -2
  2. {sweatstack-0.98.0 → sweatstack-0.99.0}/CHANGELOG.md +89 -0
  3. {sweatstack-0.98.0 → sweatstack-0.99.0}/PKG-INFO +1 -1
  4. sweatstack-0.99.0/plans/012_writer_and_platform.md +391 -0
  5. {sweatstack-0.98.0 → sweatstack-0.99.0}/pyproject.toml +1 -1
  6. {sweatstack-0.98.0 → sweatstack-0.99.0}/skills/sweatstack-python/api.md +6 -0
  7. {sweatstack-0.98.0 → sweatstack-0.99.0}/skills/sweatstack-python/data-models.md +9 -4
  8. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/_renames.py +2 -0
  9. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/client.py +2 -2
  10. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/openapi_schemas.py +231 -40
  11. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/activities.py +40 -4
  12. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/dailies.py +24 -9
  13. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/schemas.py +6 -7
  14. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_changelog.py +33 -6
  15. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_dailies.py +92 -35
  16. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_frames.py +11 -3
  17. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_output.py +4 -8
  18. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_renames.py +5 -7
  19. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_resources.py +82 -12
  20. {sweatstack-0.98.0 → sweatstack-0.99.0}/uv.lock +1 -1
  21. {sweatstack-0.98.0 → sweatstack-0.99.0}/.git-blame-ignore-revs +0 -0
  22. {sweatstack-0.98.0 → sweatstack-0.99.0}/.github/workflows/ci.yml +0 -0
  23. {sweatstack-0.98.0 → sweatstack-0.99.0}/.gitignore +0 -0
  24. {sweatstack-0.98.0 → sweatstack-0.99.0}/.gitleaks.toml +0 -0
  25. {sweatstack-0.98.0 → sweatstack-0.99.0}/.python-version +0 -0
  26. {sweatstack-0.98.0 → sweatstack-0.99.0}/CONTRIBUTING.md +0 -0
  27. {sweatstack-0.98.0 → sweatstack-0.99.0}/DEVELOPMENT.md +0 -0
  28. {sweatstack-0.98.0 → sweatstack-0.99.0}/LICENSE +0 -0
  29. {sweatstack-0.98.0 → sweatstack-0.99.0}/Makefile +0 -0
  30. {sweatstack-0.98.0 → sweatstack-0.99.0}/README.md +0 -0
  31. {sweatstack-0.98.0 → sweatstack-0.99.0}/UPGRADING.md +0 -0
  32. {sweatstack-0.98.0 → sweatstack-0.99.0}/evals/__init__.py +0 -0
  33. {sweatstack-0.98.0 → sweatstack-0.99.0}/evals/agent/__init__.py +0 -0
  34. {sweatstack-0.98.0 → sweatstack-0.99.0}/evals/agent/agent.py +0 -0
  35. {sweatstack-0.98.0 → sweatstack-0.99.0}/evals/agent/cases.py +0 -0
  36. {sweatstack-0.98.0 → sweatstack-0.99.0}/evals/agent/evaluators.py +0 -0
  37. {sweatstack-0.98.0 → sweatstack-0.99.0}/evals/agent/harness.py +0 -0
  38. {sweatstack-0.98.0 → sweatstack-0.99.0}/evals/agent/lifecycle.py +0 -0
  39. {sweatstack-0.98.0 → sweatstack-0.99.0}/evals/agent/run.py +0 -0
  40. {sweatstack-0.98.0 → sweatstack-0.99.0}/examples/fastapi_webhooks_example.py +0 -0
  41. {sweatstack-0.98.0 → sweatstack-0.99.0}/examples/send_webhook.py +0 -0
  42. {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/001a_tests.md +0 -0
  43. {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/001b_metadata.md +0 -0
  44. {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/001c_dailies.md +0 -0
  45. {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
  46. {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/003_trace_test_linking.md +0 -0
  47. {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/004_codebase_hygiene.md +0 -0
  48. {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/005_ost_sport_bridge.md +0 -0
  49. {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/006_output_backends.md +0 -0
  50. {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/007_account_status_userinfo_portal.md +0 -0
  51. {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/008_sdk_documentation.md +0 -0
  52. {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/009_road_to_1_0.md +0 -0
  53. {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/010_credentials_one_source.md +0 -0
  54. {sweatstack-0.98.0 → sweatstack-0.99.0}/plans/011_user_agent_on_every_request.md +0 -0
  55. {sweatstack-0.98.0 → sweatstack-0.99.0}/skills/sweatstack-python/SKILL.md +0 -0
  56. {sweatstack-0.98.0 → sweatstack-0.99.0}/skills/sweatstack-python/fastapi.md +0 -0
  57. {sweatstack-0.98.0 → sweatstack-0.99.0}/skills/sweatstack-python/streamlit.md +0 -0
  58. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/__init__.py +0 -0
  59. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/_docs.py +0 -0
  60. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/_frames.py +0 -0
  61. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/_transport.py +0 -0
  62. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/cli.py +0 -0
  63. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/constants.py +0 -0
  64. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/exceptions.py +0 -0
  65. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/fastapi/__init__.py +0 -0
  66. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
  67. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/fastapi/config.py +0 -0
  68. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/fastapi/dependencies.py +0 -0
  69. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/fastapi/models.py +0 -0
  70. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/fastapi/routes.py +0 -0
  71. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/fastapi/session.py +0 -0
  72. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/fastapi/token_stores.py +0 -0
  73. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/fastapi/webhooks.py +0 -0
  74. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/py.typed +0 -0
  75. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/__init__.py +0 -0
  76. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/_app_metadata.py +0 -0
  77. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/_base.py +0 -0
  78. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/oauth.py +0 -0
  79. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/portal.py +0 -0
  80. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/profile.py +0 -0
  81. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/teams.py +0 -0
  82. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/tests.py +0 -0
  83. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/traces.py +0 -0
  84. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/resources/users.py +0 -0
  85. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/streamlit.py +0 -0
  86. {sweatstack-0.98.0 → sweatstack-0.99.0}/src/sweatstack/utils.py +0 -0
  87. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/__init__.py +0 -0
  88. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/conftest.py +0 -0
  89. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_access_token_cache.py +0 -0
  90. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_account_status.py +0 -0
  91. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_authorization_url.py +0 -0
  92. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_credentials.py +0 -0
  93. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_docs_check.py +0 -0
  94. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_docstrings.py +0 -0
  95. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_dtype_conversion.py +0 -0
  96. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_exceptions.py +0 -0
  97. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_identity.py +0 -0
  98. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_longitudinal_mean_max_after.py +0 -0
  99. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_metadata.py +0 -0
  100. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_portal.py +0 -0
  101. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_public_hygiene.py +0 -0
  102. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_public_surface.py +0 -0
  103. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_segmentation.py +0 -0
  104. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_skill.py +0 -0
  105. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_sport_ost.py +0 -0
  106. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_streamlit_auth.py +0 -0
  107. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_teams.py +0 -0
  108. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_tests.py +0 -0
  109. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_trace_test_linking.py +0 -0
  110. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_transport.py +0 -0
  111. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_user_agent.py +0 -0
  112. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_webhooks.py +0 -0
  113. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_wire_names.py +0 -0
  114. {sweatstack-0.98.0 → sweatstack-0.99.0}/tests/test_write_timezone_validation.py +0 -0
  115. {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, sources, status codes, capabilities),
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sweatstack
3
- Version: 0.98.0
3
+ Version: 0.99.0
4
4
  Summary: The official Python client for SweatStack
5
5
  Project-URL: Homepage, https://sweatstack.no
6
6
  Project-URL: Documentation, https://docs.sweatstack.no/learn/python/
@@ -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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sweatstack"
3
- version = "0.98.0"
3
+ version = "0.99.0"
4
4
  description = "The official Python client for SweatStack"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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`, `source` (`DailySource`, an open set: `manual`,
52
- `garmin_connect`, ...; markers and max efforts carry it too).
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`, `origin`, `filename`, `status`
58
- (`processing`, `processed`, `failed`), `error`, `activity_ids`, `created_at`.
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
  ]