sweatstack 0.91.0__tar.gz → 0.93.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.93.0}/CHANGELOG.md +44 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/PKG-INFO +1 -1
- {sweatstack-0.91.0 → sweatstack-0.93.0}/pyproject.toml +1 -1
- {sweatstack-0.91.0 → sweatstack-0.93.0}/skills/sweatstack-python/api.md +1 -1
- {sweatstack-0.91.0 → sweatstack-0.93.0}/skills/sweatstack-python/data-models.md +4 -3
- {sweatstack-0.91.0 → sweatstack-0.93.0}/skills/sweatstack-python/streamlit.md +8 -4
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/client.py +4 -3
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/openapi_schemas.py +49 -29
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/oauth.py +5 -3
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/portal.py +24 -27
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/profile.py +14 -9
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/schemas.py +8 -7
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_account_status.py +36 -16
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_identity.py +4 -3
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_portal.py +20 -29
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_transport.py +11 -2
- {sweatstack-0.91.0 → sweatstack-0.93.0}/uv.lock +1 -1
- {sweatstack-0.91.0 → sweatstack-0.93.0}/.git-blame-ignore-revs +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/.github/workflows/ci.yml +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/.gitignore +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/.gitleaks.toml +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/.python-version +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/AGENTS.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/CONTRIBUTING.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/DEVELOPMENT.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/LICENSE +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/Makefile +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/README.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/evals/__init__.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/evals/agent/__init__.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/evals/agent/agent.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/evals/agent/cases.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/evals/agent/evaluators.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/evals/agent/harness.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/evals/agent/lifecycle.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/evals/agent/run.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/examples/fastapi_webhooks_example.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/examples/send_webhook.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/plans/001a_tests.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/plans/001b_metadata.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/plans/001c_dailies.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/plans/003_trace_test_linking.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/plans/004_codebase_hygiene.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/plans/005_ost_sport_bridge.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/plans/006_output_backends.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/plans/007_account_status_userinfo_portal.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/plans/008_sdk_documentation.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/plans/009_road_to_1_0.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/skills/sweatstack-python/SKILL.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/skills/sweatstack-python/fastapi.md +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/__init__.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/_docs.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/_frames.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/_renames.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/_transport.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/cli.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/constants.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/exceptions.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/fastapi/__init__.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/fastapi/config.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/fastapi/dependencies.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/fastapi/models.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/fastapi/routes.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/fastapi/session.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/fastapi/token_stores.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/fastapi/webhooks.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/py.typed +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/__init__.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/_app_metadata.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/_base.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/activities.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/dailies.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/teams.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/tests.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/traces.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/users.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/streamlit.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/utils.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/__init__.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_access_token_cache.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_dailies.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_docs_check.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_docstrings.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_dtype_conversion.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_exceptions.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_frames.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_longitudinal_mean_max_after.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_metadata.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_output.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_public_hygiene.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_public_surface.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_renames.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_resources.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_segmentation.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_skill.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_sport_ost.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_teams.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_tests.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_trace_test_linking.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_webhooks.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_wire_names.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_write_timezone_validation.py +0 -0
- {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/typing/output_types.py +0 -0
|
@@ -6,6 +6,50 @@ 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.93.0] - 2026-10-09
|
|
10
|
+
|
|
11
|
+
Issues are only about the data your app uses. Requires the SweatStack API release that adds
|
|
12
|
+
`capabilities` to applications (see the platform changelog).
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **BREAKING: `issue.status` is an `IssueStatus`**, `action_required` or `syncing`, on
|
|
17
|
+
`oauth.userinfo()` and `profile.status()`. The `unavailable` issues
|
|
18
|
+
(`activity_history_unavailable`, `dailies_unavailable`, `workouts_unavailable`) are gone: what
|
|
19
|
+
no connected source provides is in `capabilities`, where `unavailable` means the user could
|
|
20
|
+
still connect a source that provides it. Three new codes, each `action_required` with a
|
|
21
|
+
`destination`: `activities_blocked_upstream`, `dailies_no_source`, `workouts_no_source`. A
|
|
22
|
+
client on 0.90.0 or later accepts them without an upgrade (`StatusIssueCode` is open).
|
|
23
|
+
- `ApplicationCreate.capabilities` and `ApplicationResponse.capabilities`: the capabilities an
|
|
24
|
+
app declares it uses (`list[Capability]`; omit it for the default, `activities` and
|
|
25
|
+
`activity_history`). SweatStack reports issues only for these.
|
|
26
|
+
|
|
27
|
+
## [0.92.0] - 2026-10-08
|
|
28
|
+
|
|
29
|
+
Portal links are created with the user's token, when the user acts. Requires the SweatStack API
|
|
30
|
+
release that replaces `action_url` with `destination` (see the platform changelog).
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- **BREAKING: `issue.action_url` is replaced by `issue.destination`** on `oauth.userinfo()` and
|
|
35
|
+
`profile.status()`. Show a button only when `destination` is set, and on click pass it to
|
|
36
|
+
`portal.sessions.create()` for the URL. A destination the client does not know yet parses
|
|
37
|
+
instead of failing.
|
|
38
|
+
- **BREAKING: `portal.sessions.create()` authenticates with the client's user token**, not the
|
|
39
|
+
app's `client_id` and `client_secret`. Call it on a client that holds the user's access token;
|
|
40
|
+
the Portal is branded for the app that token was issued to. A delegated token raises
|
|
41
|
+
`SweatStackAuthError`.
|
|
42
|
+
- Without `return_url` the Portal no longer links back to your app's URL: it tells the user to
|
|
43
|
+
close the page. `return_url` must equal or sit under one of your app's registered redirect
|
|
44
|
+
URIs.
|
|
45
|
+
|
|
46
|
+
### Upgrading
|
|
47
|
+
|
|
48
|
+
| Before | After |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `user.issue.action_url` | `client.portal.sessions.create(user.issue.destination, return_url=...).url` |
|
|
51
|
+
| `Client(client_id=..., client_secret=...).portal.sessions.create(...)` | the same call on the client holding the user's token |
|
|
52
|
+
|
|
9
53
|
## [0.91.0] - 2026-10-08
|
|
10
54
|
|
|
11
55
|
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).
|
|
@@ -60,6 +60,7 @@ from .schemas import (
|
|
|
60
60
|
CapabilityStatus,
|
|
61
61
|
DailyMeasure,
|
|
62
62
|
DailyResponse,
|
|
63
|
+
IssueStatus,
|
|
63
64
|
Marker,
|
|
64
65
|
Metric,
|
|
65
66
|
Modifier,
|
|
@@ -860,9 +861,8 @@ class Client:
|
|
|
860
861
|
skip_token_check: Use the raw token without the expiry check (used during
|
|
861
862
|
refresh, to avoid recursion).
|
|
862
863
|
auth: If False, send no Authorization header and never load or refresh a token.
|
|
863
|
-
For
|
|
864
|
-
|
|
865
|
-
it is not needed.
|
|
864
|
+
For the token exchange, which authenticates with the app's own credentials in
|
|
865
|
+
the body, so a user's bearer is never sent where it is not needed.
|
|
866
866
|
"""
|
|
867
867
|
headers = httpx.Headers({"User-Agent": f"python-sweatstack/{__version__}"})
|
|
868
868
|
if not auth:
|
|
@@ -1104,6 +1104,7 @@ __all__ = sorted(
|
|
|
1104
1104
|
"CapabilityStatus",
|
|
1105
1105
|
"DailyMeasure",
|
|
1106
1106
|
"DailyResponse",
|
|
1107
|
+
"IssueStatus",
|
|
1107
1108
|
"Marker",
|
|
1108
1109
|
"Metric",
|
|
1109
1110
|
"Modifier",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# generated by datamodel-codegen:
|
|
2
2
|
# filename: openapi.json
|
|
3
|
-
# timestamp: 2026-10-
|
|
3
|
+
# timestamp: 2026-10-09T08:30:22+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)
|
|
@@ -44,26 +44,6 @@ class AltitudeSummary(BaseModel):
|
|
|
44
44
|
max: int | float | None = Field(None, title='Max')
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
class ApplicationCreate(BaseModel):
|
|
48
|
-
name: constr(min_length=1, max_length=100) = Field(..., title='Name')
|
|
49
|
-
description: constr(max_length=500) | None = Field(None, title='Description')
|
|
50
|
-
redirect_uris: list[str] | None = Field(None, title='Redirect Uris')
|
|
51
|
-
page: str | None = Field(None, title='Page')
|
|
52
|
-
generate_secret: bool | None = Field(False, title='Generate Secret')
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
class ApplicationCreateOrUpdate(BaseModel):
|
|
56
|
-
name: str = Field(..., title='Name')
|
|
57
|
-
description: str | None = Field(None, title='Description')
|
|
58
|
-
url: AnyUrl | None = Field(None, title='Url')
|
|
59
|
-
image: AnyUrl | None = Field(None, title='Image')
|
|
60
|
-
redirect_uris: list[str] | None = Field(None, title='Redirect Uris')
|
|
61
|
-
privacy_statement: AnyUrl | None = Field(None, title='Privacy Statement')
|
|
62
|
-
brand_color: str | None = Field(None, title='Brand Color')
|
|
63
|
-
webhook_endpoints: list[AnyUrl] | None = Field(None, title='Webhook Endpoints')
|
|
64
|
-
webhook_secret: SecretStr | None = Field(None, title='Webhook Secret')
|
|
65
|
-
|
|
66
|
-
|
|
67
47
|
class ApplicationListItem(BaseModel):
|
|
68
48
|
id: str = Field(..., title='Id')
|
|
69
49
|
name: str = Field(..., title='Name')
|
|
@@ -199,6 +179,12 @@ class BodySaveOrUpdateIntegrationProviderTenantsTenantIdIntegrationProvidersInte
|
|
|
199
179
|
webhook_secret: SecretStr | None = Field(None, title='Webhook Secret')
|
|
200
180
|
|
|
201
181
|
|
|
182
|
+
class BodyStartReprocessInPlaceAdminOperationsReprocessInPlacePost(BaseModel):
|
|
183
|
+
user_ids: str | None = Field('', title='User Ids')
|
|
184
|
+
runners: int | None = Field(1, title='Runners')
|
|
185
|
+
batch_size: int | None = Field(0, title='Batch Size')
|
|
186
|
+
|
|
187
|
+
|
|
202
188
|
class BodyUpdateManagedUserApiV1UsersUserIdPut(BaseModel):
|
|
203
189
|
first_name: str | None = Field(None, title='First Name')
|
|
204
190
|
last_name: str | None = Field(None, title='Last Name')
|
|
@@ -426,6 +412,11 @@ class IntervalsIcuWebhookEventType(Enum):
|
|
|
426
412
|
FITNESS_UPDATED = 'FITNESS_UPDATED'
|
|
427
413
|
|
|
428
414
|
|
|
415
|
+
class IssueStatus(Enum):
|
|
416
|
+
action_required = 'action_required'
|
|
417
|
+
syncing = 'syncing'
|
|
418
|
+
|
|
419
|
+
|
|
429
420
|
class ManagedUserCreate(BaseModel):
|
|
430
421
|
first_name: str = Field(..., title='First Name')
|
|
431
422
|
last_name: str | None = Field(..., title='Last Name')
|
|
@@ -522,10 +513,8 @@ class PortalDestination(Enum):
|
|
|
522
513
|
|
|
523
514
|
|
|
524
515
|
class PortalSessionRequest(BaseModel):
|
|
525
|
-
client_id: str = Field(..., title='Client Id')
|
|
526
|
-
client_secret: SecretStr | None = Field(None, title='Client Secret')
|
|
527
516
|
destination: PortalDestination
|
|
528
|
-
return_url:
|
|
517
|
+
return_url: constr(max_length=2048) | None = Field(None, title='Return Url')
|
|
529
518
|
|
|
530
519
|
|
|
531
520
|
class PortalSessionResponse(BaseModel):
|
|
@@ -615,20 +604,20 @@ class SpeedSummary(BaseModel):
|
|
|
615
604
|
class StatusIssueCode(Enum):
|
|
616
605
|
no_source_connected = 'no_source_connected'
|
|
617
606
|
activities_not_granted = 'activities_not_granted'
|
|
607
|
+
activities_blocked_upstream = 'activities_blocked_upstream'
|
|
618
608
|
activity_history_not_granted = 'activity_history_not_granted'
|
|
619
609
|
dailies_not_granted = 'dailies_not_granted'
|
|
610
|
+
dailies_no_source = 'dailies_no_source'
|
|
620
611
|
workouts_not_granted = 'workouts_not_granted'
|
|
612
|
+
workouts_no_source = 'workouts_no_source'
|
|
621
613
|
sync_pending = 'sync_pending'
|
|
622
|
-
activity_history_unavailable = 'activity_history_unavailable'
|
|
623
|
-
dailies_unavailable = 'dailies_unavailable'
|
|
624
|
-
workouts_unavailable = 'workouts_unavailable'
|
|
625
614
|
|
|
626
615
|
|
|
627
616
|
class StatusIssueResponse(BaseModel):
|
|
628
617
|
code: StatusIssueCode
|
|
629
|
-
status:
|
|
618
|
+
status: IssueStatus
|
|
630
619
|
message: str = Field(..., title='Message')
|
|
631
|
-
|
|
620
|
+
destination: PortalDestination | None = None
|
|
632
621
|
|
|
633
622
|
|
|
634
623
|
class TTERef(BaseModel):
|
|
@@ -940,6 +929,32 @@ class ActivitySummarySummary(BaseModel):
|
|
|
940
929
|
smo2: Smo2Summary | None = None
|
|
941
930
|
|
|
942
931
|
|
|
932
|
+
class ApplicationCreate(BaseModel):
|
|
933
|
+
name: constr(min_length=1, max_length=100) = Field(..., title='Name')
|
|
934
|
+
description: constr(max_length=500) | None = Field(None, title='Description')
|
|
935
|
+
redirect_uris: list[str] | None = Field(None, title='Redirect Uris')
|
|
936
|
+
capabilities: list[Capability] | None = Field(
|
|
937
|
+
None,
|
|
938
|
+
description="The capabilities your app uses: SweatStack reports account-status issues only for these (plan 075). Omit it for the default, activities and activity_history. An empty list means no issues at all. Change it later in the app's settings.",
|
|
939
|
+
title='Capabilities',
|
|
940
|
+
)
|
|
941
|
+
page: str | None = Field(None, title='Page')
|
|
942
|
+
generate_secret: bool | None = Field(False, title='Generate Secret')
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
class ApplicationCreateOrUpdate(BaseModel):
|
|
946
|
+
name: str = Field(..., title='Name')
|
|
947
|
+
description: str | None = Field(None, title='Description')
|
|
948
|
+
url: AnyUrl | None = Field(None, title='Url')
|
|
949
|
+
image: AnyUrl | None = Field(None, title='Image')
|
|
950
|
+
redirect_uris: list[str] | None = Field(None, title='Redirect Uris')
|
|
951
|
+
privacy_statement: AnyUrl | None = Field(None, title='Privacy Statement')
|
|
952
|
+
brand_color: str | None = Field(None, title='Brand Color')
|
|
953
|
+
webhook_endpoints: list[AnyUrl] | None = Field(None, title='Webhook Endpoints')
|
|
954
|
+
webhook_secret: SecretStr | None = Field(None, title='Webhook Secret')
|
|
955
|
+
capabilities: list[Capability] | None = Field(None, title='Capabilities')
|
|
956
|
+
|
|
957
|
+
|
|
943
958
|
class ApplicationCreateResponse(BaseModel):
|
|
944
959
|
id: str = Field(..., title='Id')
|
|
945
960
|
name: str = Field(..., title='Name')
|
|
@@ -955,6 +970,11 @@ class ApplicationCreateResponse(BaseModel):
|
|
|
955
970
|
description='The App Directory is retired. Reflects the last stored listing; removed on 2026-10-12.',
|
|
956
971
|
title='Published',
|
|
957
972
|
)
|
|
973
|
+
capabilities: list[Capability] | None = Field(
|
|
974
|
+
None,
|
|
975
|
+
description='The capabilities this app uses. SweatStack reports account-status issues only for these. Default: activities and activity_history.',
|
|
976
|
+
title='Capabilities',
|
|
977
|
+
)
|
|
958
978
|
webhook_endpoints: list[AnyUrl] | None = Field(None, title='Webhook Endpoints')
|
|
959
979
|
webhook_secret: SecretStr | None = Field(None, title='Webhook Secret')
|
|
960
980
|
client_secret: str | None = Field(None, title='Client Secret')
|
|
@@ -145,9 +145,11 @@ 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`` issues, so show a button only when it is set. Issues are reported only
|
|
151
|
+
for the capabilities your app declared it uses.
|
|
152
|
+
Without the ``profile`` scope, ``client.profile.status()`` gives the same ``issue``.
|
|
151
153
|
|
|
152
154
|
Returns:
|
|
153
155
|
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())
|
|
@@ -24,18 +24,23 @@ class Profile(Resource):
|
|
|
24
24
|
|
|
25
25
|
**Beta**: the server documents this endpoint as beta.
|
|
26
26
|
|
|
27
|
-
``issue`` is ``None`` when
|
|
28
|
-
``{code, status, message,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
``issue`` is ``None`` when nothing is wrong with the data your app uses, otherwise
|
|
28
|
+
``{code, status, message, destination}``. An issue is always temporary: ``issue.status``
|
|
29
|
+
is ``action_required`` (the user can fix it) or ``syncing`` (the data is arriving), never
|
|
30
|
+
a third value. Show ``issue.message``, and show a button only when ``issue.destination``
|
|
31
|
+
is set: on click, pass it to ``client.portal.sessions.create()``. Don't parse
|
|
32
|
+
``message`` or branch on ``code``. Issues are reported only for the capabilities your app
|
|
33
|
+
declared it uses (its settings; the default is activities and activity history).
|
|
32
34
|
|
|
33
35
|
``capabilities`` maps each :class:`Capability` (``activities``, ``activity_history``,
|
|
34
|
-
``dailies``, ``workouts``) to a :class:`CapabilityStatus
|
|
35
|
-
|
|
36
|
+
``dailies``, ``workouts``) to a :class:`CapabilityStatus` (``ready``, ``syncing``,
|
|
37
|
+
``action_required``, ``unavailable``): facts about the user's connected sources, the
|
|
38
|
+
same for every app. ``unavailable`` means no connected source provides it, which
|
|
39
|
+
changes when the user connects one that does. Codes and capability keys are open sets:
|
|
40
|
+
a value the client doesn't know parses as a pseudo-member; ignore it.
|
|
36
41
|
|
|
37
42
|
Accepts a token with ``data:read`` or ``profile``. Delegated tokens are allowed and
|
|
38
|
-
always get ``
|
|
43
|
+
always get ``destination=None``.
|
|
39
44
|
|
|
40
45
|
Returns:
|
|
41
46
|
AccountStatusResponse: ``issue`` and ``capabilities``.
|
|
@@ -51,7 +56,7 @@ class Profile(Resource):
|
|
|
51
56
|
client = Client()
|
|
52
57
|
status = client.profile.status()
|
|
53
58
|
if status.issue:
|
|
54
|
-
print(status.issue.message, status.issue.
|
|
59
|
+
print(status.issue.message, status.issue.destination)
|
|
55
60
|
```
|
|
56
61
|
"""
|
|
57
62
|
response = self._client._request("get", "/api/v1/profile/status")
|
|
@@ -30,6 +30,7 @@ from .openapi_schemas import (
|
|
|
30
30
|
CapabilityStatus,
|
|
31
31
|
DailyMeasure,
|
|
32
32
|
DailyResponse,
|
|
33
|
+
IssueStatus,
|
|
33
34
|
Marker,
|
|
34
35
|
Metric,
|
|
35
36
|
PortalDestination,
|
|
@@ -56,12 +57,12 @@ def _open_enum(enum_cls: type[Enum]) -> None:
|
|
|
56
57
|
"""Let ``enum_cls`` accept values the bundled schema does not know yet.
|
|
57
58
|
|
|
58
59
|
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
|
-
|
|
60
|
+
codes, capabilities and Portal destinations appear without notice). An unknown value
|
|
61
|
+
becomes a pseudo-member with ``.value`` and ``.name`` set to the string, cached so repeated
|
|
62
|
+
lookups return the same object, instead of a validation error that would break the client
|
|
63
|
+
until it is updated. Closed enums (``CapabilityStatus``, ...) are left strict on purpose:
|
|
64
|
+
the server promises those never grow, or a value the client does not know is not something
|
|
65
|
+
it should act on.
|
|
65
66
|
"""
|
|
66
67
|
|
|
67
68
|
@classmethod
|
|
@@ -75,7 +76,7 @@ def _open_enum(enum_cls: type[Enum]) -> None:
|
|
|
75
76
|
enum_cls._missing_ = _missing_ # ty: ignore[invalid-assignment] # patched onto a generated enum
|
|
76
77
|
|
|
77
78
|
|
|
78
|
-
for _open in (Metric, Scope, DailyMeasure, StatusIssueCode, Capability):
|
|
79
|
+
for _open in (Metric, Scope, DailyMeasure, StatusIssueCode, Capability, PortalDestination):
|
|
79
80
|
_open_enum(_open)
|
|
80
81
|
|
|
81
82
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"""Account status: ``userinfo.issue`` and ``GET /api/v1/profile/status``.
|
|
2
2
|
|
|
3
|
-
The fixtures are the
|
|
3
|
+
The fixtures are the six payloads the public docs prescribe for mocking
|
|
4
4
|
("Why does this user have no data?"), verbatim, so these tests pin the SDK to
|
|
5
|
-
the documented contract rather than to our reading of it.
|
|
5
|
+
the documented contract rather than to our reading of it. An issue is always
|
|
6
|
+
temporary: ``action_required`` or ``syncing``, never ``unavailable`` (plan 075).
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
from unittest.mock import MagicMock, patch
|
|
@@ -16,6 +17,8 @@ from sweatstack import (
|
|
|
16
17
|
AccountStatusResponse,
|
|
17
18
|
Capability,
|
|
18
19
|
CapabilityStatus,
|
|
20
|
+
IssueStatus,
|
|
21
|
+
PortalDestination,
|
|
19
22
|
StatusIssueCode,
|
|
20
23
|
StatusIssueResponse,
|
|
21
24
|
UserInfoResponse,
|
|
@@ -30,7 +33,7 @@ DOCS_PAYLOADS = {
|
|
|
30
33
|
"code": "no_source_connected",
|
|
31
34
|
"status": "action_required",
|
|
32
35
|
"message": "No data source is connected yet.",
|
|
33
|
-
"
|
|
36
|
+
"destination": "manage-integrations",
|
|
34
37
|
}
|
|
35
38
|
},
|
|
36
39
|
"still_syncing": {
|
|
@@ -38,7 +41,7 @@ DOCS_PAYLOADS = {
|
|
|
38
41
|
"code": "sync_pending",
|
|
39
42
|
"status": "syncing",
|
|
40
43
|
"message": "Garmin Connect data is still arriving.",
|
|
41
|
-
"
|
|
44
|
+
"destination": None,
|
|
42
45
|
}
|
|
43
46
|
},
|
|
44
47
|
"history_not_shared": {
|
|
@@ -46,15 +49,23 @@ DOCS_PAYLOADS = {
|
|
|
46
49
|
"code": "activity_history_not_granted",
|
|
47
50
|
"status": "action_required",
|
|
48
51
|
"message": "Garmin Connect isn't sharing past activities.",
|
|
49
|
-
"
|
|
52
|
+
"destination": "manage-integrations",
|
|
50
53
|
}
|
|
51
54
|
},
|
|
52
|
-
"
|
|
55
|
+
"blocked_upstream": {
|
|
53
56
|
"issue": {
|
|
54
|
-
"code": "
|
|
55
|
-
"status": "
|
|
57
|
+
"code": "activities_blocked_upstream",
|
|
58
|
+
"status": "action_required",
|
|
59
|
+
"message": "Intervals.icu can't pass on activities that come from Strava.",
|
|
60
|
+
"destination": "manage-integrations",
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"no_source": {
|
|
64
|
+
"issue": {
|
|
65
|
+
"code": "dailies_no_source",
|
|
66
|
+
"status": "action_required",
|
|
56
67
|
"message": "No connected source provides daily health data.",
|
|
57
|
-
"
|
|
68
|
+
"destination": "manage-integrations",
|
|
58
69
|
}
|
|
59
70
|
},
|
|
60
71
|
"all_good": {"issue": None},
|
|
@@ -107,10 +118,11 @@ class TestDocumentedPayloads:
|
|
|
107
118
|
assert user.issue is None
|
|
108
119
|
else:
|
|
109
120
|
assert isinstance(user.issue, StatusIssueResponse)
|
|
110
|
-
assert user.issue.status ==
|
|
121
|
+
assert user.issue.status == IssueStatus(payload["issue"]["status"])
|
|
111
122
|
assert user.issue.code == StatusIssueCode(payload["issue"]["code"])
|
|
112
123
|
assert user.issue.message == payload["issue"]["message"]
|
|
113
|
-
|
|
124
|
+
expected = payload["issue"]["destination"]
|
|
125
|
+
assert user.issue.destination == (expected and PortalDestination(expected))
|
|
114
126
|
|
|
115
127
|
@pytest.mark.parametrize("payload", DOCS_PAYLOADS.values(), ids=DOCS_PAYLOADS.keys())
|
|
116
128
|
def test_profile_status_round_trip(self, payload):
|
|
@@ -124,21 +136,22 @@ class TestDocumentedPayloads:
|
|
|
124
136
|
user = UserInfoResponse.model_validate({**USERINFO, **DOCS_PAYLOADS["history_not_shared"]})
|
|
125
137
|
shown = []
|
|
126
138
|
if user.issue:
|
|
127
|
-
shown.append((user.issue.message, user.issue.
|
|
139
|
+
shown.append((user.issue.message, user.issue.destination))
|
|
128
140
|
assert shown == [
|
|
129
141
|
(
|
|
130
142
|
"Garmin Connect isn't sharing past activities.",
|
|
131
|
-
|
|
143
|
+
PortalDestination.manage_integrations,
|
|
132
144
|
)
|
|
133
145
|
]
|
|
134
146
|
|
|
135
|
-
def
|
|
147
|
+
def test_destination_absent_means_no_button(self):
|
|
136
148
|
user = UserInfoResponse.model_validate({**USERINFO, **DOCS_PAYLOADS["still_syncing"]})
|
|
137
|
-
assert user.issue is not None and user.issue.
|
|
149
|
+
assert user.issue is not None and user.issue.destination is None
|
|
138
150
|
|
|
139
151
|
|
|
140
152
|
class TestOpenSets:
|
|
141
|
-
"""Codes
|
|
153
|
+
"""Codes, capability keys and destinations are open sets by contract; the status set is
|
|
154
|
+
closed."""
|
|
142
155
|
|
|
143
156
|
def test_unknown_code_parses_as_pseudo_member(self):
|
|
144
157
|
issue = StatusIssueResponse.model_validate(
|
|
@@ -148,6 +161,13 @@ class TestOpenSets:
|
|
|
148
161
|
assert StatusIssueCode("something_new") is issue.code # cached
|
|
149
162
|
assert StatusIssueResponse.model_validate(issue.model_dump()).code == issue.code
|
|
150
163
|
|
|
164
|
+
def test_unknown_destination_parses_as_pseudo_member(self):
|
|
165
|
+
"""A destination added on the server must not break `userinfo()` on an older client."""
|
|
166
|
+
issue = StatusIssueResponse.model_validate(
|
|
167
|
+
{"code": "x", "status": "action_required", "message": "x", "destination": "manage-new"}
|
|
168
|
+
)
|
|
169
|
+
assert issue.destination.value == "manage-new"
|
|
170
|
+
|
|
151
171
|
def test_unknown_capability_key_parses_and_known_keys_still_resolve(self):
|
|
152
172
|
status = AccountStatusResponse.model_validate(
|
|
153
173
|
{"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
|