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.
Files changed (105) hide show
  1. {sweatstack-0.91.0 → sweatstack-0.93.0}/CHANGELOG.md +44 -0
  2. {sweatstack-0.91.0 → sweatstack-0.93.0}/PKG-INFO +1 -1
  3. {sweatstack-0.91.0 → sweatstack-0.93.0}/pyproject.toml +1 -1
  4. {sweatstack-0.91.0 → sweatstack-0.93.0}/skills/sweatstack-python/api.md +1 -1
  5. {sweatstack-0.91.0 → sweatstack-0.93.0}/skills/sweatstack-python/data-models.md +4 -3
  6. {sweatstack-0.91.0 → sweatstack-0.93.0}/skills/sweatstack-python/streamlit.md +8 -4
  7. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/client.py +4 -3
  8. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/openapi_schemas.py +49 -29
  9. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/oauth.py +5 -3
  10. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/portal.py +24 -27
  11. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/profile.py +14 -9
  12. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/schemas.py +8 -7
  13. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_account_status.py +36 -16
  14. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_identity.py +4 -3
  15. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_portal.py +20 -29
  16. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_transport.py +11 -2
  17. {sweatstack-0.91.0 → sweatstack-0.93.0}/uv.lock +1 -1
  18. {sweatstack-0.91.0 → sweatstack-0.93.0}/.git-blame-ignore-revs +0 -0
  19. {sweatstack-0.91.0 → sweatstack-0.93.0}/.github/workflows/ci.yml +0 -0
  20. {sweatstack-0.91.0 → sweatstack-0.93.0}/.gitignore +0 -0
  21. {sweatstack-0.91.0 → sweatstack-0.93.0}/.gitleaks.toml +0 -0
  22. {sweatstack-0.91.0 → sweatstack-0.93.0}/.python-version +0 -0
  23. {sweatstack-0.91.0 → sweatstack-0.93.0}/AGENTS.md +0 -0
  24. {sweatstack-0.91.0 → sweatstack-0.93.0}/CONTRIBUTING.md +0 -0
  25. {sweatstack-0.91.0 → sweatstack-0.93.0}/DEVELOPMENT.md +0 -0
  26. {sweatstack-0.91.0 → sweatstack-0.93.0}/LICENSE +0 -0
  27. {sweatstack-0.91.0 → sweatstack-0.93.0}/Makefile +0 -0
  28. {sweatstack-0.91.0 → sweatstack-0.93.0}/README.md +0 -0
  29. {sweatstack-0.91.0 → sweatstack-0.93.0}/evals/__init__.py +0 -0
  30. {sweatstack-0.91.0 → sweatstack-0.93.0}/evals/agent/__init__.py +0 -0
  31. {sweatstack-0.91.0 → sweatstack-0.93.0}/evals/agent/agent.py +0 -0
  32. {sweatstack-0.91.0 → sweatstack-0.93.0}/evals/agent/cases.py +0 -0
  33. {sweatstack-0.91.0 → sweatstack-0.93.0}/evals/agent/evaluators.py +0 -0
  34. {sweatstack-0.91.0 → sweatstack-0.93.0}/evals/agent/harness.py +0 -0
  35. {sweatstack-0.91.0 → sweatstack-0.93.0}/evals/agent/lifecycle.py +0 -0
  36. {sweatstack-0.91.0 → sweatstack-0.93.0}/evals/agent/run.py +0 -0
  37. {sweatstack-0.91.0 → sweatstack-0.93.0}/examples/fastapi_webhooks_example.py +0 -0
  38. {sweatstack-0.91.0 → sweatstack-0.93.0}/examples/send_webhook.py +0 -0
  39. {sweatstack-0.91.0 → sweatstack-0.93.0}/plans/001a_tests.md +0 -0
  40. {sweatstack-0.91.0 → sweatstack-0.93.0}/plans/001b_metadata.md +0 -0
  41. {sweatstack-0.91.0 → sweatstack-0.93.0}/plans/001c_dailies.md +0 -0
  42. {sweatstack-0.91.0 → sweatstack-0.93.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
  43. {sweatstack-0.91.0 → sweatstack-0.93.0}/plans/003_trace_test_linking.md +0 -0
  44. {sweatstack-0.91.0 → sweatstack-0.93.0}/plans/004_codebase_hygiene.md +0 -0
  45. {sweatstack-0.91.0 → sweatstack-0.93.0}/plans/005_ost_sport_bridge.md +0 -0
  46. {sweatstack-0.91.0 → sweatstack-0.93.0}/plans/006_output_backends.md +0 -0
  47. {sweatstack-0.91.0 → sweatstack-0.93.0}/plans/007_account_status_userinfo_portal.md +0 -0
  48. {sweatstack-0.91.0 → sweatstack-0.93.0}/plans/008_sdk_documentation.md +0 -0
  49. {sweatstack-0.91.0 → sweatstack-0.93.0}/plans/009_road_to_1_0.md +0 -0
  50. {sweatstack-0.91.0 → sweatstack-0.93.0}/skills/sweatstack-python/SKILL.md +0 -0
  51. {sweatstack-0.91.0 → sweatstack-0.93.0}/skills/sweatstack-python/fastapi.md +0 -0
  52. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/__init__.py +0 -0
  53. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/_docs.py +0 -0
  54. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/_frames.py +0 -0
  55. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/_renames.py +0 -0
  56. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/_transport.py +0 -0
  57. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/cli.py +0 -0
  58. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/constants.py +0 -0
  59. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/exceptions.py +0 -0
  60. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/fastapi/__init__.py +0 -0
  61. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
  62. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/fastapi/config.py +0 -0
  63. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/fastapi/dependencies.py +0 -0
  64. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/fastapi/models.py +0 -0
  65. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/fastapi/routes.py +0 -0
  66. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/fastapi/session.py +0 -0
  67. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/fastapi/token_stores.py +0 -0
  68. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/fastapi/webhooks.py +0 -0
  69. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/py.typed +0 -0
  70. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/__init__.py +0 -0
  71. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/_app_metadata.py +0 -0
  72. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/_base.py +0 -0
  73. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/activities.py +0 -0
  74. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/dailies.py +0 -0
  75. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/teams.py +0 -0
  76. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/tests.py +0 -0
  77. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/traces.py +0 -0
  78. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/resources/users.py +0 -0
  79. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/streamlit.py +0 -0
  80. {sweatstack-0.91.0 → sweatstack-0.93.0}/src/sweatstack/utils.py +0 -0
  81. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/__init__.py +0 -0
  82. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_access_token_cache.py +0 -0
  83. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_dailies.py +0 -0
  84. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_docs_check.py +0 -0
  85. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_docstrings.py +0 -0
  86. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_dtype_conversion.py +0 -0
  87. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_exceptions.py +0 -0
  88. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_frames.py +0 -0
  89. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_longitudinal_mean_max_after.py +0 -0
  90. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_metadata.py +0 -0
  91. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_output.py +0 -0
  92. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_public_hygiene.py +0 -0
  93. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_public_surface.py +0 -0
  94. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_renames.py +0 -0
  95. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_resources.py +0 -0
  96. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_segmentation.py +0 -0
  97. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_skill.py +0 -0
  98. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_sport_ost.py +0 -0
  99. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_teams.py +0 -0
  100. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_tests.py +0 -0
  101. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_trace_test_linking.py +0 -0
  102. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_webhooks.py +0 -0
  103. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_wire_names.py +0 -0
  104. {sweatstack-0.91.0 → sweatstack-0.93.0}/tests/test_write_timezone_validation.py +0 -0
  105. {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()`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sweatstack
3
- Version: 0.91.0
3
+ Version: 0.93.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.93.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).
@@ -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 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.
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-05T10:01:09+00:00
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: str | None = Field(None, title='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: CapabilityStatus
618
+ status: IssueStatus
630
619
  message: str = Field(..., title='Message')
631
- action_url: str | None = Field(None, title='Action Url')
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.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`` 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 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())
@@ -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 there is nothing to tell the user, otherwise
28
- ``{code, status, message, action_url}``. Branch on ``issue.status``
29
- (:class:`CapabilityStatus`: ``ready``, ``syncing``, ``action_required``,
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``.
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`. Codes and capability keys are
35
- open sets: a value the client doesn't know parses as a pseudo-member; ignore it.
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 ``action_url=None``.
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.action_url)
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 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.
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 five payloads the public docs prescribe for mocking
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
- "action_url": "https://app.sweatstack.no/portal/integrations?app=x",
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
- "action_url": None,
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
- "action_url": "https://app.sweatstack.no/portal/integrations?app=x",
52
+ "destination": "manage-integrations",
50
53
  }
51
54
  },
52
- "permanently_limited": {
55
+ "blocked_upstream": {
53
56
  "issue": {
54
- "code": "dailies_unavailable",
55
- "status": "unavailable",
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
- "action_url": None,
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 == CapabilityStatus(payload["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
- assert user.issue.action_url == payload["issue"]["action_url"]
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.action_url))
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
- "https://app.sweatstack.no/portal/integrations?app=x",
143
+ PortalDestination.manage_integrations,
132
144
  )
133
145
  ]
134
146
 
135
- def test_action_url_absent_means_no_button(self):
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.action_url is None
149
+ assert user.issue is not None and user.issue.destination is None
138
150
 
139
151
 
140
152
  class TestOpenSets:
141
- """Codes and capability keys are open sets by contract; the status set is closed."""
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
- "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.93.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