sweatstack 0.94.0__tar.gz → 0.96.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.94.0 → sweatstack-0.96.0}/CHANGELOG.md +96 -0
  2. {sweatstack-0.94.0 → sweatstack-0.96.0}/PKG-INFO +1 -1
  3. sweatstack-0.96.0/plans/011_user_agent_on_every_request.md +228 -0
  4. {sweatstack-0.94.0 → sweatstack-0.96.0}/pyproject.toml +1 -1
  5. {sweatstack-0.94.0 → sweatstack-0.96.0}/skills/sweatstack-python/api.md +2 -2
  6. {sweatstack-0.94.0 → sweatstack-0.96.0}/skills/sweatstack-python/data-models.md +1 -1
  7. {sweatstack-0.94.0 → sweatstack-0.96.0}/skills/sweatstack-python/fastapi.md +1 -1
  8. {sweatstack-0.94.0 → sweatstack-0.96.0}/skills/sweatstack-python/streamlit.md +2 -2
  9. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/__init__.py +1 -0
  10. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/_frames.py +0 -3
  11. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/_transport.py +19 -0
  12. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/cli.py +5 -12
  13. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/client.py +4 -11
  14. sweatstack-0.96.0/src/sweatstack/constants.py +19 -0
  15. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/fastapi/config.py +3 -2
  16. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/fastapi/dependencies.py +2 -1
  17. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/fastapi/routes.py +12 -11
  18. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/openapi_schemas.py +114 -482
  19. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/resources/oauth.py +62 -18
  20. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/resources/profile.py +2 -2
  21. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/resources/teams.py +5 -3
  22. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/streamlit.py +19 -16
  23. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_account_status.py +5 -5
  24. sweatstack-0.96.0/tests/test_authorization_url.py +103 -0
  25. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_frames.py +0 -21
  26. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_resources.py +2 -3
  27. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_transport.py +69 -0
  28. sweatstack-0.96.0/tests/test_user_agent.py +131 -0
  29. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_wire_names.py +1 -0
  30. {sweatstack-0.94.0 → sweatstack-0.96.0}/uv.lock +1 -1
  31. sweatstack-0.94.0/src/sweatstack/constants.py +0 -1
  32. {sweatstack-0.94.0 → sweatstack-0.96.0}/.git-blame-ignore-revs +0 -0
  33. {sweatstack-0.94.0 → sweatstack-0.96.0}/.github/workflows/ci.yml +0 -0
  34. {sweatstack-0.94.0 → sweatstack-0.96.0}/.gitignore +0 -0
  35. {sweatstack-0.94.0 → sweatstack-0.96.0}/.gitleaks.toml +0 -0
  36. {sweatstack-0.94.0 → sweatstack-0.96.0}/.python-version +0 -0
  37. {sweatstack-0.94.0 → sweatstack-0.96.0}/AGENTS.md +0 -0
  38. {sweatstack-0.94.0 → sweatstack-0.96.0}/CONTRIBUTING.md +0 -0
  39. {sweatstack-0.94.0 → sweatstack-0.96.0}/DEVELOPMENT.md +0 -0
  40. {sweatstack-0.94.0 → sweatstack-0.96.0}/LICENSE +0 -0
  41. {sweatstack-0.94.0 → sweatstack-0.96.0}/Makefile +0 -0
  42. {sweatstack-0.94.0 → sweatstack-0.96.0}/README.md +0 -0
  43. {sweatstack-0.94.0 → sweatstack-0.96.0}/UPGRADING.md +0 -0
  44. {sweatstack-0.94.0 → sweatstack-0.96.0}/evals/__init__.py +0 -0
  45. {sweatstack-0.94.0 → sweatstack-0.96.0}/evals/agent/__init__.py +0 -0
  46. {sweatstack-0.94.0 → sweatstack-0.96.0}/evals/agent/agent.py +0 -0
  47. {sweatstack-0.94.0 → sweatstack-0.96.0}/evals/agent/cases.py +0 -0
  48. {sweatstack-0.94.0 → sweatstack-0.96.0}/evals/agent/evaluators.py +0 -0
  49. {sweatstack-0.94.0 → sweatstack-0.96.0}/evals/agent/harness.py +0 -0
  50. {sweatstack-0.94.0 → sweatstack-0.96.0}/evals/agent/lifecycle.py +0 -0
  51. {sweatstack-0.94.0 → sweatstack-0.96.0}/evals/agent/run.py +0 -0
  52. {sweatstack-0.94.0 → sweatstack-0.96.0}/examples/fastapi_webhooks_example.py +0 -0
  53. {sweatstack-0.94.0 → sweatstack-0.96.0}/examples/send_webhook.py +0 -0
  54. {sweatstack-0.94.0 → sweatstack-0.96.0}/plans/001a_tests.md +0 -0
  55. {sweatstack-0.94.0 → sweatstack-0.96.0}/plans/001b_metadata.md +0 -0
  56. {sweatstack-0.94.0 → sweatstack-0.96.0}/plans/001c_dailies.md +0 -0
  57. {sweatstack-0.94.0 → sweatstack-0.96.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
  58. {sweatstack-0.94.0 → sweatstack-0.96.0}/plans/003_trace_test_linking.md +0 -0
  59. {sweatstack-0.94.0 → sweatstack-0.96.0}/plans/004_codebase_hygiene.md +0 -0
  60. {sweatstack-0.94.0 → sweatstack-0.96.0}/plans/005_ost_sport_bridge.md +0 -0
  61. {sweatstack-0.94.0 → sweatstack-0.96.0}/plans/006_output_backends.md +0 -0
  62. {sweatstack-0.94.0 → sweatstack-0.96.0}/plans/007_account_status_userinfo_portal.md +0 -0
  63. {sweatstack-0.94.0 → sweatstack-0.96.0}/plans/008_sdk_documentation.md +0 -0
  64. {sweatstack-0.94.0 → sweatstack-0.96.0}/plans/009_road_to_1_0.md +0 -0
  65. {sweatstack-0.94.0 → sweatstack-0.96.0}/plans/010_credentials_one_source.md +0 -0
  66. {sweatstack-0.94.0 → sweatstack-0.96.0}/skills/sweatstack-python/SKILL.md +0 -0
  67. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/_docs.py +0 -0
  68. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/_renames.py +0 -0
  69. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/exceptions.py +0 -0
  70. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/fastapi/__init__.py +0 -0
  71. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
  72. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/fastapi/models.py +0 -0
  73. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/fastapi/session.py +0 -0
  74. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/fastapi/token_stores.py +0 -0
  75. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/fastapi/webhooks.py +0 -0
  76. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/py.typed +0 -0
  77. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/resources/__init__.py +0 -0
  78. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/resources/_app_metadata.py +0 -0
  79. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/resources/_base.py +0 -0
  80. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/resources/activities.py +0 -0
  81. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/resources/dailies.py +0 -0
  82. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/resources/portal.py +0 -0
  83. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/resources/tests.py +0 -0
  84. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/resources/traces.py +0 -0
  85. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/resources/users.py +0 -0
  86. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/schemas.py +0 -0
  87. {sweatstack-0.94.0 → sweatstack-0.96.0}/src/sweatstack/utils.py +0 -0
  88. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/__init__.py +0 -0
  89. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/conftest.py +0 -0
  90. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_access_token_cache.py +0 -0
  91. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_changelog.py +0 -0
  92. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_credentials.py +0 -0
  93. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_dailies.py +0 -0
  94. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_docs_check.py +0 -0
  95. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_docstrings.py +0 -0
  96. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_dtype_conversion.py +0 -0
  97. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_exceptions.py +0 -0
  98. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_identity.py +0 -0
  99. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_longitudinal_mean_max_after.py +0 -0
  100. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_metadata.py +0 -0
  101. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_output.py +0 -0
  102. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_portal.py +0 -0
  103. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_public_hygiene.py +0 -0
  104. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_public_surface.py +0 -0
  105. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_renames.py +0 -0
  106. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_segmentation.py +0 -0
  107. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_skill.py +0 -0
  108. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_sport_ost.py +0 -0
  109. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_streamlit_auth.py +0 -0
  110. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_teams.py +0 -0
  111. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_tests.py +0 -0
  112. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_trace_test_linking.py +0 -0
  113. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_webhooks.py +0 -0
  114. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/test_write_timezone_validation.py +0 -0
  115. {sweatstack-0.94.0 → sweatstack-0.96.0}/tests/typing/output_types.py +0 -0
@@ -11,6 +11,102 @@ 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.96.0] - 2026-10-16
15
+
16
+ ### Added
17
+
18
+ - `client.oauth.authorization_url()` takes `nonce`, `integration` and `email_auth_mode`, the
19
+ authorization parameters it was missing. `integration="garmin_connect"` sends the user straight
20
+ to Garmin, skipping the sign-in chooser.
21
+
22
+ ### Changed
23
+
24
+ - No sign-in link sends `prompt` any more: `client.oauth.authorization_url()` defaults to
25
+ `prompt=None` instead of `"none"`, and the FastAPI plugin and the Streamlit helper leave it out.
26
+ Users see what they saw before: SweatStack skips the consent screen by itself when the user's
27
+ grant already covers the request.
28
+ - The Streamlit helper separates scopes with spaces, as the other sign-in links do.
29
+ - `StreamlitAuth.get_authorization_url()` raises `ValueError` without a client ID or redirect
30
+ URI, instead of building a link that fails on the server.
31
+
32
+
33
+ ## [0.95.0] - 2026-10-13
34
+
35
+ Scopes are categories now. Request what your app uses. Breaking: the default scopes change
36
+ without an error, and the metabolic map and capability names move; see **Upgrading** below, or
37
+ hand the migration prompt to your coding agent.
38
+
39
+ ### Changed
40
+
41
+ - The default scope of `Client.login()`, `client.oauth.authorization_url()`, the FastAPI plugin and
42
+ the Streamlit helper is one constant, `sweatstack.constants.DEFAULT_SCOPE`:
43
+ `openid profile email activities:read offline_access`. Add `wellness:read`,
44
+ `reproductive_health:read`, `workouts:write` or `sharing:read` when your app uses them.
45
+ - `client.teams.authorize()` grants `activities:read` by default; only the delegable scopes are
46
+ ever granted.
47
+ - `client.oauth.userinfo()` needs the `openid` scope; the name claims need `profile`, the email
48
+ needs `email`.
49
+ - `client.profile.status()` needs no scope.
50
+ - `Application.default_scopes` and `Application.purposes` replace `capabilities` on
51
+ `client.applications` (the old field is still accepted and translated).
52
+ - The metabolic map responses are the markers themselves: no `inputs`, no `markers` wrapper; the
53
+ VO2 map has `vo2max_relative`.
54
+ - The `dailies` capability and its issue codes are `wellness`, `wellness_not_granted` and
55
+ `wellness_no_source`.
56
+
57
+ ### Added
58
+
59
+ - `sweatstack.__version__` is the installed version.
60
+
61
+ ### Fixed
62
+
63
+ - Token calls from the FastAPI and Streamlit integrations (login, refresh and delegated tokens)
64
+ now send the `python-sweatstack/<version>` User-Agent, like every other request the SDK
65
+ makes. Before, they identified themselves as plain httpx.
66
+
67
+ <!-- --8<-- [start:upgrading-0-95] -->
68
+ ### Upgrading
69
+
70
+ | 0.94 | 0.95 |
71
+ |---|---|
72
+ | `scope="data:read data:write profile offline_access"`, or no `scope=` at all | **Silent:** the default is `DEFAULT_SCOPE` (`openid profile email activities:read offline_access`). Add the categories you use: `wellness`, `reproductive_health`, `workouts` and `sharing`, each as `:read` or `:write` |
73
+ | `Scope.data_read` in `teams.authorize(scopes=...)`, or no `scopes=` | **Silent:** `"activities:read"` is the default, and only the delegable categories are granted; anything else is dropped |
74
+ | `userinfo()` with `profile` | `userinfo()` with `openid profile email`; without `openid` it raises `SweatStackAuthError` |
75
+ | `response.markers.fatmax` on a metabolic map | `response.fatmax` |
76
+ | `response.inputs.body_mass` on a metabolic map | not published; `response.vo2max_relative` on the VO2 map |
77
+ | `status.capabilities["dailies"]` | `status.capabilities["wellness"]` |
78
+
79
+ An app registered before the platform release keeps working with `data:read` and `data:write`
80
+ for six months; see the platform changelog.
81
+
82
+ **Migration prompt.** Paste this into your coding agent:
83
+
84
+ ```text
85
+ Upgrade this codebase to sweatstack 0.95, which replaces the data:read and data:write scope
86
+ bundles with per-category scopes. The upgrade table is the "Upgrading" section of the 0.95.0
87
+ entry in https://github.com/SweatStack/sweatstack-python/blob/main/CHANGELOG.md
88
+ (also at https://docs.sweatstack.no/learn/python/upgrading/). Read it first.
89
+
90
+ Find every place the app requests or grants scopes (Client.login(), oauth.authorization_url(),
91
+ sweatstack.fastapi.configure(scopes=...), StreamlitAuth(scopes=...) and SWEATSTACK_SCOPES,
92
+ teams.authorize(scopes=...)) and:
93
+
94
+ 1. Replace data:read and data:write with the categories the app uses: activities, wellness,
95
+ reproductive_health, workouts and sharing, each as :read or :write. Start from
96
+ sweatstack.constants.DEFAULT_SCOPE (openid profile email activities:read offline_access)
97
+ and add to it. Do not keep the old bundles.
98
+ 2. Where the app calls userinfo(), request openid, plus profile and email for the claims it
99
+ shows.
100
+ 3. On the metabolic maps, read response.<marker> instead of response.markers.<marker>, drop
101
+ any use of response.inputs, and read status.capabilities["wellness"] where the app read
102
+ ["dailies"].
103
+
104
+ Then run the code and the tests. A category the app forgot surfaces as SweatStackAuthError
105
+ on the first call that needs it; add that category, not a bundle.
106
+ ```
107
+ <!-- --8<-- [end:upgrading-0-95] -->
108
+
109
+
14
110
  ## [0.94.0] - 2026-10-12
15
111
 
16
112
  A client's tokens come from one source. Upgrade every app that other people sign in to.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sweatstack
3
- Version: 0.94.0
3
+ Version: 0.96.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,228 @@
1
+ # Plan 011: Every request the SDK makes carries its User-Agent
2
+
3
+ Requests made through `Client` identify themselves as `python-sweatstack/<version>`. The four
4
+ token calls in the FastAPI and Streamlit integrations do not: each is a bare `httpx.post`, so it
5
+ goes out as `python-httpx/<version>`, the library default. Seen from the server, an integrator's
6
+ logins, refreshes and delegations look like an unknown httpx script, not like the SDK at a known
7
+ version. That makes three things harder than they should be: telling an integrator which SDK
8
+ version they run, tying a token-endpoint failure to an SDK release, and knowing when an old
9
+ release has stopped being used.
10
+
11
+ The fix is one constant, one function and one guard. **Constant:** the header string has one
12
+ home, next to `__version__`. **Function:** every request that does not go through the client's
13
+ pool goes through `_transport.post`, which adds the header. **Guard:** a test fails on any
14
+ request made with `httpx` directly from anywhere else, so the next bare call is caught at
15
+ `make check`, not in the server's logs.
16
+
17
+ Proposed 2026-10-13. Ships in the next release, under *Fixed*, next to the scope work already under
18
+ `[Unreleased]`; no public surface changes.
19
+
20
+ > **Public repository.** This file is published with the code. It describes the SDK by its own
21
+ > source and the server by its public contract. It cites no app, no version in use, no counts
22
+ > and no time window from server logs; those are customer behaviour and stay out (AGENTS.md,
23
+ > "Public repository").
24
+
25
+
26
+ ## As built (2026-10-13)
27
+
28
+ Sections 2 to 5 shipped as designed, with three refinements:
29
+
30
+ - **`__version__` is public at the package root.** `client.py` no longer defines or imports
31
+ it; `sweatstack.__version__` re-exports it from `constants.py`, which is where Python users
32
+ look for it. Nothing in the repository read it from `client.py`. Listed under *Added*.
33
+ - **The guard also catches `from httpx import post`.** D5 named attribute calls only; the test
34
+ flags a `from httpx import ...` of any request-making name too, so the rule cannot be dodged
35
+ by changing the import style.
36
+ - **The callback test runs the real route.** It mounts the router with `instrument`, sets the
37
+ state cookie that `create_state` mints and follows the handler through to the session
38
+ redirect, so a change to the state check or the exchange would surface here.
39
+
40
+
41
+ ## 1. The problem, verified
42
+
43
+ ### 1.1 What the header is for
44
+
45
+ The server reads `User-Agent` to attribute traffic to an SDK release. The client has sent
46
+ `python-sweatstack/<version>` since the pooled connection landed; every call that goes through
47
+ `Client._request` carries it, including the client's own token exchange and refresh. The value
48
+ comes from `importlib.metadata.version("sweatstack")`, with `"unknown"` as the fallback when
49
+ the package is not installed.
50
+
51
+ ### 1.2 Where it is missing
52
+
53
+ On `main` at 0.94.0, four requests bypass the pool. Each is a bare `httpx.post` with no
54
+ `User-Agent`:
55
+
56
+ | Call site | Endpoint | When it runs |
57
+ |---|---|---|
58
+ | `fastapi/dependencies.py`, `_refresh_access_token` | `POST /api/v1/oauth/token` (`refresh_token`) | Every request whose session token is about to expire |
59
+ | `fastapi/routes.py`, the `/callback` route | `POST /api/v1/oauth/token` (`authorization_code`) | Every login |
60
+ | `fastapi/routes.py`, `_fetch_delegated_token` | `POST /api/v1/oauth/delegated-token` | Switching to a delegated user |
61
+ | `streamlit.py`, `StreamlitAuth._exchange_token` | `POST /api/v1/oauth/token` (`authorization_code`) | Every login |
62
+
63
+ The refresh is by far the most frequent of the four: a long-running app makes one per session
64
+ per token lifetime, which is why token traffic from the integrations is mostly anonymous today.
65
+
66
+ Nothing else is affected. `client.py` builds exactly one `httpx.Client`, the pool, with the
67
+ header set. `cli.py` fetches a local `openapi.json` with `httpx.get`; that is a developer tool
68
+ and stays as it is. No other HTTP library is used anywhere in `src/`.
69
+
70
+ ### 1.3 How it got this way
71
+
72
+ Release 0.65.0 had the same defect inside `Client`: a bare `httpx.post` for the token exchange
73
+ next to a pooled client that did carry the header. The pool refactor fixed it for `Client` by
74
+ routing the exchange through `_http_client`. The integrations were never followed up. Plan 010
75
+ noted the Streamlit exchange as "not folded in" and left it for later; this is later.
76
+
77
+ The header string is also written out twice in `client.py`, once for the pool and once in
78
+ `_http_client`. Two copies today, no shared one, and `__version__` lives in `client.py`, which
79
+ is the wrong place for the integrations to import from.
80
+
81
+
82
+ ## 2. Design
83
+
84
+ Five decisions. Each names the alternative it rejects.
85
+
86
+ **D1. One constant, one home.** `constants.py` gains
87
+
88
+ ```python
89
+ from importlib.metadata import PackageNotFoundError, version
90
+
91
+ try:
92
+ __version__ = version("sweatstack")
93
+ except PackageNotFoundError:
94
+ __version__ = "unknown"
95
+
96
+ USER_AGENT = f"python-sweatstack/{__version__}"
97
+ ```
98
+
99
+ and `client.py` imports both names from there, so `sweatstack.client.__version__` keeps
100
+ working. `constants.py` imports nothing from the package and every call-site module already
101
+ imports it, so there is no cycle to manage. *Rejected:* a new `_version.py` module. One more
102
+ file for one line, and `constants.py` is already the home for values every entry point shares.
103
+
104
+ **D2. One function for requests outside the pool.** `_transport.py` gains
105
+
106
+ ```python
107
+ def post(url: str, **kwargs: Any) -> httpx.Response:
108
+ """``httpx.post`` with the SDK's ``User-Agent``; the caller's headers win on a clash."""
109
+ headers = httpx.Headers({"User-Agent": USER_AGENT})
110
+ headers.update(kwargs.pop("headers", None) or {})
111
+ return httpx.post(url, headers=headers, **kwargs)
112
+ ```
113
+
114
+ It adds the header and nothing else. No retry (`POST` is never retried; see
115
+ `RETRYABLE_METHODS`), no error translation, no timeout. The four sites keep their own error
116
+ handling and timeouts exactly as they are. *Rejected:* passing `headers={"User-Agent": ...}` at
117
+ each site. It works, but then the guard has to prove that every multi-line call passes the
118
+ header, which a test cannot do reliably. With a function, the guard is one rule: nobody but
119
+ the transport talks to `httpx`.
120
+
121
+ **D3. The integrations stay stateless.** They do not acquire a `Client` to make the call. They
122
+ are deliberately thin helpers over the token endpoint, with no token pair of their own to
123
+ manage, and a `Client` would bring the refresh lock and credential resolution of plan 010 into
124
+ code paths that must not have them. *Rejected:* `Client(auth=False)._request(...)`; a bigger
125
+ change than the header needs, and it pulls a user-facing object into a server hot path.
126
+
127
+ **D4. The value stays `python-sweatstack/<version>`.** Identical to what the client sends, so
128
+ the server sees one SDK, not two. *Rejected:* appending `python-httpx/<version>` or a runtime
129
+ description, as some SDKs do. Nothing on the server reads it, and it would make the two paths
130
+ disagree.
131
+
132
+ **D5. The guard is an AST test, not a grep.** It parses every module under `src/sweatstack/`
133
+ and fails on a call to `httpx.get`, `post`, `put`, `patch`, `delete`, `request`, `stream`,
134
+ `Client` or `AsyncClient` outside an allow-list of three: `_transport.py` (the function above),
135
+ `client.py` (the pool) and `cli.py` (the developer tool). *Rejected:* the regex in the first
136
+ draft of this plan. A regex matches text, not calls, and cannot see across the lines of a call
137
+ to check what it passes.
138
+
139
+
140
+ ## 3. Change
141
+
142
+ Seven files, all small. Order matters only for the first two.
143
+
144
+ 1. **`constants.py`**: add `__version__` and `USER_AGENT` as in D1.
145
+ 2. **`client.py`**: drop the local `__version__` block and the `importlib.metadata` import;
146
+ `from .constants import DEFAULT_SCOPE, DEFAULT_URL, USER_AGENT, __version__`. Replace both
147
+ header literals (the pool and `_http_client`) with `USER_AGENT`.
148
+ 3. **`_transport.py`**: add `post` as in D2, importing `USER_AGENT` from `constants`.
149
+ 4. **`fastapi/dependencies.py`**: `httpx.post(...)` in `_refresh_access_token` becomes
150
+ `_transport.post(...)`, same arguments. The `timeout=REFRESH_HTTP_TIMEOUT` stays.
151
+ 5. **`fastapi/routes.py`**: the same substitution in the `/callback` route and in
152
+ `_fetch_delegated_token`. The latter keeps its `Authorization` header; the function merges it
153
+ under the SDK header. Both keep their `httpx.HTTPError` handling, which still applies since
154
+ the function raises what `httpx.post` raises. The `import httpx` stays, for the exception
155
+ type.
156
+ 6. **`streamlit.py`**: the same substitution in `_exchange_token`, keeping `auth=` and `data=`.
157
+ The `import httpx` stays, for `httpx.BasicAuth`.
158
+ 7. **`CHANGELOG.md`**: see section 5.
159
+
160
+ Nothing in the body, auth, timeout or error path of any of the four calls changes. A diff of
161
+ each site is one token: `httpx.post` to `_transport.post`, plus the import.
162
+
163
+
164
+ ## 4. Tests
165
+
166
+ All offline, all under `tests/`. `make check` must pass with no silenced findings.
167
+
168
+ **The function** (`tests/test_transport.py`, where the HTTP layer is tested):
169
+
170
+ - `post` sends `User-Agent: python-sweatstack/<version>` and passes the URL and keyword
171
+ arguments through unchanged.
172
+ - A caller's `Authorization` header survives the merge.
173
+ - A caller's own `User-Agent` wins, so the merge rule in the docstring is true.
174
+
175
+ **The guard** (`tests/test_transport.py`): the AST walk of D5. It fails with the file, line and
176
+ call that broke the rule, so the fix is obvious from the message.
177
+
178
+ **The four call sites** (new `tests/test_user_agent.py`): one test each, patching
179
+ `httpx.post` and asserting the request's `User-Agent` equals `constants.USER_AGENT` and that
180
+ what the site sent before is still sent (grant type, code, credentials, bearer).
181
+
182
+ - `_refresh_access_token` is a function; call it directly.
183
+ - `_fetch_delegated_token` is a function; call it directly and also assert the bearer is
184
+ still on the request.
185
+ - The `/callback` route runs through `fastapi.testclient.TestClient` on an app configured as
186
+ in `tests/test_access_token_cache.py`, with a `state` query parameter that matches the state
187
+ cookie, so the handler reaches the exchange.
188
+ - `StreamlitAuth._exchange_token` runs with the session fixtures from
189
+ `tests/test_streamlit_auth.py`.
190
+
191
+ Patching `httpx.post` rather than injecting `httpx.MockTransport` is deliberate: a module-level
192
+ `httpx.post` builds its own client and accepts no transport, and the function keeps that shape
193
+ on purpose (D2).
194
+
195
+ **Done when:**
196
+
197
+ - The four tests above and the three function tests pass.
198
+ - The guard passes on `main` plus this change, and fails when a bare `httpx.post` is put back
199
+ into any of the four sites.
200
+ - `grep -rn "python-sweatstack/" src/` finds exactly one line, in `constants.py`.
201
+
202
+
203
+ ## 5. Release
204
+
205
+ Part of the next release, with the scope work already under `[Unreleased]`. Add there:
206
+
207
+ > ### Fixed
208
+ > - Token calls from the FastAPI and Streamlit integrations (login, refresh and delegated
209
+ > tokens) now send the `python-sweatstack/<version>` User-Agent, like every other request
210
+ > the SDK makes. Before, they identified themselves as plain httpx.
211
+
212
+ Not breaking: no name, parameter or behaviour visible to a caller changes, so there is no
213
+ *Upgrading* section and nothing for `_renames`. The skill in `skills/sweatstack-python/` and the
214
+ docs describe the public surface, which is unchanged, so neither moves.
215
+
216
+
217
+ ## 6. Seen while looking, left alone
218
+
219
+ Three things this plan does not change. Each would be its own decision.
220
+
221
+ - **`cli.py`** keeps its bare `httpx.get`. It talks to a developer's local server and is
222
+ allow-listed by the guard with a comment saying why.
223
+ - **Timeouts differ across the four sites.** The refresh passes `REFRESH_HTTP_TIMEOUT`; the two
224
+ logins and the delegation use httpx's default. Whether the three should share a constant is a
225
+ reliability question, not a header question.
226
+ - **Error handling differs across the four sites.** One calls `raise_for_status`, one
227
+ translates to the SDK's exceptions, one redirects with an error code, one raises
228
+ `SweatStackAuthError`. Each is right for its caller; unifying them is not this plan's job.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sweatstack"
3
- version = "0.94.0"
3
+ version = "0.96.0"
4
4
  description = "The official Python client for SweatStack"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -105,9 +105,9 @@ client.whoami() # the user this cl
105
105
  ```python
106
106
  client.portal.sessions.create("manage-integrations", return_url=None) # user token; mint on click, never store
107
107
 
108
- client.oauth.userinfo() # OpenID claims + issue (needs the profile scope)
108
+ client.oauth.userinfo() # OpenID claims + issue (needs openid; profile and email add the name and email claims)
109
109
  verifier, challenge = client.oauth.generate_pkce_params()
110
- client.oauth.authorization_url(client_id="YOUR_CLIENT_ID", redirect_uri="http://localhost:8000/callback", code_challenge=challenge)
110
+ client.oauth.authorization_url(client_id="YOUR_CLIENT_ID", redirect_uri="http://localhost:8000/callback", code_challenge=challenge) # + scope, state, nonce, integration="garmin_connect", email_auth_mode; leave prompt out
111
111
  client.oauth.exchange_code("code", client_id="YOUR_CLIENT_ID", code_verifier=verifier) # persist=True saves on this machine; never in a server
112
112
  ```
113
113
 
@@ -29,7 +29,7 @@ taxonomy is preserved (`is_standard` is `False`) instead of failing.
29
29
  - `Metric`: data streams such as `power`, `speed`, `heart_rate`, `cadence`, `altitude`,
30
30
  `distance`, `temperature`, `core_temperature`, `smo2`, `lactate`, `rpe`. `metric.display_name()`
31
31
  gives a readable form. Open set: unknown values from a newer server still parse.
32
- - `Scope`: `Scope.data_read` (`"data:read"`), `data_write`, `profile`, `openid`, `offline_access`, `admin`. Open set.
32
+ - `Scope`: `Scope.activities_read` (`"activities:read"`), `activities_write`, `wellness_read`, `wellness_write`, `reproductive_health_read`, `reproductive_health_write`, `workouts_read`, `workouts_write`, `sharing_read`, `sharing_write`, `openid`, `profile`, `email`, `offline_access`, `admin`; `data_read` and `data_write` are the retired bundles. Open set.
33
33
  - `DailyMeasure`: `body_mass`, `body_fat_pct`, `resting_hr`, `hrv`, `sleep_duration`,
34
34
  `sleep_altitude`, `menstrual_cycle_day`. Open set.
35
35
 
@@ -50,7 +50,7 @@ configure(
50
50
  client_secret: str = None, # SWEATSTACK_CLIENT_SECRET
51
51
  app_url: str = None, # APP_URL
52
52
  session_secret: str = None, # SWEATSTACK_SESSION_SECRET (Fernet key)
53
- scopes: list[str] = ["profile", "data:read"],
53
+ scopes: list[str] = DEFAULT_SCOPE.split(), # openid profile email activities:read offline_access
54
54
  cookie_secure: bool = None, # auto-detected from app_url scheme
55
55
  cookie_max_age: int = 86400, # session lifetime in seconds
56
56
  auth_route_prefix: str = "/auth/sweatstack",
@@ -49,7 +49,7 @@ st.dataframe(auth.client.activities.list(output="pandas")) # st.dataframe also
49
49
  | `SWEATSTACK_CLIENT_ID` | OAuth client ID |
50
50
  | `SWEATSTACK_CLIENT_SECRET` | OAuth client secret |
51
51
  | `SWEATSTACK_REDIRECT_URI` | OAuth callback URI |
52
- | `SWEATSTACK_SCOPES` | Comma-separated scopes (default: `data:read,profile`) |
52
+ | `SWEATSTACK_SCOPES` | Comma-separated scopes (default: `sweatstack.constants.DEFAULT_SCOPE`, i.e. `openid,profile,email,activities:read,offline_access`) |
53
53
 
54
54
  ## Selector Components
55
55
 
@@ -115,7 +115,7 @@ activities = auth.client.activities.list()
115
115
  - **Never cache `auth` or `auth.client` across sessions** (`st.cache_resource`, module globals): they hold one user's tokens. Build them at the top of every run.
116
116
  - **Session state keys:** `sweatstack_api_key`, `sweatstack_refresh_token`, and in proxy mode `sweatstack_proxy_api_key`, `sweatstack_proxy_refresh_token`. Don't overwrite these; `auth.client` keeps a renewed token there.
117
117
  - **`select_user()` replaces `auth.client`** with a client delegated to the selected user (its own access and refresh token). Call `auth.switch_to_principal_user()` to go back.
118
- - **Scopes default to `data:read,profile`** in Streamlit — not the broader set used by `Client.authenticate()`. Add `offline_access` if you need refresh tokens in direct OAuth mode.
118
+ - **Scopes default to `DEFAULT_SCOPE`** (`openid profile email activities:read offline_access`), the same set as `Client.authenticate()` and the FastAPI plugin. Add `wellness:read`, `reproductive_health:read`, `workouts:write` or `sharing:read` when the app uses them.
119
119
 
120
120
  ## Account Status and the Portal
121
121
 
@@ -1,6 +1,7 @@
1
1
  from . import _renames
2
2
  from .client import * # noqa: F403
3
3
  from .client import __all__ as _client_all
4
+ from .constants import __version__ as __version__
4
5
  from .exceptions import (
5
6
  SweatStackAPIError,
6
7
  SweatStackAuthError,
@@ -363,7 +363,6 @@ def field_types(model: type[BaseModel]) -> dict[str, FieldType]:
363
363
  }
364
364
 
365
365
 
366
- _DATETIME_FORMATS = frozenset({"date-time", "naive-date-time"})
367
366
  _STRING_FORMATS = {
368
367
  "date-time": "datetime",
369
368
  "naive-date-time": "naive_datetime",
@@ -392,8 +391,6 @@ def _field_type(node: dict, definitions: dict, path: str, stack: tuple[str, ...]
392
391
  return _field_type(options[0], definitions, path, stack)
393
392
  if options and all(option.get("type") in ("integer", "number") for option in options):
394
393
  return "float"
395
- if options and all(option.get("format") in _DATETIME_FORMATS for option in options):
396
- return "datetime" # aware | naive: naive values are taken as UTC
397
394
  return _fallback(path, node)
398
395
 
399
396
  if "enum" in node or "const" in node:
@@ -3,6 +3,11 @@
3
3
  The policy follows the OpenAI, Anthropic and Stripe clients, with one difference: ``POST`` is
4
4
  never retried, because the SweatStack API has no idempotency keys and a retried create could
5
5
  create twice.
6
+
7
+ This module is also the only place in the package that calls ``httpx`` to make a request:
8
+ through a client's pool, or through :func:`post` for the token calls the FastAPI and Streamlit
9
+ integrations make without a client. Both put the SDK's ``User-Agent`` on the wire, and
10
+ ``tests/test_transport.py`` fails on a request made from anywhere else.
6
11
  """
7
12
 
8
13
  from __future__ import annotations
@@ -14,6 +19,8 @@ from typing import Any
14
19
 
15
20
  import httpx
16
21
 
22
+ from .constants import USER_AGENT
23
+
17
24
  RETRY_STATUSES = frozenset({408, 429, 500, 502, 503, 504})
18
25
  """Statuses worth retrying: timeouts, rate limits and transient server errors."""
19
26
 
@@ -73,6 +80,18 @@ def wait_before_retry(
73
80
  return wait
74
81
 
75
82
 
83
+ def post(url: str, **kwargs: Any) -> httpx.Response:
84
+ """``httpx.post`` with the SDK's ``User-Agent``; the caller's headers win on a clash.
85
+
86
+ For the one-off token calls the integrations make without a ``Client``. It adds the header
87
+ and nothing else: no retry (a ``POST`` is never retried), no error translation and no
88
+ timeout, so each caller keeps its own.
89
+ """
90
+ headers = httpx.Headers({"User-Agent": USER_AGENT})
91
+ headers.update(kwargs.pop("headers", None) or {})
92
+ return httpx.post(url, headers=headers, **kwargs)
93
+
94
+
76
95
  class Session:
77
96
  """One request scope on the client's shared connection pool, with its own headers.
78
97
 
@@ -58,18 +58,14 @@ def _bind_sport_to_ost(path: Path) -> None:
58
58
  path.write_text("".join(out))
59
59
 
60
60
 
61
- # Fields the server sends without a UTC offset, though the OpenAPI schema says date-time.
62
- _NAIVE_OR_AWARE = {"registered_at", "backfill_loaded_until"}
63
-
64
-
65
61
  def _restore_naive_local_datetimes(path: Path) -> None:
66
62
  """Type local timestamps as ``NaiveDatetime`` rather than ``AwareDatetime``.
67
63
 
68
64
  The API returns *local* timestamps without a timezone, but datamodel-codegen types every
69
65
  ``date-time`` field as ``AwareDatetime`` -- which rejects a naive value. Retype the local fields
70
- (those whose name ends in ``_local``) back to ``NaiveDatetime``, and let the fields the server
71
- sends without an offset (``registered_at``, ``backfill_loaded_until``) accept either. AST-anchored and idempotent, so it survives regeneration (and replaces the
72
- manual fixups this file has needed in the past).
66
+ (those whose name ends in ``_local``) back to ``NaiveDatetime``; every other timestamp the server
67
+ returns is UTC with ``Z``. AST-anchored and idempotent, so it survives regeneration (and replaces
68
+ the manual fixups this file has needed in the past).
73
69
  """
74
70
  src = path.read_text()
75
71
  tree = ast.parse(src)
@@ -83,12 +79,9 @@ def _restore_naive_local_datetimes(path: Path) -> None:
83
79
  annotation = ast.get_source_segment(src, node.annotation)
84
80
  if annotation is None:
85
81
  continue
86
- if name.endswith("_local") and "AwareDatetime" in annotation:
87
- retyped = annotation.replace("AwareDatetime", "NaiveDatetime")
88
- elif name in _NAIVE_OR_AWARE and "NaiveDatetime" not in annotation:
89
- retyped = annotation.replace("AwareDatetime", "AwareDatetime | NaiveDatetime", 1)
90
- else:
82
+ if not (name.endswith("_local") and "AwareDatetime" in annotation):
91
83
  continue
84
+ retyped = annotation.replace("AwareDatetime", "NaiveDatetime")
92
85
  i = node.lineno - 1
93
86
  replace[i] = lines[i].replace(annotation, retyped, 1)
94
87
 
@@ -15,7 +15,6 @@ from datetime import date, datetime
15
15
  from enum import Enum
16
16
  from functools import cached_property, wraps
17
17
  from http.server import BaseHTTPRequestHandler, HTTPServer
18
- from importlib.metadata import version
19
18
  from inspect import getmembers, isfunction
20
19
  from pathlib import Path
21
20
  from typing import Any, Literal
@@ -27,7 +26,7 @@ from pydantic import SecretStr
27
26
 
28
27
  from . import _frames, _renames, _transport
29
28
  from ._frames import FrameOutput, set_output
30
- from .constants import DEFAULT_URL
29
+ from .constants import DEFAULT_SCOPE, DEFAULT_URL, USER_AGENT
31
30
  from .exceptions import (
32
31
  SweatStackAPIError,
33
32
  SweatStackAuthError,
@@ -121,11 +120,6 @@ AUTH_SUCCESSFUL_RESPONSE = """<!DOCTYPE html>
121
120
  </html>"""
122
121
  OAUTH2_CLIENT_ID = "5382f68b0d254378"
123
122
 
124
- try:
125
- __version__ = version("sweatstack")
126
- except ImportError:
127
- __version__ = "unknown"
128
-
129
123
 
130
124
  def _to_secret(value: str | SecretStr | None) -> SecretStr | None:
131
125
  """Convert a string to SecretStr, or return None if value is None."""
@@ -647,8 +641,7 @@ class Client:
647
641
  client_id=OAUTH2_CLIENT_ID,
648
642
  redirect_uri=f"http://localhost:{port}",
649
643
  code_challenge=code_challenge,
650
- scope="data:read data:write profile offline_access",
651
- prompt=None,
644
+ scope=DEFAULT_SCOPE,
652
645
  )
653
646
 
654
647
  webbrowser.open(authorization_url)
@@ -894,7 +887,7 @@ class Client:
894
887
  return built[1]
895
888
  pool = httpx.Client(
896
889
  base_url=url,
897
- headers={"User-Agent": f"python-sweatstack/{__version__}"},
890
+ headers={"User-Agent": USER_AGENT},
898
891
  timeout=self.timeout,
899
892
  )
900
893
  self.__dict__["_pool_entry"] = (url, pool)
@@ -918,7 +911,7 @@ class Client:
918
911
  For the token exchange, which authenticates with the app's own credentials in
919
912
  the body, so a user's bearer is never sent where it is not needed.
920
913
  """
921
- headers = httpx.Headers({"User-Agent": f"python-sweatstack/{__version__}"})
914
+ headers = httpx.Headers({"User-Agent": USER_AGENT})
922
915
  if not auth:
923
916
  token = None
924
917
  elif skip_token_check:
@@ -0,0 +1,19 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ DEFAULT_URL = "https://app.sweatstack.no"
4
+
5
+ try:
6
+ __version__ = version("sweatstack")
7
+ except PackageNotFoundError:
8
+ __version__ = "unknown"
9
+
10
+ # What every request the SDK makes says it is, from the connection pool and from the token
11
+ # calls the integrations make outside it (``_transport.post``). One definition, so the server
12
+ # sees one SDK at one version wherever a request comes from.
13
+ USER_AGENT = f"python-sweatstack/{__version__}"
14
+
15
+ # The scopes the quickstart asks for: sign in, see the name and email, read activities, keep
16
+ # access. One constant for every entry point that carries a default, so they cannot drift.
17
+ # Wellness, reproductive health, writes and sharing are choices to type. Declared ``str`` so
18
+ # that ``DEFAULT_SCOPE.split()`` is a ``list[str]`` and not a ``list[LiteralString]``.
19
+ DEFAULT_SCOPE: str = "openid profile email activities:read offline_access"
@@ -11,6 +11,7 @@ from urllib.parse import quote
11
11
  from cryptography.fernet import Fernet
12
12
  from pydantic import SecretStr
13
13
 
14
+ from ..constants import DEFAULT_SCOPE
14
15
  from .access_token_cache import AccessTokenCache, InMemoryAccessTokenCache
15
16
 
16
17
  if TYPE_CHECKING:
@@ -89,7 +90,7 @@ def configure(
89
90
  session_secret: Fernet key(s) for cookie encryption. Can be a single key
90
91
  or a list of keys for key rotation (first encrypts, all decrypt).
91
92
  Falls back to SWEATSTACK_SESSION_SECRET env var.
92
- scopes: OAuth scopes to request. Defaults to ["profile", "data:read"].
93
+ scopes: OAuth scopes to request. Defaults to ``DEFAULT_SCOPE`` split on spaces.
93
94
  cookie_secure: Whether to set the Secure flag on cookies. If not specified,
94
95
  auto-detected from app_url (True for https, False for http).
95
96
  cookie_max_age: Session cookie lifetime in seconds. Defaults to 86400 (24h).
@@ -151,7 +152,7 @@ def configure(
151
152
  session_secret_obj = [_to_secret(s) for s in session_secret]
152
153
 
153
154
  if scopes is None:
154
- scopes = ["profile", "data:read"]
155
+ scopes = DEFAULT_SCOPE.split()
155
156
 
156
157
  # Normalize prefix (strip trailing slash)
157
158
  auth_route_prefix = auth_route_prefix.rstrip("/")
@@ -13,6 +13,7 @@ from urllib.parse import quote
13
13
  import httpx
14
14
  from fastapi import Depends, HTTPException, Request, Response
15
15
 
16
+ from .. import _transport
16
17
  from ..client import Client
17
18
  from ..constants import DEFAULT_URL
18
19
  from ..utils import decode_jwt_body
@@ -122,7 +123,7 @@ def _refresh_access_token(
122
123
  on ``/oauth/token``; the tuple shape exists so that a future
123
124
  server change is handled transparently.
124
125
  """
125
- response = httpx.post(
126
+ response = _transport.post(
126
127
  f"{DEFAULT_URL}/api/v1/oauth/token",
127
128
  data={
128
129
  "grant_type": "refresh_token",