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.
- {sweatstack-0.75.0 → sweatstack-0.76.0}/.claude/skills/sweatstack-python/SKILL.md +3 -3
- {sweatstack-0.75.0 → sweatstack-0.76.0}/.claude/skills/sweatstack-python/client.md +36 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/CHANGELOG.md +16 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/PKG-INFO +1 -1
- sweatstack-0.76.0/plans/002_TYPED_EXCEPTIONS.md +269 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/pyproject.toml +1 -1
- sweatstack-0.76.0/src/sweatstack/__init__.py +12 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/client.py +67 -67
- sweatstack-0.76.0/src/sweatstack/exceptions.py +67 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/fastapi/routes.py +24 -13
- {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/openapi_schemas.py +5 -5
- {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/streamlit.py +8 -4
- sweatstack-0.76.0/tests/test_exceptions.py +312 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/uv.lock +1 -1
- sweatstack-0.75.0/src/sweatstack/__init__.py +0 -1
- {sweatstack-0.75.0 → sweatstack-0.76.0}/.claude/settings.local.json +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/.claude/skills/sweatstack-python/data-models.md +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/.claude/skills/sweatstack-python/fastapi.md +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/.claude/skills/sweatstack-python/streamlit.md +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/.gitignore +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/.python-version +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/CONTRIBUTING.md +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/DEVELOPMENT.md +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/LICENSE +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/Makefile +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/README.md +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/docs/conf.py +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/docs/everything.rst +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/docs/index.rst +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/examples/fastapi_webhooks_example.py +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/examples/send_webhook.py +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/plans/001a_tests.md +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/plans/001b_metadata.md +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/plans/001c_dailies.md +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/cli.py +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/constants.py +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/fastapi/__init__.py +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/fastapi/config.py +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/fastapi/dependencies.py +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/fastapi/models.py +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/fastapi/session.py +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/fastapi/token_stores.py +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/fastapi/webhooks.py +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/ipython_init.py +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/py.typed +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/schemas.py +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/sweatshell.py +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/utils.py +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/tests/__init__.py +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/tests/test_dailies.py +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/tests/test_dtype_conversion.py +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/tests/test_metadata.py +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/tests/test_teams.py +0 -0
- {sweatstack-0.75.0 → sweatstack-0.76.0}/tests/test_tests.py +0 -0
- {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 (
|
|
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:** `
|
|
16
|
+
**Install:** `uv add sweatstack`
|
|
17
17
|
|
|
18
|
-
**Extras:** `
|
|
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
|
|
@@ -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.
|
|
@@ -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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|
|
793
|
-
raise
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
955
|
-
|
|
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
-
|
|
991
|
+
@staticmethod
|
|
992
|
+
def _parse_error_body(response: httpx.Response) -> dict | str | None:
|
|
969
993
|
try:
|
|
970
|
-
response.
|
|
971
|
-
except
|
|
972
|
-
|
|
973
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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-
|
|
3
|
+
# timestamp: 2026-04-27T08:07:03+00:00
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
-
from datetime import date
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sweatstack-0.75.0 → sweatstack-0.76.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|