sweatstack 0.91.0__tar.gz → 0.92.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 (105) hide show
  1. {sweatstack-0.91.0 → sweatstack-0.92.0}/CHANGELOG.md +26 -0
  2. {sweatstack-0.91.0 → sweatstack-0.92.0}/PKG-INFO +1 -1
  3. {sweatstack-0.91.0 → sweatstack-0.92.0}/pyproject.toml +1 -1
  4. {sweatstack-0.91.0 → sweatstack-0.92.0}/skills/sweatstack-python/api.md +1 -1
  5. {sweatstack-0.91.0 → sweatstack-0.92.0}/skills/sweatstack-python/data-models.md +4 -3
  6. {sweatstack-0.91.0 → sweatstack-0.92.0}/skills/sweatstack-python/streamlit.md +8 -4
  7. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/client.py +2 -3
  8. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/openapi_schemas.py +9 -5
  9. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/oauth.py +4 -3
  10. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/portal.py +24 -27
  11. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/profile.py +5 -4
  12. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/schemas.py +7 -7
  13. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_account_status.py +20 -10
  14. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_identity.py +4 -3
  15. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_portal.py +20 -29
  16. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_transport.py +11 -2
  17. {sweatstack-0.91.0 → sweatstack-0.92.0}/uv.lock +1 -1
  18. {sweatstack-0.91.0 → sweatstack-0.92.0}/.git-blame-ignore-revs +0 -0
  19. {sweatstack-0.91.0 → sweatstack-0.92.0}/.github/workflows/ci.yml +0 -0
  20. {sweatstack-0.91.0 → sweatstack-0.92.0}/.gitignore +0 -0
  21. {sweatstack-0.91.0 → sweatstack-0.92.0}/.gitleaks.toml +0 -0
  22. {sweatstack-0.91.0 → sweatstack-0.92.0}/.python-version +0 -0
  23. {sweatstack-0.91.0 → sweatstack-0.92.0}/AGENTS.md +0 -0
  24. {sweatstack-0.91.0 → sweatstack-0.92.0}/CONTRIBUTING.md +0 -0
  25. {sweatstack-0.91.0 → sweatstack-0.92.0}/DEVELOPMENT.md +0 -0
  26. {sweatstack-0.91.0 → sweatstack-0.92.0}/LICENSE +0 -0
  27. {sweatstack-0.91.0 → sweatstack-0.92.0}/Makefile +0 -0
  28. {sweatstack-0.91.0 → sweatstack-0.92.0}/README.md +0 -0
  29. {sweatstack-0.91.0 → sweatstack-0.92.0}/evals/__init__.py +0 -0
  30. {sweatstack-0.91.0 → sweatstack-0.92.0}/evals/agent/__init__.py +0 -0
  31. {sweatstack-0.91.0 → sweatstack-0.92.0}/evals/agent/agent.py +0 -0
  32. {sweatstack-0.91.0 → sweatstack-0.92.0}/evals/agent/cases.py +0 -0
  33. {sweatstack-0.91.0 → sweatstack-0.92.0}/evals/agent/evaluators.py +0 -0
  34. {sweatstack-0.91.0 → sweatstack-0.92.0}/evals/agent/harness.py +0 -0
  35. {sweatstack-0.91.0 → sweatstack-0.92.0}/evals/agent/lifecycle.py +0 -0
  36. {sweatstack-0.91.0 → sweatstack-0.92.0}/evals/agent/run.py +0 -0
  37. {sweatstack-0.91.0 → sweatstack-0.92.0}/examples/fastapi_webhooks_example.py +0 -0
  38. {sweatstack-0.91.0 → sweatstack-0.92.0}/examples/send_webhook.py +0 -0
  39. {sweatstack-0.91.0 → sweatstack-0.92.0}/plans/001a_tests.md +0 -0
  40. {sweatstack-0.91.0 → sweatstack-0.92.0}/plans/001b_metadata.md +0 -0
  41. {sweatstack-0.91.0 → sweatstack-0.92.0}/plans/001c_dailies.md +0 -0
  42. {sweatstack-0.91.0 → sweatstack-0.92.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
  43. {sweatstack-0.91.0 → sweatstack-0.92.0}/plans/003_trace_test_linking.md +0 -0
  44. {sweatstack-0.91.0 → sweatstack-0.92.0}/plans/004_codebase_hygiene.md +0 -0
  45. {sweatstack-0.91.0 → sweatstack-0.92.0}/plans/005_ost_sport_bridge.md +0 -0
  46. {sweatstack-0.91.0 → sweatstack-0.92.0}/plans/006_output_backends.md +0 -0
  47. {sweatstack-0.91.0 → sweatstack-0.92.0}/plans/007_account_status_userinfo_portal.md +0 -0
  48. {sweatstack-0.91.0 → sweatstack-0.92.0}/plans/008_sdk_documentation.md +0 -0
  49. {sweatstack-0.91.0 → sweatstack-0.92.0}/plans/009_road_to_1_0.md +0 -0
  50. {sweatstack-0.91.0 → sweatstack-0.92.0}/skills/sweatstack-python/SKILL.md +0 -0
  51. {sweatstack-0.91.0 → sweatstack-0.92.0}/skills/sweatstack-python/fastapi.md +0 -0
  52. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/__init__.py +0 -0
  53. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/_docs.py +0 -0
  54. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/_frames.py +0 -0
  55. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/_renames.py +0 -0
  56. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/_transport.py +0 -0
  57. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/cli.py +0 -0
  58. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/constants.py +0 -0
  59. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/exceptions.py +0 -0
  60. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/fastapi/__init__.py +0 -0
  61. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
  62. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/fastapi/config.py +0 -0
  63. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/fastapi/dependencies.py +0 -0
  64. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/fastapi/models.py +0 -0
  65. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/fastapi/routes.py +0 -0
  66. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/fastapi/session.py +0 -0
  67. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/fastapi/token_stores.py +0 -0
  68. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/fastapi/webhooks.py +0 -0
  69. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/py.typed +0 -0
  70. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/__init__.py +0 -0
  71. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/_app_metadata.py +0 -0
  72. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/_base.py +0 -0
  73. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/activities.py +0 -0
  74. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/dailies.py +0 -0
  75. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/teams.py +0 -0
  76. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/tests.py +0 -0
  77. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/traces.py +0 -0
  78. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/users.py +0 -0
  79. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/streamlit.py +0 -0
  80. {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/utils.py +0 -0
  81. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/__init__.py +0 -0
  82. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_access_token_cache.py +0 -0
  83. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_dailies.py +0 -0
  84. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_docs_check.py +0 -0
  85. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_docstrings.py +0 -0
  86. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_dtype_conversion.py +0 -0
  87. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_exceptions.py +0 -0
  88. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_frames.py +0 -0
  89. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_longitudinal_mean_max_after.py +0 -0
  90. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_metadata.py +0 -0
  91. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_output.py +0 -0
  92. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_public_hygiene.py +0 -0
  93. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_public_surface.py +0 -0
  94. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_renames.py +0 -0
  95. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_resources.py +0 -0
  96. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_segmentation.py +0 -0
  97. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_skill.py +0 -0
  98. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_sport_ost.py +0 -0
  99. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_teams.py +0 -0
  100. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_tests.py +0 -0
  101. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_trace_test_linking.py +0 -0
  102. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_webhooks.py +0 -0
  103. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_wire_names.py +0 -0
  104. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_write_timezone_validation.py +0 -0
  105. {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/typing/output_types.py +0 -0
@@ -6,6 +6,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
 
9
+ ## [0.92.0] - 2026-10-08
10
+
11
+ Portal links are created with the user's token, when the user acts. Requires the SweatStack API
12
+ release that replaces `action_url` with `destination` (see the platform changelog).
13
+
14
+ ### Changed
15
+
16
+ - **BREAKING: `issue.action_url` is replaced by `issue.destination`** on `oauth.userinfo()` and
17
+ `profile.status()`. Show a button only when `destination` is set, and on click pass it to
18
+ `portal.sessions.create()` for the URL. A destination the client does not know yet parses
19
+ instead of failing.
20
+ - **BREAKING: `portal.sessions.create()` authenticates with the client's user token**, not the
21
+ app's `client_id` and `client_secret`. Call it on a client that holds the user's access token;
22
+ the Portal is branded for the app that token was issued to. A delegated token raises
23
+ `SweatStackAuthError`.
24
+ - Without `return_url` the Portal no longer links back to your app's URL: it tells the user to
25
+ close the page. `return_url` must equal or sit under one of your app's registered redirect
26
+ URIs.
27
+
28
+ ### Upgrading
29
+
30
+ | Before | After |
31
+ |---|---|
32
+ | `user.issue.action_url` | `client.portal.sessions.create(user.issue.destination, return_url=...).url` |
33
+ | `Client(client_id=..., client_secret=...).portal.sessions.create(...)` | the same call on the client holding the user's token |
34
+
9
35
  ## [0.91.0] - 2026-10-08
10
36
 
11
37
  One namespace per API resource: `client.activities.list()` instead of `client.get_activities()`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sweatstack
3
- Version: 0.91.0
3
+ Version: 0.92.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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sweatstack"
3
- version = "0.91.0"
3
+ version = "0.92.0"
4
4
  description = "The official Python client for SweatStack"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -103,7 +103,7 @@ client.whoami() # the user this cl
103
103
  ## portal and oauth
104
104
 
105
105
  ```python
106
- client.portal.sessions.create("manage-integrations", return_url=None) # app credentials, no user token
106
+ client.portal.sessions.create("manage-integrations", return_url=None) # user token; mint on click, never store
107
107
 
108
108
  client.oauth.userinfo() # OpenID claims + issue (needs the profile scope)
109
109
  verifier, challenge = client.oauth.generate_pkce_params()
@@ -59,7 +59,8 @@ taxonomy is preserved (`is_standard` is `False`) instead of failing.
59
59
  `email`, `registered_at`, `issue`.
60
60
  - **AccountStatusResponse** (`profile.status()`, beta): `issue` (`StatusIssueResponse | None`:
61
61
  `code` (open set), `status` (`CapabilityStatus`: `ready`, `syncing`, `action_required`,
62
- `unavailable`), `message` (display only), `action_url`) and `capabilities`
63
- (`dict[Capability, CapabilityStatus]`). Branch on `status`, show `message`, show a button
64
- only when `action_url` is set.
62
+ `unavailable`), `message` (display only), `destination` (`PortalDestination | None`)) and
63
+ `capabilities` (`dict[Capability, CapabilityStatus]`). Branch on `status`, show `message`,
64
+ show a button only when `destination` is set, and on click pass it to
65
+ `portal.sessions.create()`.
65
66
  - **PortalSessionResponse**: `url` (opaque; never build one by hand).
@@ -121,9 +121,13 @@ activities = auth.client.activities.list()
121
121
  user = auth.client.oauth.userinfo()
122
122
  if user.issue:
123
123
  st.warning(user.issue.message)
124
- if user.issue.action_url:
125
- st.link_button("Fix it", user.issue.action_url)
124
+ if user.issue.destination:
125
+ # Minted fresh on each run and never stored. return_url must equal or sit under one of
126
+ # your app's registered redirect URIs.
127
+ session = auth.client.portal.sessions.create(
128
+ user.issue.destination, return_url="https://your-app.streamlit.app"
129
+ )
130
+ st.link_button("Fix it", session.url)
126
131
  ```
127
132
 
128
- `auth.client` carries the app credentials, so `auth.client.portal.sessions.create("manage-integrations",
129
- return_url=...)` works too. See [api.md](api.md#portal-and-oauth) and [data-models.md](data-models.md).
133
+ See [api.md](api.md#portal-and-oauth) and [data-models.md](data-models.md).
@@ -860,9 +860,8 @@ class Client:
860
860
  skip_token_check: Use the raw token without the expiry check (used during
861
861
  refresh, to avoid recursion).
862
862
  auth: If False, send no Authorization header and never load or refresh a token.
863
- For endpoints that authenticate with the app's own credentials in the body
864
- (Portal sessions, the token exchange), so a user's bearer is never sent where
865
- it is not needed.
863
+ For the token exchange, which authenticates with the app's own credentials in
864
+ the body, so a user's bearer is never sent where it is not needed.
866
865
  """
867
866
  headers = httpx.Headers({"User-Agent": f"python-sweatstack/{__version__}"})
868
867
  if not auth:
@@ -1,6 +1,6 @@
1
1
  # generated by datamodel-codegen:
2
2
  # filename: openapi.json
3
- # timestamp: 2026-10-05T10:01:09+00:00
3
+ # timestamp: 2026-10-08T18:52:13+00:00
4
4
 
5
5
  from __future__ import annotations
6
6
  from open_sport_taxonomy.pydantic import SportField # OST sport type (see schemas.py)
@@ -199,6 +199,12 @@ class BodySaveOrUpdateIntegrationProviderTenantsTenantIdIntegrationProvidersInte
199
199
  webhook_secret: SecretStr | None = Field(None, title='Webhook Secret')
200
200
 
201
201
 
202
+ class BodyStartReprocessInPlaceAdminOperationsReprocessInPlacePost(BaseModel):
203
+ user_ids: str | None = Field('', title='User Ids')
204
+ runners: int | None = Field(1, title='Runners')
205
+ batch_size: int | None = Field(0, title='Batch Size')
206
+
207
+
202
208
  class BodyUpdateManagedUserApiV1UsersUserIdPut(BaseModel):
203
209
  first_name: str | None = Field(None, title='First Name')
204
210
  last_name: str | None = Field(None, title='Last Name')
@@ -522,10 +528,8 @@ class PortalDestination(Enum):
522
528
 
523
529
 
524
530
  class PortalSessionRequest(BaseModel):
525
- client_id: str = Field(..., title='Client Id')
526
- client_secret: SecretStr | None = Field(None, title='Client Secret')
527
531
  destination: PortalDestination
528
- return_url: str | None = Field(None, title='Return Url')
532
+ return_url: constr(max_length=2048) | None = Field(None, title='Return Url')
529
533
 
530
534
 
531
535
  class PortalSessionResponse(BaseModel):
@@ -628,7 +632,7 @@ class StatusIssueResponse(BaseModel):
628
632
  code: StatusIssueCode
629
633
  status: CapabilityStatus
630
634
  message: str = Field(..., title='Message')
631
- action_url: str | None = Field(None, title='Action Url')
635
+ destination: PortalDestination | None = None
632
636
 
633
637
 
634
638
  class TTERef(BaseModel):
@@ -145,9 +145,10 @@ class OAuth(Resource):
145
145
  Requires the ``profile`` scope. Besides ``sub``, ``name``, ``given_name``,
146
146
  ``family_name``, ``email`` and ``registered_at``, the response has ``issue`` (beta):
147
147
  ``None`` when there is nothing to say, otherwise the one thing to tell the user now.
148
- ``issue.action_url`` opens the Portal; it is ``None`` on delegated tokens and on
149
- ``syncing`` or ``unavailable`` issues, so show a button only when it is set. Without
150
- the ``profile`` scope, ``client.profile.status()`` gives the same ``issue``.
148
+ ``issue.destination`` says the user can fix it in the Portal: pass it to
149
+ ``client.portal.sessions.create()`` when they click. It is ``None`` on delegated tokens
150
+ and on ``syncing`` or ``unavailable`` issues, so show a button only when it is set.
151
+ Without the ``profile`` scope, ``client.profile.status()`` gives the same ``issue``.
151
152
 
152
153
  Returns:
153
154
  UserInfoResponse: The claims and the optional ``issue``.
@@ -30,58 +30,55 @@ class PortalSessions(Resource):
30
30
  *,
31
31
  return_url: str | None = None,
32
32
  ) -> PortalSessionResponse:
33
- """Creates a Portal link for this app's users.
33
+ """Creates a Portal link for the user this client acts for.
34
34
 
35
35
  Endpoint: ``POST /api/v1/portal/sessions``
36
36
 
37
37
  **Beta**: the server documents the Portal as beta.
38
38
 
39
- Most apps don't need this: ``issue.action_url`` from ``client.oauth.userinfo()`` or
40
- ``client.profile.status()`` is already such a link. Create one to choose the
41
- destination or the return link.
39
+ The only way to get a Portal URL. Call it when the user acts, typically with
40
+ ``issue.destination`` from ``client.oauth.userinfo()`` or ``client.profile.status()``,
41
+ and send the user straight there: don't store or reuse the URL.
42
42
 
43
- This is a server-to-server call. It authenticates with the client's own app
44
- credentials (``client_id``, and ``client_secret`` if the app has one) in the request
45
- body, never with a user token; the user is identified when they open the link. A
46
- client on the default ``client_id`` creates a Portal branded as the SweatStack Python
47
- client. The URL is opaque: never build one by hand.
43
+ Needs a client holding the user's own access token (the one from your OAuth flow).
44
+ The Portal is branded for the app that token was issued to. The URL is opaque: never
45
+ build one by hand.
48
46
 
49
47
  Args:
50
48
  destination: ``"manage-integrations"`` or ``"manage-teams"``. A string the client
51
49
  does not know is sent as is, so a newer destination works before the SDK
52
50
  learns it.
53
- return_url: Where "Back to {app}" points; must be one of the app's registered
54
- redirect URIs. Omit it to have the Portal tell the user to close the page,
55
- which suits native apps.
51
+ return_url: Where "Back to {app}" points. It follows the same rule as an OAuth
52
+ redirect URI: it must equal or sit under one of the app's registered redirect
53
+ URIs. Omit it to have the Portal tell the user to close the page, which suits
54
+ native apps and installed PWAs.
56
55
 
57
56
  Returns:
58
57
  PortalSessionResponse: ``url``, to send the user to.
59
58
 
60
59
  Raises:
61
- SweatStackAuthError: If the app credentials are invalid (401).
62
- SweatStackBadRequestError: If ``return_url`` is not registered for the app, or
63
- ``client_id`` is not an application (400).
60
+ SweatStackAuthError: If the client has no access token (401), or the token is
61
+ delegated (403): a link minted for a coach would open the coach's own account.
62
+ SweatStackBadRequestError: If ``return_url`` does not belong to the app, or the
63
+ token was not issued to an app (400).
64
64
  SweatStackAPIError: If the API request fails for any other reason.
65
65
 
66
66
  Examples:
67
67
  ```python
68
68
  from sweatstack import Client
69
69
 
70
- app = Client(client_id="YOUR_CLIENT_ID", client_secret="YOUR_CLIENT_SECRET")
71
- session = app.portal.sessions.create(
72
- "manage-integrations", return_url="https://example.com/app/"
73
- )
74
- print(session.url)
70
+ client = Client() # holds the user's access token
71
+ user = client.oauth.userinfo()
72
+ if user.issue and user.issue.destination:
73
+ session = client.portal.sessions.create(
74
+ user.issue.destination, return_url="https://example.com/app/settings"
75
+ )
76
+ print(session.url)
75
77
  ```
76
78
  """
77
79
  client = self._client
78
- body: dict[str, Any] = {
79
- "client_id": client.client_id,
80
- "destination": client._enums_to_strings([destination])[0],
81
- }
82
- if client.client_secret is not None:
83
- body["client_secret"] = client.client_secret.get_secret_value()
80
+ body: dict[str, Any] = {"destination": client._enums_to_strings([destination])[0]}
84
81
  if return_url is not None:
85
82
  body["return_url"] = return_url
86
- response = client._request("post", "/api/v1/portal/sessions", json=body, auth=False)
83
+ response = client._request("post", "/api/v1/portal/sessions", json=body)
87
84
  return PortalSessionResponse.model_validate(response.json())
@@ -25,17 +25,18 @@ class Profile(Resource):
25
25
  **Beta**: the server documents this endpoint as beta.
26
26
 
27
27
  ``issue`` is ``None`` when there is nothing to tell the user, otherwise
28
- ``{code, status, message, action_url}``. Branch on ``issue.status``
28
+ ``{code, status, message, destination}``. Branch on ``issue.status``
29
29
  (:class:`CapabilityStatus`: ``ready``, ``syncing``, ``action_required``,
30
30
  ``unavailable``), show ``issue.message``, and show a button only when
31
- ``issue.action_url`` is set. Don't parse ``message`` or branch on ``code``.
31
+ ``issue.destination`` is set: on click, pass it to ``client.portal.sessions.create()``.
32
+ Don't parse ``message`` or branch on ``code``.
32
33
 
33
34
  ``capabilities`` maps each :class:`Capability` (``activities``, ``activity_history``,
34
35
  ``dailies``, ``workouts``) to a :class:`CapabilityStatus`. Codes and capability keys are
35
36
  open sets: a value the client doesn't know parses as a pseudo-member; ignore it.
36
37
 
37
38
  Accepts a token with ``data:read`` or ``profile``. Delegated tokens are allowed and
38
- always get ``action_url=None``.
39
+ always get ``destination=None``.
39
40
 
40
41
  Returns:
41
42
  AccountStatusResponse: ``issue`` and ``capabilities``.
@@ -51,7 +52,7 @@ class Profile(Resource):
51
52
  client = Client()
52
53
  status = client.profile.status()
53
54
  if status.issue:
54
- print(status.issue.message, status.issue.action_url)
55
+ print(status.issue.message, status.issue.destination)
55
56
  ```
56
57
  """
57
58
  response = self._client._request("get", "/api/v1/profile/status")
@@ -56,12 +56,12 @@ def _open_enum(enum_cls: type[Enum]) -> None:
56
56
  """Let ``enum_cls`` accept values the bundled schema does not know yet.
57
57
 
58
58
  The server treats some enums as open sets (new metrics, scopes, daily measures, status
59
- codes and capabilities appear without notice). An unknown value becomes a pseudo-member
60
- with ``.value`` and ``.name`` set to the string, cached so repeated lookups return the
61
- same object, instead of a validation error that would break the client until it is
62
- updated. Closed enums (``CapabilityStatus``, ``PortalDestination``, ...) are left strict
63
- on purpose: the server promises those never grow, or a value the client does not know
64
- is not something it should act on.
59
+ codes, capabilities and Portal destinations appear without notice). An unknown value
60
+ becomes a pseudo-member with ``.value`` and ``.name`` set to the string, cached so repeated
61
+ lookups return the same object, instead of a validation error that would break the client
62
+ until it is updated. Closed enums (``CapabilityStatus``, ...) are left strict on purpose:
63
+ the server promises those never grow, or a value the client does not know is not something
64
+ it should act on.
65
65
  """
66
66
 
67
67
  @classmethod
@@ -75,7 +75,7 @@ def _open_enum(enum_cls: type[Enum]) -> None:
75
75
  enum_cls._missing_ = _missing_ # ty: ignore[invalid-assignment] # patched onto a generated enum
76
76
 
77
77
 
78
- for _open in (Metric, Scope, DailyMeasure, StatusIssueCode, Capability):
78
+ for _open in (Metric, Scope, DailyMeasure, StatusIssueCode, Capability, PortalDestination):
79
79
  _open_enum(_open)
80
80
 
81
81
 
@@ -16,6 +16,7 @@ from sweatstack import (
16
16
  AccountStatusResponse,
17
17
  Capability,
18
18
  CapabilityStatus,
19
+ PortalDestination,
19
20
  StatusIssueCode,
20
21
  StatusIssueResponse,
21
22
  UserInfoResponse,
@@ -30,7 +31,7 @@ DOCS_PAYLOADS = {
30
31
  "code": "no_source_connected",
31
32
  "status": "action_required",
32
33
  "message": "No data source is connected yet.",
33
- "action_url": "https://app.sweatstack.no/portal/integrations?app=x",
34
+ "destination": "manage-integrations",
34
35
  }
35
36
  },
36
37
  "still_syncing": {
@@ -38,7 +39,7 @@ DOCS_PAYLOADS = {
38
39
  "code": "sync_pending",
39
40
  "status": "syncing",
40
41
  "message": "Garmin Connect data is still arriving.",
41
- "action_url": None,
42
+ "destination": None,
42
43
  }
43
44
  },
44
45
  "history_not_shared": {
@@ -46,7 +47,7 @@ DOCS_PAYLOADS = {
46
47
  "code": "activity_history_not_granted",
47
48
  "status": "action_required",
48
49
  "message": "Garmin Connect isn't sharing past activities.",
49
- "action_url": "https://app.sweatstack.no/portal/integrations?app=x",
50
+ "destination": "manage-integrations",
50
51
  }
51
52
  },
52
53
  "permanently_limited": {
@@ -54,7 +55,7 @@ DOCS_PAYLOADS = {
54
55
  "code": "dailies_unavailable",
55
56
  "status": "unavailable",
56
57
  "message": "No connected source provides daily health data.",
57
- "action_url": None,
58
+ "destination": None,
58
59
  }
59
60
  },
60
61
  "all_good": {"issue": None},
@@ -110,7 +111,8 @@ class TestDocumentedPayloads:
110
111
  assert user.issue.status == CapabilityStatus(payload["issue"]["status"])
111
112
  assert user.issue.code == StatusIssueCode(payload["issue"]["code"])
112
113
  assert user.issue.message == payload["issue"]["message"]
113
- assert user.issue.action_url == payload["issue"]["action_url"]
114
+ expected = payload["issue"]["destination"]
115
+ assert user.issue.destination == (expected and PortalDestination(expected))
114
116
 
115
117
  @pytest.mark.parametrize("payload", DOCS_PAYLOADS.values(), ids=DOCS_PAYLOADS.keys())
116
118
  def test_profile_status_round_trip(self, payload):
@@ -124,21 +126,22 @@ class TestDocumentedPayloads:
124
126
  user = UserInfoResponse.model_validate({**USERINFO, **DOCS_PAYLOADS["history_not_shared"]})
125
127
  shown = []
126
128
  if user.issue:
127
- shown.append((user.issue.message, user.issue.action_url))
129
+ shown.append((user.issue.message, user.issue.destination))
128
130
  assert shown == [
129
131
  (
130
132
  "Garmin Connect isn't sharing past activities.",
131
- "https://app.sweatstack.no/portal/integrations?app=x",
133
+ PortalDestination.manage_integrations,
132
134
  )
133
135
  ]
134
136
 
135
- def test_action_url_absent_means_no_button(self):
137
+ def test_destination_absent_means_no_button(self):
136
138
  user = UserInfoResponse.model_validate({**USERINFO, **DOCS_PAYLOADS["still_syncing"]})
137
- assert user.issue is not None and user.issue.action_url is None
139
+ assert user.issue is not None and user.issue.destination is None
138
140
 
139
141
 
140
142
  class TestOpenSets:
141
- """Codes and capability keys are open sets by contract; the status set is closed."""
143
+ """Codes, capability keys and destinations are open sets by contract; the status set is
144
+ closed."""
142
145
 
143
146
  def test_unknown_code_parses_as_pseudo_member(self):
144
147
  issue = StatusIssueResponse.model_validate(
@@ -148,6 +151,13 @@ class TestOpenSets:
148
151
  assert StatusIssueCode("something_new") is issue.code # cached
149
152
  assert StatusIssueResponse.model_validate(issue.model_dump()).code == issue.code
150
153
 
154
+ def test_unknown_destination_parses_as_pseudo_member(self):
155
+ """A destination added on the server must not break `userinfo()` on an older client."""
156
+ issue = StatusIssueResponse.model_validate(
157
+ {"code": "x", "status": "action_required", "message": "x", "destination": "manage-new"}
158
+ )
159
+ assert issue.destination.value == "manage-new"
160
+
151
161
  def test_unknown_capability_key_parses_and_known_keys_still_resolve(self):
152
162
  status = AccountStatusResponse.model_validate(
153
163
  {"issue": None, "capabilities": {**CAPABILITIES, "dailies_history": "syncing"}}
@@ -8,7 +8,7 @@ import httpx
8
8
  import pytest
9
9
  from pydantic import SecretStr
10
10
 
11
- from sweatstack import UserInfoResponse, UserSummary
11
+ from sweatstack import PortalDestination, UserInfoResponse, UserSummary
12
12
  from sweatstack.client import Client
13
13
 
14
14
 
@@ -73,7 +73,7 @@ class TestUserinfo:
73
73
  "code": "no_source_connected",
74
74
  "status": "action_required",
75
75
  "message": "No data source is connected yet.",
76
- "action_url": "https://app.sweatstack.no/portal/x",
76
+ "destination": "manage-integrations",
77
77
  },
78
78
  }
79
79
  http = MagicMock()
@@ -83,4 +83,5 @@ class TestUserinfo:
83
83
  with patch.object(client, "_http_client", return_value=http):
84
84
  user = client.oauth.userinfo()
85
85
  assert isinstance(user, UserInfoResponse)
86
- assert user.issue is not None and user.issue.action_url.endswith("/portal/x")
86
+ assert user.issue is not None
87
+ assert user.issue.destination == PortalDestination.manage_integrations
@@ -1,10 +1,9 @@
1
- """Portal sessions: ``POST /api/v1/portal/sessions`` with the app's own credentials."""
1
+ """Portal sessions: ``POST /api/v1/portal/sessions`` with the user's access token."""
2
2
 
3
- from unittest.mock import MagicMock, PropertyMock, patch
3
+ from unittest.mock import MagicMock, patch
4
4
 
5
5
  import httpx
6
6
  import pytest
7
- from pydantic import SecretStr
8
7
 
9
8
  from sweatstack import PortalDestination, PortalSessionResponse
10
9
  from sweatstack.client import Client
@@ -44,18 +43,17 @@ def _mint(app, **kwargs):
44
43
 
45
44
 
46
45
  class TestRequestBody:
47
- def test_public_client_sends_id_and_destination_only(self, app):
46
+ def test_sends_the_destination_and_nothing_about_the_app(self, app):
47
+ """The app is the token's audience, so the body names no client and carries no secret."""
48
48
  session, call, _ = _mint(app, destination="manage-integrations")
49
49
  assert call["url"] == "/api/v1/portal/sessions"
50
- assert call["json"] == {"client_id": "app_123", "destination": "manage-integrations"}
50
+ assert call["json"] == {"destination": "manage-integrations"}
51
51
  assert isinstance(session, PortalSessionResponse)
52
52
  assert session.url.startswith("https://app.sweatstack.no/portal/")
53
53
 
54
- def test_confidential_client_sends_its_secret(self, app):
55
- app._client_secret = SecretStr("s3cret")
54
+ def test_accepts_the_enum(self, app):
56
55
  _, call, _ = _mint(app, destination=PortalDestination.manage_teams)
57
- assert call["json"]["client_secret"] == "s3cret"
58
- assert call["json"]["destination"] == "manage-teams"
56
+ assert call["json"] == {"destination": "manage-teams"}
59
57
 
60
58
  def test_return_url_is_forwarded_only_when_given(self, app):
61
59
  _, call, _ = _mint(
@@ -70,36 +68,29 @@ class TestRequestBody:
70
68
  assert call["json"]["destination"] == "manage-something-new"
71
69
 
72
70
 
73
- class TestNoUserToken:
74
- def test_minting_uses_the_unauthenticated_http_client(self, app):
71
+ class TestUserToken:
72
+ def test_minting_sends_the_users_bearer(self, app):
75
73
  _, _, http_client_kwargs = _mint(app, destination="manage-integrations")
76
- assert http_client_kwargs == {"auth": False}
77
-
78
- def test_auth_false_sends_no_authorization_header_and_never_loads_a_token(self, app):
79
- app._api_key = SecretStr("user-bearer")
80
- with patch.object(Client, "api_key", new_callable=PropertyMock) as api_key:
81
- api_key.side_effect = AssertionError("token load/refresh must not run for auth=False")
82
- with app._http_client(auth=False) as http:
83
- assert "authorization" not in {k.lower() for k in http.headers}
84
- assert http.headers["user-agent"].startswith("python-sweatstack/")
85
-
86
- def test_auth_true_still_sends_the_bearer(self, app):
87
- app._api_key = SecretStr("user-bearer")
88
- with app._http_client(skip_token_check=True) as http:
89
- assert http.headers["authorization"] == "Bearer user-bearer"
74
+ assert http_client_kwargs == {"auth": True}
90
75
 
91
76
 
92
77
  class TestErrors:
93
- def test_bad_credentials(self, app):
94
- http = _http_returning(401, {"detail": "Invalid client credentials"})
78
+ def test_no_token_or_a_delegated_one(self, app):
79
+ http = _http_returning(403, {"detail": "Delegated token not allowed."})
95
80
  with (
96
81
  patch.object(app, "_http_client", return_value=http),
97
82
  pytest.raises(SweatStackAuthError),
98
83
  ):
99
84
  app.portal.sessions.create("manage-integrations")
100
85
 
101
- def test_unregistered_return_url(self, app):
102
- http = _http_returning(400, {"detail": "return_url is not registered for this application"})
86
+ def test_return_url_outside_the_redirect_uris(self, app):
87
+ http = _http_returning(
88
+ 400,
89
+ {
90
+ "detail": "return_url must equal or sit under one of the application's "
91
+ "registered redirect URIs"
92
+ },
93
+ )
103
94
  with (
104
95
  patch.object(app, "_http_client", return_value=http),
105
96
  pytest.raises(SweatStackBadRequestError),
@@ -9,7 +9,7 @@ import base64
9
9
  import json
10
10
  import threading
11
11
  import time
12
- from unittest.mock import patch
12
+ from unittest.mock import PropertyMock, patch
13
13
 
14
14
  import httpx
15
15
  import pytest
@@ -152,11 +152,20 @@ class TestConnections:
152
152
  def test_authorization_is_per_request(self):
153
153
  client, seen = _client(_statuses(200))
154
154
  client._request("get", "/api/v1/traces/")
155
- client._request("post", "/api/v1/portal/sessions", json={}, auth=False)
155
+ client._request("post", "/api/v1/oauth/token", data={}, auth=False)
156
156
  assert seen[0].headers["authorization"] == "Bearer user-token"
157
157
  assert "authorization" not in seen[1].headers
158
158
  assert all(r.headers["user-agent"].startswith("python-sweatstack/") for r in seen)
159
159
 
160
+ def test_auth_false_sends_no_authorization_header_and_never_loads_a_token(self):
161
+ """The token exchange runs before there is a token to load or refresh."""
162
+ client = Client(api_key="user-bearer", url=URL)
163
+ with patch.object(Client, "api_key", new_callable=PropertyMock) as api_key:
164
+ api_key.side_effect = AssertionError("token load/refresh must not run for auth=False")
165
+ with client._http_client(auth=False) as http:
166
+ assert "authorization" not in {k.lower() for k in http.headers}
167
+ assert http.headers["user-agent"].startswith("python-sweatstack/")
168
+
160
169
  def test_close_releases_the_pool_and_the_next_call_opens_one(self):
161
170
  client = Client(api_key="x", url=URL)
162
171
  pool = client._pool()
@@ -2748,7 +2748,7 @@ wheels = [
2748
2748
 
2749
2749
  [[package]]
2750
2750
  name = "sweatstack"
2751
- version = "0.91.0"
2751
+ version = "0.92.0"
2752
2752
  source = { editable = "." }
2753
2753
  dependencies = [
2754
2754
  { name = "email-validator" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes