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.
- {sweatstack-0.91.0 → sweatstack-0.92.0}/CHANGELOG.md +26 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/PKG-INFO +1 -1
- {sweatstack-0.91.0 → sweatstack-0.92.0}/pyproject.toml +1 -1
- {sweatstack-0.91.0 → sweatstack-0.92.0}/skills/sweatstack-python/api.md +1 -1
- {sweatstack-0.91.0 → sweatstack-0.92.0}/skills/sweatstack-python/data-models.md +4 -3
- {sweatstack-0.91.0 → sweatstack-0.92.0}/skills/sweatstack-python/streamlit.md +8 -4
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/client.py +2 -3
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/openapi_schemas.py +9 -5
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/oauth.py +4 -3
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/portal.py +24 -27
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/profile.py +5 -4
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/schemas.py +7 -7
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_account_status.py +20 -10
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_identity.py +4 -3
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_portal.py +20 -29
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_transport.py +11 -2
- {sweatstack-0.91.0 → sweatstack-0.92.0}/uv.lock +1 -1
- {sweatstack-0.91.0 → sweatstack-0.92.0}/.git-blame-ignore-revs +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/.github/workflows/ci.yml +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/.gitignore +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/.gitleaks.toml +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/.python-version +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/AGENTS.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/CONTRIBUTING.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/DEVELOPMENT.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/LICENSE +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/Makefile +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/README.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/evals/__init__.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/evals/agent/__init__.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/evals/agent/agent.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/evals/agent/cases.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/evals/agent/evaluators.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/evals/agent/harness.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/evals/agent/lifecycle.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/evals/agent/run.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/examples/fastapi_webhooks_example.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/examples/send_webhook.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/plans/001a_tests.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/plans/001b_metadata.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/plans/001c_dailies.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/plans/003_trace_test_linking.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/plans/004_codebase_hygiene.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/plans/005_ost_sport_bridge.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/plans/006_output_backends.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/plans/007_account_status_userinfo_portal.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/plans/008_sdk_documentation.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/plans/009_road_to_1_0.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/skills/sweatstack-python/SKILL.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/skills/sweatstack-python/fastapi.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/__init__.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/_docs.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/_frames.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/_renames.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/_transport.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/cli.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/constants.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/exceptions.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/fastapi/__init__.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/fastapi/config.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/fastapi/dependencies.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/fastapi/models.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/fastapi/routes.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/fastapi/session.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/fastapi/token_stores.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/fastapi/webhooks.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/py.typed +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/__init__.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/_app_metadata.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/_base.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/activities.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/dailies.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/teams.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/tests.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/traces.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/resources/users.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/streamlit.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/src/sweatstack/utils.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/__init__.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_access_token_cache.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_dailies.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_docs_check.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_docstrings.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_dtype_conversion.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_exceptions.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_frames.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_longitudinal_mean_max_after.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_metadata.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_output.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_public_hygiene.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_public_surface.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_renames.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_resources.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_segmentation.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_skill.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_sport_ost.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_teams.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_tests.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_trace_test_linking.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_webhooks.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_wire_names.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.92.0}/tests/test_write_timezone_validation.py +0 -0
- {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()`.
|
|
@@ -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) #
|
|
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), `
|
|
63
|
-
(`dict[Capability, CapabilityStatus]`). Branch on `status`, show `message`,
|
|
64
|
-
only when `
|
|
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.
|
|
125
|
-
|
|
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
|
-
|
|
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
|
|
864
|
-
|
|
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-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
149
|
-
``
|
|
150
|
-
|
|
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
|
|
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
|
-
|
|
40
|
-
``client.
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
54
|
-
redirect
|
|
55
|
-
which suits
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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,
|
|
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.
|
|
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 ``
|
|
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.
|
|
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
|
|
60
|
-
with ``.value`` and ``.name`` set to the string, cached so repeated
|
|
61
|
-
same object, instead of a validation error that would break the client
|
|
62
|
-
updated. Closed enums (``CapabilityStatus``,
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
133
|
+
PortalDestination.manage_integrations,
|
|
132
134
|
)
|
|
133
135
|
]
|
|
134
136
|
|
|
135
|
-
def
|
|
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.
|
|
139
|
+
assert user.issue is not None and user.issue.destination is None
|
|
138
140
|
|
|
139
141
|
|
|
140
142
|
class TestOpenSets:
|
|
141
|
-
"""Codes
|
|
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
|
-
"
|
|
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
|
|
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
|
|
1
|
+
"""Portal sessions: ``POST /api/v1/portal/sessions`` with the user's access token."""
|
|
2
2
|
|
|
3
|
-
from unittest.mock import MagicMock,
|
|
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
|
|
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"] == {"
|
|
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
|
|
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"]
|
|
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
|
|
74
|
-
def
|
|
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":
|
|
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
|
|
94
|
-
http = _http_returning(
|
|
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
|
|
102
|
-
http = _http_returning(
|
|
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/
|
|
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()
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|