sweatstack 0.75.0__tar.gz → 0.76.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 (57) hide show
  1. {sweatstack-0.75.0 → sweatstack-0.76.0}/.claude/skills/sweatstack-python/SKILL.md +3 -3
  2. {sweatstack-0.75.0 → sweatstack-0.76.0}/.claude/skills/sweatstack-python/client.md +36 -0
  3. {sweatstack-0.75.0 → sweatstack-0.76.0}/CHANGELOG.md +16 -0
  4. {sweatstack-0.75.0 → sweatstack-0.76.0}/PKG-INFO +1 -1
  5. sweatstack-0.76.0/plans/002_TYPED_EXCEPTIONS.md +269 -0
  6. {sweatstack-0.75.0 → sweatstack-0.76.0}/pyproject.toml +1 -1
  7. sweatstack-0.76.0/src/sweatstack/__init__.py +12 -0
  8. {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/client.py +67 -67
  9. sweatstack-0.76.0/src/sweatstack/exceptions.py +67 -0
  10. {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/fastapi/routes.py +24 -13
  11. {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/openapi_schemas.py +5 -5
  12. {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/streamlit.py +8 -4
  13. sweatstack-0.76.0/tests/test_exceptions.py +312 -0
  14. {sweatstack-0.75.0 → sweatstack-0.76.0}/uv.lock +1 -1
  15. sweatstack-0.75.0/src/sweatstack/__init__.py +0 -1
  16. {sweatstack-0.75.0 → sweatstack-0.76.0}/.claude/settings.local.json +0 -0
  17. {sweatstack-0.75.0 → sweatstack-0.76.0}/.claude/skills/sweatstack-python/data-models.md +0 -0
  18. {sweatstack-0.75.0 → sweatstack-0.76.0}/.claude/skills/sweatstack-python/fastapi.md +0 -0
  19. {sweatstack-0.75.0 → sweatstack-0.76.0}/.claude/skills/sweatstack-python/streamlit.md +0 -0
  20. {sweatstack-0.75.0 → sweatstack-0.76.0}/.gitignore +0 -0
  21. {sweatstack-0.75.0 → sweatstack-0.76.0}/.python-version +0 -0
  22. {sweatstack-0.75.0 → sweatstack-0.76.0}/CONTRIBUTING.md +0 -0
  23. {sweatstack-0.75.0 → sweatstack-0.76.0}/DEVELOPMENT.md +0 -0
  24. {sweatstack-0.75.0 → sweatstack-0.76.0}/LICENSE +0 -0
  25. {sweatstack-0.75.0 → sweatstack-0.76.0}/Makefile +0 -0
  26. {sweatstack-0.75.0 → sweatstack-0.76.0}/README.md +0 -0
  27. {sweatstack-0.75.0 → sweatstack-0.76.0}/docs/conf.py +0 -0
  28. {sweatstack-0.75.0 → sweatstack-0.76.0}/docs/everything.rst +0 -0
  29. {sweatstack-0.75.0 → sweatstack-0.76.0}/docs/index.rst +0 -0
  30. {sweatstack-0.75.0 → sweatstack-0.76.0}/examples/fastapi_webhooks_example.py +0 -0
  31. {sweatstack-0.75.0 → sweatstack-0.76.0}/examples/send_webhook.py +0 -0
  32. {sweatstack-0.75.0 → sweatstack-0.76.0}/plans/001a_tests.md +0 -0
  33. {sweatstack-0.75.0 → sweatstack-0.76.0}/plans/001b_metadata.md +0 -0
  34. {sweatstack-0.75.0 → sweatstack-0.76.0}/plans/001c_dailies.md +0 -0
  35. {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
  36. {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/cli.py +0 -0
  37. {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/constants.py +0 -0
  38. {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/fastapi/__init__.py +0 -0
  39. {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/fastapi/config.py +0 -0
  40. {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/fastapi/dependencies.py +0 -0
  41. {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/fastapi/models.py +0 -0
  42. {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/fastapi/session.py +0 -0
  43. {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/fastapi/token_stores.py +0 -0
  44. {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/fastapi/webhooks.py +0 -0
  45. {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/ipython_init.py +0 -0
  46. {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
  47. {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/py.typed +0 -0
  48. {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/schemas.py +0 -0
  49. {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/sweatshell.py +0 -0
  50. {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/utils.py +0 -0
  51. {sweatstack-0.75.0 → sweatstack-0.76.0}/tests/__init__.py +0 -0
  52. {sweatstack-0.75.0 → sweatstack-0.76.0}/tests/test_dailies.py +0 -0
  53. {sweatstack-0.75.0 → sweatstack-0.76.0}/tests/test_dtype_conversion.py +0 -0
  54. {sweatstack-0.75.0 → sweatstack-0.76.0}/tests/test_metadata.py +0 -0
  55. {sweatstack-0.75.0 → sweatstack-0.76.0}/tests/test_teams.py +0 -0
  56. {sweatstack-0.75.0 → sweatstack-0.76.0}/tests/test_tests.py +0 -0
  57. {sweatstack-0.75.0 → sweatstack-0.76.0}/tests/test_webhooks.py +0 -0
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: sweatstack-python
3
3
  description: >
4
- Builds Python applications using the SweatStack client library (pip install sweatstack).
4
+ Builds Python applications using the SweatStack client library (uv add sweatstack).
5
5
  Covers authentication, activity and trace data retrieval, pandas DataFrames, Streamlit
6
6
  dashboards, FastAPI backends, user delegation, teams, and file uploads. Use when writing
7
7
  Python scripts, notebooks, Streamlit apps, or FastAPI services that access SweatStack
@@ -13,9 +13,9 @@ description: >
13
13
 
14
14
  Python client library for the SweatStack sports data platform.
15
15
 
16
- **Install:** `pip install sweatstack`
16
+ **Install:** `uv add sweatstack`
17
17
 
18
- **Extras:** `pip install sweatstack[streamlit]` · `pip install sweatstack[fastapi]`
18
+ **Extras:** `uv add sweatstack[streamlit]` · `uv add sweatstack[fastapi]`
19
19
 
20
20
  ## Quick Start
21
21
 
@@ -381,6 +381,42 @@ sweatstack.authenticate()
381
381
  df = sweatstack.get_latest_activity_data()
382
382
  ```
383
383
 
384
+ ## Error Handling
385
+
386
+ All errors raised by the library are subclasses of `SweatStackError`. Consumers never need to import `httpx`.
387
+
388
+ ```python
389
+ from sweatstack import (
390
+ SweatStackError, # base — catch-all
391
+ SweatStackConnectionError, # DNS, timeout, connection refused
392
+ SweatStackTokenRefreshError,# token refresh failed (expired/missing)
393
+ SweatStackAPIError, # HTTP error response (has status_code, url, method, body, request_id)
394
+ SweatStackAuthError, # 401, 403
395
+ SweatStackNotFoundError, # 404
396
+ SweatStackRateLimitError, # 429 (has retry_after)
397
+ SweatStackBadRequestError, # other 4xx
398
+ SweatStackServerError, # 5xx
399
+ )
400
+ ```
401
+
402
+ Typical usage:
403
+
404
+ ```python
405
+ try:
406
+ activity = client.get_activity("nonexistent_id")
407
+ except SweatStackNotFoundError:
408
+ print("Activity doesn't exist")
409
+ except SweatStackServerError as e:
410
+ print(f"Server error (transient): {e.status_code}")
411
+ except SweatStackAPIError as e:
412
+ print(f"API error: {e.status_code} {e.body}")
413
+ ```
414
+
415
+ Hierarchy:
416
+ - `SweatStackConnectionError`, `SweatStackTokenRefreshError`, and `SweatStackAPIError` are all direct children of `SweatStackError`.
417
+ - All HTTP-response errors (`SweatStackAuthError`, `SweatStackNotFoundError`, etc.) are subclasses of `SweatStackAPIError` and carry `status_code`, `url`, `method`, `request_id`, and `body` attributes.
418
+ - `SweatStackRateLimitError` additionally has `retry_after: int | None`.
419
+
384
420
  ## Gotchas
385
421
 
386
422
  - **Sport enum uses underscores:** `Sport.cycling_road`, not `Sport("road")` or `Sport.cycling.road`. String values use dots: `"cycling.road"`.
@@ -6,6 +6,22 @@ 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.76.0] - 2026-04-27
10
+
11
+ ### Added
12
+ - Typed exception hierarchy (`sweatstack.exceptions`). All API errors now raise specific exception types: `SweatStackAuthError` (401/403), `SweatStackNotFoundError` (404), `SweatStackRateLimitError` (429), `SweatStackBadRequestError` (other 4xx), `SweatStackServerError` (5xx). Transport failures raise `SweatStackConnectionError`.
13
+ - Structured error metadata on all API exceptions: `status_code`, `url`, `method`, `request_id`, `body`.
14
+
15
+ ### Changed
16
+ - **BREAKING:** All client methods now raise `SweatStackAPIError` subclasses instead of `httpx.HTTPStatusError`. Code that catches `httpx.HTTPStatusError` must switch to catching `SweatStackAPIError` (or a specific subclass).
17
+ - **BREAKING:** `TokenRefreshError` renamed to `SweatStackTokenRefreshError` and moved to `sweatstack.exceptions`. Import path changed from `from sweatstack import TokenRefreshError` to `from sweatstack import SweatStackTokenRefreshError`.
18
+ - **BREAKING:** 422 responses now raise `SweatStackBadRequestError` instead of `ValueError`.
19
+ - Transport errors (DNS, timeouts, connection refused) now raise `SweatStackConnectionError` instead of leaking raw httpx exceptions.
20
+
21
+ ### Removed
22
+ - `httpx` is no longer part of the public error surface. Consumers do not need to import `httpx` to handle errors.
23
+
24
+
9
25
  ## [0.75.0] - 2026-04-23
10
26
 
11
27
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sweatstack
3
- Version: 0.75.0
3
+ Version: 0.76.0
4
4
  Summary: The official Python client for SweatStack
5
5
  Project-URL: Homepage, https://sweatstack.no
6
6
  Project-URL: Documentation, https://developer.sweatstack.no/getting-started/
@@ -0,0 +1,269 @@
1
+ # Typed exception hierarchy for the SweatStack client
2
+
3
+ ## Context
4
+
5
+ The `sweatstack` Python client currently surfaces `httpx.HTTPStatusError`
6
+ directly to consumers via `Client._raise_for_status` /
7
+ `Client._print_response_and_raise` (`src/sweatstack/client.py:968-997`).
8
+
9
+ This has two problems:
10
+
11
+ 1. **Consumers can't branch on error kind without inspecting status codes.**
12
+ Telling apart "upstream is down" (5xx, transient) from "you sent bad input"
13
+ (4xx, your bug) requires `exc.response.status_code`. That's the leaky
14
+ abstraction a client library exists to absorb.
15
+ 2. **`httpx` is an implementation detail.** Any consumer that wants to handle
16
+ errors must `import httpx` to catch `HTTPStatusError`. If the transport
17
+ ever changes, every consumer breaks.
18
+
19
+ ## Exception hierarchy
20
+
21
+ ```
22
+ SweatStackError # base -- catch-all for anything sweatstack-related
23
+ ├── SweatStackConnectionError # transport-level: DNS, timeout, no response
24
+ ├── SweatStackTokenRefreshError # client-side auth failure (before any request)
25
+ └── SweatStackAPIError # HTTP response with error status
26
+ ├── SweatStackAuthError # 401, 403
27
+ ├── SweatStackNotFoundError # 404
28
+ ├── SweatStackRateLimitError # 429 -- exposes retry_after
29
+ ├── SweatStackBadRequestError # other 4xx
30
+ └── SweatStackServerError # 5xx
31
+ ```
32
+
33
+ `SweatStackTokenRefreshError` is a direct child of `SweatStackError`, not of
34
+ `SweatStackAPIError`. Token refresh is a client-side operation that happens
35
+ before a request is made -- it has no status code, no URL, no method. Forcing
36
+ it under `SweatStackAPIError` would violate Liskov: every `SweatStackAPIError`
37
+ guarantees `status_code: int`, and a token refresh failure can't provide one.
38
+
39
+ ### Attributes on `SweatStackAPIError`
40
+
41
+ ```python
42
+ class SweatStackAPIError(SweatStackError):
43
+ status_code: int
44
+ url: str
45
+ method: str
46
+ request_id: str | None # from X-Request-ID header, if present
47
+ body: dict | str | None # parsed JSON if possible, else raw text, else None
48
+ ```
49
+
50
+ `SweatStackRateLimitError` adds `retry_after: int | None` (seconds, from
51
+ `Retry-After` header).
52
+
53
+ `SweatStackConnectionError` and `SweatStackTokenRefreshError` take only a
54
+ `message: str`.
55
+
56
+ ### Why these classes
57
+
58
+ | Class | Reason |
59
+ |---|---|
60
+ | `SweatStackServerError` | The most important new class. `except SweatStackServerError` handles "upstream is unhappy" without catching consumer bugs. |
61
+ | `SweatStackBadRequestError` | Separate from server errors: don't retry, surface to developers. Different remediation = different class. |
62
+ | `SweatStackNotFoundError` | 404 is often domain-meaningful ("trace was deleted"), not a bug. Consumers want to handle it as a business case. |
63
+ | `SweatStackRateLimitError` | Reserves the class even if the API isn't rate-limited today. Zero cost, prevents future refactoring. |
64
+ | `SweatStackConnectionError` | Covers `httpx.ConnectError`, `httpx.ReadTimeout`, etc. Currently these leak through as raw httpx types. |
65
+ | `SweatStackAuthError` | Groups 401/403 HTTP responses. |
66
+ | `SweatStackTokenRefreshError` | Client-side auth failure. Sibling of `SweatStackAPIError`, not child -- no HTTP response data to carry. |
67
+
68
+ ## Implementation
69
+
70
+ ### 1. New module: `src/sweatstack/exceptions.py`
71
+
72
+ Define the full hierarchy. Pure module -- no imports from `client.py`, no
73
+ `httpx`. This is the public error contract.
74
+
75
+ ```python
76
+ class SweatStackError(Exception):
77
+ """Base exception for all SweatStack errors."""
78
+
79
+ class SweatStackConnectionError(SweatStackError):
80
+ """Transport-level failure: DNS, connect timeout, read timeout, no response."""
81
+
82
+ class SweatStackTokenRefreshError(SweatStackError):
83
+ """Token refresh failed (expired, missing, or refresh request failed)."""
84
+
85
+ class SweatStackAPIError(SweatStackError):
86
+ """HTTP response with an error status code."""
87
+ def __init__(self, *, status_code, url, method, request_id=None, body=None):
88
+ self.status_code = status_code
89
+ self.url = url
90
+ self.method = method
91
+ self.request_id = request_id
92
+ self.body = body
93
+ super().__init__(self._format_message())
94
+
95
+ def _format_message(self):
96
+ msg = f"{self.status_code}: {self.method} {self.url}"
97
+ if self.request_id:
98
+ msg += f" (request_id={self.request_id})"
99
+ if self.body:
100
+ msg += f" — {self.body}"
101
+ return msg
102
+
103
+ def __repr__(self):
104
+ return (
105
+ f"{type(self).__name__}(status_code={self.status_code!r}, "
106
+ f"method={self.method!r}, url={self.url!r})"
107
+ )
108
+
109
+ class SweatStackAuthError(SweatStackAPIError):
110
+ """401 or 403: authentication or authorization failure."""
111
+
112
+ class SweatStackNotFoundError(SweatStackAPIError):
113
+ """404: resource not found."""
114
+
115
+ class SweatStackRateLimitError(SweatStackAPIError):
116
+ """429: rate limited."""
117
+ def __init__(self, *, retry_after=None, **kwargs):
118
+ self.retry_after = retry_after
119
+ super().__init__(**kwargs)
120
+
121
+ class SweatStackBadRequestError(SweatStackAPIError):
122
+ """4xx (not 401, 403, 404, 429): client sent invalid input."""
123
+
124
+ class SweatStackServerError(SweatStackAPIError):
125
+ """5xx: server-side failure, transient, safe to retry for idempotent ops."""
126
+ ```
127
+
128
+ ### 2. Translation in `client.py`
129
+
130
+ Replace `_raise_for_status` and `_print_response_and_raise` with a single
131
+ translation point:
132
+
133
+ ```python
134
+ def _raise_for_status(self, response: httpx.Response) -> None:
135
+ if response.is_success:
136
+ return
137
+
138
+ status = response.status_code
139
+ body = self._parse_error_body(response)
140
+ request_id = response.headers.get("x-request-id")
141
+ common = dict(
142
+ status_code=status,
143
+ url=str(response.request.url),
144
+ method=response.request.method,
145
+ request_id=request_id,
146
+ body=body,
147
+ )
148
+
149
+ if status in (401, 403):
150
+ raise SweatStackAuthError(**common)
151
+ if status == 404:
152
+ raise SweatStackNotFoundError(**common)
153
+ if status == 429:
154
+ retry_after = int(response.headers.get("retry-after", "0")) or None
155
+ raise SweatStackRateLimitError(retry_after=retry_after, **common)
156
+ if 400 <= status < 500:
157
+ raise SweatStackBadRequestError(**common)
158
+ if 500 <= status < 600:
159
+ raise SweatStackServerError(**common)
160
+ # Fallback (e.g. 3xx that wasn't followed) -- shouldn't happen
161
+ raise SweatStackAPIError(**common)
162
+
163
+ @staticmethod
164
+ def _parse_error_body(response: httpx.Response) -> dict | str | None:
165
+ try:
166
+ return response.json()
167
+ except Exception:
168
+ text = response.text
169
+ return text if text else None
170
+ ```
171
+
172
+ Wrap transport-level errors at the HTTP call site:
173
+
174
+ ```python
175
+ try:
176
+ response = self._http_client.request(...)
177
+ except httpx.HTTPError as exc:
178
+ raise SweatStackConnectionError(str(exc)) from exc
179
+ self._raise_for_status(response)
180
+ ```
181
+
182
+ ### 3. Remove `_print_response_and_raise` and `_add_note`
183
+
184
+ These exist only to annotate `httpx.HTTPStatusError` with response text.
185
+ The new exceptions carry `body` natively, so these methods are dead code.
186
+
187
+ ### 4. Clean up the 422 special case
188
+
189
+ The current code raises `ValueError(response.json())` for 422. This should
190
+ raise `SweatStackBadRequestError` like any other 4xx. The parsed validation
191
+ error body lands in `body` where consumers can inspect it.
192
+
193
+ ### 5. Clean up the 401/Streamlit special case
194
+
195
+ `_raise_for_status` currently detects Streamlit and adds a hint note on 401.
196
+ After this change, it raises `SweatStackAuthError`. The Streamlit hint (if
197
+ still wanted) moves to the Streamlit integration layer, not the core error
198
+ path.
199
+
200
+ ### 6. Move `TokenRefreshError` to `exceptions.py`
201
+
202
+ - Rename to `SweatStackTokenRefreshError`.
203
+ - Parent is `SweatStackError` (not `SweatStackAPIError`).
204
+ - Remove old definition from `client.py`.
205
+ - Import in `client.py` from `exceptions.py`.
206
+
207
+ ### 7. Update `__init__.py` exports
208
+
209
+ ```python
210
+ from .client import *
211
+ from .exceptions import (
212
+ SweatStackError,
213
+ SweatStackConnectionError,
214
+ SweatStackTokenRefreshError,
215
+ SweatStackAPIError,
216
+ SweatStackAuthError,
217
+ SweatStackNotFoundError,
218
+ SweatStackRateLimitError,
219
+ SweatStackBadRequestError,
220
+ SweatStackServerError,
221
+ )
222
+ ```
223
+
224
+ ### 8. Tests
225
+
226
+ Unit tests covering each status branch (401, 403, 404, 429, 400, 422, 500,
227
+ 502, 503, network error). Each test asserts:
228
+ - Correct exception type raised
229
+ - `status_code`, `url`, `method`, `body` populated correctly
230
+ - `request_id` populated when header present
231
+ - `retry_after` populated for 429
232
+ - Inheritance: e.g. `SweatStackServerError` is caught by
233
+ `except SweatStackAPIError` and `except SweatStackError`
234
+ - `__repr__` produces structured output suitable for logging
235
+
236
+ ## Backwards incompatibilities
237
+
238
+ Since no external consumers exist yet, these are listed for completeness and
239
+ to inform the changelog for the first stable release.
240
+
241
+ | What changed | Before | After |
242
+ |---|---|---|
243
+ | HTTP error exceptions | `httpx.HTTPStatusError` | `SweatStackAPIError` subclasses |
244
+ | 422 responses | `ValueError(response.json())` | `SweatStackBadRequestError` |
245
+ | Transport errors (DNS, timeout) | Raw `httpx.ConnectError`, `httpx.ReadTimeout`, etc. | `SweatStackConnectionError` |
246
+ | `TokenRefreshError` class name | `TokenRefreshError` | `SweatStackTokenRefreshError` |
247
+ | `TokenRefreshError` base class | `Exception` | `SweatStackError` |
248
+ | `TokenRefreshError` import path | `from sweatstack import TokenRefreshError` or `from sweatstack.client import TokenRefreshError` | `from sweatstack import SweatStackTokenRefreshError` or `from sweatstack.exceptions import SweatStackTokenRefreshError` |
249
+ | 401 + Streamlit hint | Appended as exception note on `httpx.HTTPStatusError` | `SweatStackAuthError` raised; Streamlit hint moves to Streamlit integration layer |
250
+ | `_print_response_and_raise` | Public-ish method on `Client` | Removed |
251
+ | `_add_note` | Helper on `Client` | Removed |
252
+
253
+ ## Out of scope
254
+
255
+ - **Built-in retry on 5xx / 429.** Clean follow-up once typed exceptions exist.
256
+ - **Idempotency keys on POST.** Requires API-side support.
257
+ - **Native async client.** Separate effort.
258
+
259
+ ## Acceptance criteria
260
+
261
+ - All public client methods that previously raised `httpx.HTTPStatusError`
262
+ raise a `SweatStackAPIError` subclass appropriate to the status code.
263
+ - All transport-level failures raise `SweatStackConnectionError`.
264
+ - `httpx` types never appear in the public API surface.
265
+ - `SweatStackTokenRefreshError` is importable from `sweatstack.exceptions`
266
+ and `sweatstack`.
267
+ - Unit tests cover each status branch and verify exception type, attributes,
268
+ and inheritance.
269
+ - All `SweatStackAPIError` instances have a useful `__repr__` for debugging.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sweatstack"
3
- version = "0.75.0"
3
+ version = "0.76.0"
4
4
  description = "The official Python client for SweatStack"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,12 @@
1
+ from .client import * # noqa: F403
2
+ from .exceptions import (
3
+ SweatStackError,
4
+ SweatStackConnectionError,
5
+ SweatStackTokenRefreshError,
6
+ SweatStackAPIError,
7
+ SweatStackAuthError,
8
+ SweatStackNotFoundError,
9
+ SweatStackRateLimitError,
10
+ SweatStackBadRequestError,
11
+ SweatStackServerError,
12
+ )
@@ -28,6 +28,16 @@ import pandas as pd
28
28
  from platformdirs import user_cache_dir, user_data_dir
29
29
 
30
30
  from .constants import DEFAULT_URL
31
+ from .exceptions import (
32
+ SweatStackAPIError,
33
+ SweatStackAuthError,
34
+ SweatStackBadRequestError,
35
+ SweatStackConnectionError,
36
+ SweatStackNotFoundError,
37
+ SweatStackRateLimitError,
38
+ SweatStackServerError,
39
+ SweatStackTokenRefreshError,
40
+ )
31
41
  from .schemas import (
32
42
  ActivityDetails, ActivitySummary, ApplicationMemberRole, AuthorizedTeamResponse,
33
43
  BackfillStatus, DailyMeasure, DailyResponse,
@@ -56,16 +66,6 @@ def enable_cache(path: str | None = None) -> None:
56
66
  _cache_config = {"path": path}
57
67
 
58
68
 
59
- class TokenRefreshError(Exception):
60
- """Raised when automatic token refresh fails.
61
-
62
- This can happen when:
63
- - The refresh token is missing
64
- - The refresh token has expired
65
- - The token refresh request fails
66
- """
67
-
68
- pass
69
69
 
70
70
 
71
71
  AUTH_SUCCESSFUL_RESPONSE = """<!DOCTYPE html>
@@ -340,15 +340,15 @@ class _OAuth2Mixin:
340
340
  if redirect_uri:
341
341
  token_data["redirect_uri"] = redirect_uri
342
342
 
343
- response = httpx.post(
344
- f"{self.url}/api/v1/oauth/token",
345
- data=token_data,
346
- )
347
-
348
343
  try:
349
- self._raise_for_status(response)
350
- except httpx.HTTPStatusError as e:
351
- raise Exception(f"Token exchange failed: {e}") from e
344
+ response = httpx.post(
345
+ f"{self.url}/api/v1/oauth/token",
346
+ data=token_data,
347
+ )
348
+ except httpx.HTTPError as exc:
349
+ raise SweatStackConnectionError(str(exc)) from exc
350
+
351
+ self._raise_for_status(response)
352
352
 
353
353
  token_response = TokenResponse.model_validate(response.json())
354
354
 
@@ -773,7 +773,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
773
773
  New access token string.
774
774
 
775
775
  Raises:
776
- TokenRefreshError: If the refresh request fails.
776
+ SweatStackTokenRefreshError: If the refresh request fails.
777
777
  """
778
778
  with self._http_client(skip_token_check=True) as client:
779
779
  response = client.post(
@@ -789,8 +789,8 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
789
789
 
790
790
  try:
791
791
  self._raise_for_status(response)
792
- except httpx.HTTPStatusError as e:
793
- raise TokenRefreshError(f"Token refresh request failed: {e}") from e
792
+ except SweatStackAPIError as e:
793
+ raise SweatStackTokenRefreshError(f"Token refresh request failed: {e}") from e
794
794
 
795
795
  return response.json()["access_token"]
796
796
 
@@ -805,16 +805,16 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
805
805
  Valid access token (original if not expired, refreshed otherwise).
806
806
 
807
807
  Raises:
808
- TokenRefreshError: If the token is expired and refresh fails.
808
+ SweatStackTokenRefreshError: If the token is expired and refresh fails.
809
809
  """
810
810
  try:
811
811
  payload = decode_jwt_body(access_token)
812
812
  except Exception as e:
813
- raise TokenRefreshError(f"Invalid access token: {e}") from e
813
+ raise SweatStackTokenRefreshError(f"Invalid access token: {e}") from e
814
814
 
815
815
  expires_at = payload.get("exp")
816
816
  if expires_at is None:
817
- raise TokenRefreshError("Access token missing 'exp' claim")
817
+ raise SweatStackTokenRefreshError("Access token missing 'exp' claim")
818
818
 
819
819
  is_expired = expires_at - TOKEN_EXPIRY_MARGIN_SECONDS < time.time()
820
820
  if not is_expired:
@@ -822,7 +822,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
822
822
 
823
823
  # Token needs refresh
824
824
  if refresh_token is None:
825
- raise TokenRefreshError(
825
+ raise SweatStackTokenRefreshError(
826
826
  "Access token expired but no refresh token available. "
827
827
  "Call client.authenticate(force=True) to re-authenticate."
828
828
  )
@@ -850,7 +850,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
850
850
  SecretStr containing the access token, or None if not authenticated.
851
851
 
852
852
  Raises:
853
- TokenRefreshError: If the token is expired and refresh fails.
853
+ SweatStackTokenRefreshError: If the token is expired and refresh fails.
854
854
  """
855
855
  access_token, refresh_token = self._load_token_pair()
856
856
 
@@ -934,6 +934,9 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
934
934
  """
935
935
  Creates an httpx client with the base URL and authentication headers pre-configured.
936
936
 
937
+ Transport-level errors (DNS, timeouts, connection refused) are caught and
938
+ re-raised as SweatStackConnectionError so consumers never see raw httpx types.
939
+
937
940
  Args:
938
941
  skip_token_check: If True, uses the raw _api_key without triggering token expiry check.
939
942
  This prevents recursive token refresh attempts.
@@ -951,50 +954,47 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
951
954
  if token:
952
955
  headers["Authorization"] = f"Bearer {token.get_secret_value()}"
953
956
 
954
- with httpx.Client(base_url=self.url, headers=headers, timeout=60) as client:
955
- yield client
957
+ try:
958
+ with httpx.Client(base_url=self.url, headers=headers, timeout=60) as client:
959
+ yield client
960
+ except httpx.HTTPError as exc:
961
+ raise SweatStackConnectionError(str(exc)) from exc
962
+
963
+ def _raise_for_status(self, response: httpx.Response) -> None:
964
+ if response.is_success:
965
+ return
966
+
967
+ status = response.status_code
968
+ body = self._parse_error_body(response)
969
+ request_id = response.headers.get("x-request-id")
970
+ common = dict(
971
+ status_code=status,
972
+ url=str(response.request.url),
973
+ method=response.request.method,
974
+ request_id=request_id,
975
+ body=body,
976
+ )
956
977
 
957
- @staticmethod
958
- def _add_note(exception: Exception, note: str):
959
- """Add a note to an exception, compatible with Python <3.11."""
960
- if hasattr(exception, "add_note"):
961
- exception.add_note(note)
962
- else:
963
- if not exception.args:
964
- exception.args = (note,)
965
- else:
966
- exception.args = (f"{exception.args[0]}\n{note}",) + exception.args[1:]
978
+ if status in (401, 403):
979
+ raise SweatStackAuthError(**common)
980
+ if status == 404:
981
+ raise SweatStackNotFoundError(**common)
982
+ if status == 429:
983
+ retry_after = int(response.headers.get("retry-after", "0")) or None
984
+ raise SweatStackRateLimitError(retry_after=retry_after, **common)
985
+ if 400 <= status < 500:
986
+ raise SweatStackBadRequestError(**common)
987
+ if 500 <= status < 600:
988
+ raise SweatStackServerError(**common)
989
+ raise SweatStackAPIError(**common)
967
990
 
968
- def _print_response_and_raise(self, response: httpx.Response):
991
+ @staticmethod
992
+ def _parse_error_body(response: httpx.Response) -> dict | str | None:
969
993
  try:
970
- response.raise_for_status()
971
- except httpx.HTTPStatusError as exception:
972
- additional_info = response.text
973
- self._add_note(exception, additional_info)
974
- raise exception
975
-
976
- def _raise_for_status(self, response: httpx.Response):
977
- if response.status_code == 422:
978
- raise ValueError(response.json())
979
- elif response.status_code == 401:
980
- try:
981
- import streamlit
982
- except ImportError:
983
- self._print_response_and_raise(response)
984
- else:
985
- try:
986
- response.raise_for_status()
987
- except Exception as exception:
988
- if not self.streamlit_compatible:
989
- streamlit_error_message = (
990
- "\nStreamlit environment detected. Use StreamlitAuth.client instance.\n"
991
- "Docs: https://developer.sweatstack.no/learn/integrations/streamlit/"
992
- )
993
- self._add_note(exception, streamlit_error_message)
994
- raise
995
-
996
- else:
997
- self._print_response_and_raise(response)
994
+ return response.json()
995
+ except Exception:
996
+ text = response.text
997
+ return text if text else None
998
998
 
999
999
  def _enums_to_strings(self, values: list[Enum | str]) -> list[str]:
1000
1000
  return [value.value if isinstance(value, Enum) else value for value in values]
@@ -0,0 +1,67 @@
1
+ """Typed exception hierarchy for the SweatStack client.
2
+
3
+ This module defines all exceptions raised by the SweatStack library.
4
+ No httpx types are exposed — this is the public error contract.
5
+ """
6
+
7
+
8
+ class SweatStackError(Exception):
9
+ """Base exception for all SweatStack errors."""
10
+
11
+
12
+ class SweatStackConnectionError(SweatStackError):
13
+ """Transport-level failure: DNS, connect timeout, read timeout, no response."""
14
+
15
+
16
+ class SweatStackTokenRefreshError(SweatStackError):
17
+ """Token refresh failed (expired, missing, or refresh request failed)."""
18
+
19
+
20
+ class SweatStackAPIError(SweatStackError):
21
+ """HTTP response with an error status code."""
22
+
23
+ def __init__(self, *, status_code: int, url: str, method: str, request_id: str | None = None, body: dict | str | None = None):
24
+ self.status_code = status_code
25
+ self.url = url
26
+ self.method = method
27
+ self.request_id = request_id
28
+ self.body = body
29
+ super().__init__(self._format_message())
30
+
31
+ def _format_message(self) -> str:
32
+ msg = f"{self.status_code}: {self.method} {self.url}"
33
+ if self.request_id:
34
+ msg += f" (request_id={self.request_id})"
35
+ if self.body:
36
+ msg += f" — {self.body}"
37
+ return msg
38
+
39
+ def __repr__(self) -> str:
40
+ return (
41
+ f"{type(self).__name__}(status_code={self.status_code!r}, "
42
+ f"method={self.method!r}, url={self.url!r})"
43
+ )
44
+
45
+
46
+ class SweatStackAuthError(SweatStackAPIError):
47
+ """401 or 403: authentication or authorization failure."""
48
+
49
+
50
+ class SweatStackNotFoundError(SweatStackAPIError):
51
+ """404: resource not found."""
52
+
53
+
54
+ class SweatStackRateLimitError(SweatStackAPIError):
55
+ """429: rate limited."""
56
+
57
+ def __init__(self, *, retry_after: int | None = None, **kwargs):
58
+ self.retry_after = retry_after
59
+ super().__init__(**kwargs)
60
+
61
+
62
+ class SweatStackBadRequestError(SweatStackAPIError):
63
+ """4xx (not 401, 403, 404, 429): client sent invalid input."""
64
+
65
+
66
+ class SweatStackServerError(SweatStackAPIError):
67
+ """5xx: server-side failure, transient, safe to retry for idempotent ops."""
@@ -13,6 +13,7 @@ from fastapi import APIRouter, FastAPI, HTTPException, Request, Response
13
13
  from fastapi.responses import RedirectResponse
14
14
 
15
15
  from ..constants import DEFAULT_URL
16
+ from ..exceptions import SweatStackAPIError, SweatStackConnectionError
16
17
  from ..utils import decode_jwt_body
17
18
  from .config import get_config
18
19
  from .dependencies import _extract_expiry
@@ -101,18 +102,27 @@ def _fetch_delegated_token(
101
102
  if team_id is not None:
102
103
  body["team_id"] = team_id
103
104
 
104
- response = httpx.post(
105
- f"{DEFAULT_URL}/api/v1/oauth/delegated-token",
106
- headers={"Authorization": f"Bearer {principal_tokens.access_token}"},
107
- json=body,
108
- )
105
+ try:
106
+ response = httpx.post(
107
+ f"{DEFAULT_URL}/api/v1/oauth/delegated-token",
108
+ headers={"Authorization": f"Bearer {principal_tokens.access_token}"},
109
+ json=body,
110
+ )
111
+ except httpx.HTTPError as exc:
112
+ raise SweatStackConnectionError(str(exc)) from exc
109
113
 
110
114
  if response.status_code == 403:
111
115
  raise HTTPException(status_code=403, detail="You don't have access to this user")
112
116
  if response.status_code == 404:
113
117
  raise HTTPException(status_code=404, detail="User not found")
114
118
 
115
- response.raise_for_status()
119
+ if not response.is_success:
120
+ raise SweatStackAPIError(
121
+ status_code=response.status_code,
122
+ url=str(response.request.url),
123
+ method=response.request.method,
124
+ body=response.text or None,
125
+ )
116
126
  tokens = response.json()
117
127
 
118
128
  return TokenSet(
@@ -213,15 +223,16 @@ def create_router() -> APIRouter:
213
223
  "redirect_uri": config.redirect_uri,
214
224
  },
215
225
  )
216
- token_response.raise_for_status()
217
- tokens = token_response.json()
218
- except httpx.HTTPStatusError as e:
219
- logger.error("Token exchange failed: %s - %s", e.response.status_code, e.response.text)
220
- return error_redirect("token_exchange_failed")
221
- except Exception as e:
226
+ except httpx.HTTPError as e:
222
227
  logger.error("Token exchange error: %s", e)
223
228
  return error_redirect("token_exchange_failed")
224
229
 
230
+ if not token_response.is_success:
231
+ logger.error("Token exchange failed: %s - %s", token_response.status_code, token_response.text)
232
+ return error_redirect("token_exchange_failed")
233
+
234
+ tokens = token_response.json()
235
+
225
236
  access_token = tokens.get("access_token")
226
237
  refresh_token = tokens.get("refresh_token")
227
238
 
@@ -299,7 +310,7 @@ def create_router() -> APIRouter:
299
310
  # Fetch delegated token for the target user
300
311
  try:
301
312
  delegated_tokens = _fetch_delegated_token(session.principal, user_id, team_id=team_id)
302
- except httpx.HTTPStatusError as e:
313
+ except SweatStackAPIError as e:
303
314
  logger.warning("Failed to fetch delegated token for user %s: %s", user_id, e)
304
315
  raise HTTPException(status_code=403, detail="You don't have access to this user")
305
316
 
@@ -1,10 +1,10 @@
1
1
  # generated by datamodel-codegen:
2
2
  # filename: openapi.json
3
- # timestamp: 2026-04-22T12:23:13+00:00
3
+ # timestamp: 2026-04-27T08:07:03+00:00
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
- from datetime import date as _Date, datetime, timedelta
7
+ from datetime import date, datetime, timedelta
8
8
  from enum import Enum
9
9
  from typing import Any, Dict, List, Literal, Optional, Union
10
10
 
@@ -783,7 +783,7 @@ class CoreTemperatureSummary(BaseModel):
783
783
 
784
784
 
785
785
  class DailyCreate(BaseModel):
786
- date: _Date = Field(..., title='Date')
786
+ date: date = Field(..., title='Date')
787
787
  value: float = Field(..., title='Value')
788
788
 
789
789
 
@@ -798,7 +798,7 @@ class DailyMeasure(Enum):
798
798
 
799
799
 
800
800
  class DailyResponse(BaseModel):
801
- date: _Date = Field(..., title='Date')
801
+ date: date = Field(..., title='Date')
802
802
  value: Optional[float] = Field(..., title='Value')
803
803
  source: str = Field(..., title='Source')
804
804
 
@@ -1297,7 +1297,7 @@ class BodyCreateCheckoutSessionApiV1PaymentStripeCheckoutPost(BaseModel):
1297
1297
 
1298
1298
  class BodyCreateDailyDataDailiesPost(BaseModel):
1299
1299
  measure: DailyMeasure
1300
- date: _Date = Field(..., title='Date')
1300
+ date: date = Field(..., title='Date')
1301
1301
  value: float = Field(..., title='Value')
1302
1302
 
1303
1303
 
@@ -40,6 +40,7 @@ import httpx
40
40
 
41
41
  from .client import Client
42
42
  from .constants import DEFAULT_URL
43
+ from .exceptions import SweatStackAuthError
43
44
  from .schemas import Metric, Scope, Sport
44
45
 
45
46
 
@@ -311,10 +312,13 @@ class StreamlitAuth:
311
312
  data=token_data,
312
313
  auth=auth,
313
314
  )
314
- try:
315
- response.raise_for_status()
316
- except httpx.HTTPStatusError as e:
317
- raise Exception(f"SweatStack Python login failed. Please try again.") from e
315
+ if not response.is_success:
316
+ raise SweatStackAuthError(
317
+ status_code=response.status_code,
318
+ url=str(response.request.url),
319
+ method=response.request.method,
320
+ body="SweatStack Python login failed. Please try again.",
321
+ )
318
322
  token_response = response.json()
319
323
 
320
324
  self._set_api_key(
@@ -0,0 +1,312 @@
1
+ """Tests for the SweatStack exception hierarchy.
2
+
3
+ Covers:
4
+ - Exception instantiation and attributes
5
+ - Inheritance relationships
6
+ - __str__ and __repr__ formatting
7
+ - _raise_for_status status code mapping
8
+ - Transport error wrapping
9
+ """
10
+
11
+ import httpx
12
+ import pytest
13
+ from unittest.mock import patch
14
+
15
+ from sweatstack import Client
16
+ from sweatstack.exceptions import (
17
+ SweatStackAPIError,
18
+ SweatStackAuthError,
19
+ SweatStackBadRequestError,
20
+ SweatStackConnectionError,
21
+ SweatStackError,
22
+ SweatStackNotFoundError,
23
+ SweatStackRateLimitError,
24
+ SweatStackServerError,
25
+ SweatStackTokenRefreshError,
26
+ )
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Hierarchy and inheritance
31
+ # ---------------------------------------------------------------------------
32
+
33
+
34
+ class TestHierarchy:
35
+ def test_api_error_is_sweatstack_error(self):
36
+ exc = SweatStackAPIError(status_code=500, url="http://x", method="GET")
37
+ assert isinstance(exc, SweatStackError)
38
+ assert isinstance(exc, Exception)
39
+
40
+ def test_server_error_is_api_error(self):
41
+ exc = SweatStackServerError(status_code=500, url="http://x", method="GET")
42
+ assert isinstance(exc, SweatStackAPIError)
43
+ assert isinstance(exc, SweatStackError)
44
+
45
+ def test_auth_error_is_api_error(self):
46
+ exc = SweatStackAuthError(status_code=401, url="http://x", method="GET")
47
+ assert isinstance(exc, SweatStackAPIError)
48
+
49
+ def test_not_found_error_is_api_error(self):
50
+ exc = SweatStackNotFoundError(status_code=404, url="http://x", method="GET")
51
+ assert isinstance(exc, SweatStackAPIError)
52
+
53
+ def test_rate_limit_error_is_api_error(self):
54
+ exc = SweatStackRateLimitError(status_code=429, url="http://x", method="GET")
55
+ assert isinstance(exc, SweatStackAPIError)
56
+
57
+ def test_bad_request_error_is_api_error(self):
58
+ exc = SweatStackBadRequestError(status_code=422, url="http://x", method="POST")
59
+ assert isinstance(exc, SweatStackAPIError)
60
+
61
+ def test_connection_error_is_sweatstack_error_not_api_error(self):
62
+ exc = SweatStackConnectionError("DNS failed")
63
+ assert isinstance(exc, SweatStackError)
64
+ assert not isinstance(exc, SweatStackAPIError)
65
+
66
+ def test_token_refresh_error_is_sweatstack_error_not_api_error(self):
67
+ exc = SweatStackTokenRefreshError("token expired")
68
+ assert isinstance(exc, SweatStackError)
69
+ assert not isinstance(exc, SweatStackAPIError)
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Attributes
74
+ # ---------------------------------------------------------------------------
75
+
76
+
77
+ class TestAttributes:
78
+ def test_api_error_attributes(self):
79
+ exc = SweatStackAPIError(
80
+ status_code=500,
81
+ url="https://api.sweatstack.no/api/v1/activities",
82
+ method="GET",
83
+ request_id="req-123",
84
+ body={"error": "internal"},
85
+ )
86
+ assert exc.status_code == 500
87
+ assert exc.url == "https://api.sweatstack.no/api/v1/activities"
88
+ assert exc.method == "GET"
89
+ assert exc.request_id == "req-123"
90
+ assert exc.body == {"error": "internal"}
91
+
92
+ def test_api_error_optional_attributes_default_to_none(self):
93
+ exc = SweatStackAPIError(status_code=400, url="http://x", method="POST")
94
+ assert exc.request_id is None
95
+ assert exc.body is None
96
+
97
+ def test_rate_limit_error_retry_after(self):
98
+ exc = SweatStackRateLimitError(
99
+ retry_after=30, status_code=429, url="http://x", method="GET"
100
+ )
101
+ assert exc.retry_after == 30
102
+ assert exc.status_code == 429
103
+
104
+ def test_rate_limit_error_retry_after_defaults_to_none(self):
105
+ exc = SweatStackRateLimitError(status_code=429, url="http://x", method="GET")
106
+ assert exc.retry_after is None
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Formatting
111
+ # ---------------------------------------------------------------------------
112
+
113
+
114
+ class TestFormatting:
115
+ def test_str_basic(self):
116
+ exc = SweatStackServerError(status_code=502, url="http://api/v1/x", method="GET")
117
+ assert str(exc) == "502: GET http://api/v1/x"
118
+
119
+ def test_str_with_request_id(self):
120
+ exc = SweatStackServerError(
121
+ status_code=503, url="http://api/v1/x", method="POST", request_id="abc-123"
122
+ )
123
+ assert "(request_id=abc-123)" in str(exc)
124
+
125
+ def test_str_with_body(self):
126
+ exc = SweatStackBadRequestError(
127
+ status_code=400, url="http://x", method="POST", body="bad input"
128
+ )
129
+ assert "bad input" in str(exc)
130
+
131
+ def test_repr(self):
132
+ exc = SweatStackServerError(status_code=502, url="http://api/v1/x", method="GET")
133
+ r = repr(exc)
134
+ assert r == "SweatStackServerError(status_code=502, method='GET', url='http://api/v1/x')"
135
+
136
+ def test_connection_error_str(self):
137
+ exc = SweatStackConnectionError("Connection refused")
138
+ assert str(exc) == "Connection refused"
139
+
140
+ def test_token_refresh_error_str(self):
141
+ exc = SweatStackTokenRefreshError("token expired")
142
+ assert str(exc) == "token expired"
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # _raise_for_status mapping
147
+ # ---------------------------------------------------------------------------
148
+
149
+
150
+ def _make_response(status_code, headers=None, json_body=None, text_body=""):
151
+ """Create a mock httpx.Response for testing _raise_for_status."""
152
+ request = httpx.Request("GET", "https://api.sweatstack.no/api/v1/test")
153
+ response = httpx.Response(
154
+ status_code=status_code,
155
+ request=request,
156
+ headers=headers or {},
157
+ json=json_body,
158
+ text=text_body if json_body is None else None,
159
+ )
160
+ return response
161
+
162
+
163
+ class TestRaiseForStatus:
164
+ @pytest.fixture
165
+ def client(self):
166
+ return Client.__new__(Client)
167
+
168
+ def test_success_does_not_raise(self, client):
169
+ response = _make_response(200)
170
+ client._raise_for_status(response) # should not raise
171
+
172
+ def test_401_raises_auth_error(self, client):
173
+ response = _make_response(401)
174
+ with pytest.raises(SweatStackAuthError) as exc_info:
175
+ client._raise_for_status(response)
176
+ assert exc_info.value.status_code == 401
177
+
178
+ def test_403_raises_auth_error(self, client):
179
+ response = _make_response(403)
180
+ with pytest.raises(SweatStackAuthError) as exc_info:
181
+ client._raise_for_status(response)
182
+ assert exc_info.value.status_code == 403
183
+
184
+ def test_404_raises_not_found_error(self, client):
185
+ response = _make_response(404)
186
+ with pytest.raises(SweatStackNotFoundError) as exc_info:
187
+ client._raise_for_status(response)
188
+ assert exc_info.value.status_code == 404
189
+
190
+ def test_429_raises_rate_limit_error(self, client):
191
+ response = _make_response(429, headers={"retry-after": "60"})
192
+ with pytest.raises(SweatStackRateLimitError) as exc_info:
193
+ client._raise_for_status(response)
194
+ assert exc_info.value.status_code == 429
195
+ assert exc_info.value.retry_after == 60
196
+
197
+ def test_429_without_retry_after_header(self, client):
198
+ response = _make_response(429)
199
+ with pytest.raises(SweatStackRateLimitError) as exc_info:
200
+ client._raise_for_status(response)
201
+ assert exc_info.value.retry_after is None
202
+
203
+ def test_400_raises_bad_request_error(self, client):
204
+ response = _make_response(400)
205
+ with pytest.raises(SweatStackBadRequestError) as exc_info:
206
+ client._raise_for_status(response)
207
+ assert exc_info.value.status_code == 400
208
+
209
+ def test_422_raises_bad_request_error(self, client):
210
+ response = _make_response(422, json_body={"detail": [{"msg": "field required"}]})
211
+ with pytest.raises(SweatStackBadRequestError) as exc_info:
212
+ client._raise_for_status(response)
213
+ assert exc_info.value.status_code == 422
214
+ assert exc_info.value.body == {"detail": [{"msg": "field required"}]}
215
+
216
+ def test_500_raises_server_error(self, client):
217
+ response = _make_response(500)
218
+ with pytest.raises(SweatStackServerError) as exc_info:
219
+ client._raise_for_status(response)
220
+ assert exc_info.value.status_code == 500
221
+
222
+ def test_502_raises_server_error(self, client):
223
+ response = _make_response(502)
224
+ with pytest.raises(SweatStackServerError) as exc_info:
225
+ client._raise_for_status(response)
226
+ assert exc_info.value.status_code == 502
227
+
228
+ def test_503_raises_server_error(self, client):
229
+ response = _make_response(503)
230
+ with pytest.raises(SweatStackServerError) as exc_info:
231
+ client._raise_for_status(response)
232
+ assert exc_info.value.status_code == 503
233
+
234
+ def test_request_id_populated_from_header(self, client):
235
+ response = _make_response(500, headers={"x-request-id": "req-abc"})
236
+ with pytest.raises(SweatStackServerError) as exc_info:
237
+ client._raise_for_status(response)
238
+ assert exc_info.value.request_id == "req-abc"
239
+
240
+ def test_json_body_parsed(self, client):
241
+ response = _make_response(400, json_body={"error": "invalid_field"})
242
+ with pytest.raises(SweatStackBadRequestError) as exc_info:
243
+ client._raise_for_status(response)
244
+ assert exc_info.value.body == {"error": "invalid_field"}
245
+
246
+ def test_text_body_fallback(self, client):
247
+ response = _make_response(500, text_body="Internal Server Error")
248
+ with pytest.raises(SweatStackServerError) as exc_info:
249
+ client._raise_for_status(response)
250
+ assert exc_info.value.body == "Internal Server Error"
251
+
252
+ def test_url_and_method_populated(self, client):
253
+ response = _make_response(404)
254
+ with pytest.raises(SweatStackNotFoundError) as exc_info:
255
+ client._raise_for_status(response)
256
+ assert exc_info.value.url == "https://api.sweatstack.no/api/v1/test"
257
+ assert exc_info.value.method == "GET"
258
+
259
+ def test_catchable_as_api_error(self, client):
260
+ response = _make_response(503)
261
+ with pytest.raises(SweatStackAPIError):
262
+ client._raise_for_status(response)
263
+
264
+ def test_catchable_as_sweatstack_error(self, client):
265
+ response = _make_response(401)
266
+ with pytest.raises(SweatStackError):
267
+ client._raise_for_status(response)
268
+
269
+
270
+ # ---------------------------------------------------------------------------
271
+ # Transport error wrapping
272
+ # ---------------------------------------------------------------------------
273
+
274
+
275
+ class TestTransportErrors:
276
+ def test_http_client_wraps_connect_error(self):
277
+ client = Client.__new__(Client)
278
+ client.url = "http://localhost:1"
279
+ client._api_key = None
280
+ client._client_secret = None
281
+ client.streamlit_compatible = False
282
+
283
+ with pytest.raises(SweatStackConnectionError) as exc_info:
284
+ with client._http_client(skip_token_check=True) as http:
285
+ raise httpx.ConnectError("Connection refused")
286
+
287
+ assert "Connection refused" in str(exc_info.value)
288
+
289
+ def test_http_client_wraps_timeout_error(self):
290
+ client = Client.__new__(Client)
291
+ client.url = "http://localhost:1"
292
+ client._api_key = None
293
+ client._client_secret = None
294
+ client.streamlit_compatible = False
295
+
296
+ with pytest.raises(SweatStackConnectionError):
297
+ with client._http_client(skip_token_check=True) as http:
298
+ raise httpx.ReadTimeout("Read timed out")
299
+
300
+ def test_connection_error_preserves_cause(self):
301
+ client = Client.__new__(Client)
302
+ client.url = "http://localhost:1"
303
+ client._api_key = None
304
+ client._client_secret = None
305
+ client.streamlit_compatible = False
306
+
307
+ original = httpx.ConnectError("DNS resolution failed")
308
+ with pytest.raises(SweatStackConnectionError) as exc_info:
309
+ with client._http_client(skip_token_check=True) as http:
310
+ raise original
311
+
312
+ assert exc_info.value.__cause__ is original
@@ -2456,7 +2456,7 @@ wheels = [
2456
2456
 
2457
2457
  [[package]]
2458
2458
  name = "sweatstack"
2459
- version = "0.73.0"
2459
+ version = "0.75.0"
2460
2460
  source = { editable = "." }
2461
2461
  dependencies = [
2462
2462
  { name = "email-validator" },
@@ -1 +0,0 @@
1
- from .client import *
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