sweatstack 0.95.0__tar.gz → 0.97.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.95.0 → sweatstack-0.97.0}/.github/workflows/ci.yml +1 -1
- {sweatstack-0.95.0 → sweatstack-0.97.0}/AGENTS.md +4 -4
- {sweatstack-0.95.0 → sweatstack-0.97.0}/CHANGELOG.md +33 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/DEVELOPMENT.md +1 -1
- {sweatstack-0.95.0 → sweatstack-0.97.0}/PKG-INFO +3 -4
- {sweatstack-0.95.0 → sweatstack-0.97.0}/examples/send_webhook.py +8 -8
- {sweatstack-0.95.0 → sweatstack-0.97.0}/pyproject.toml +4 -5
- {sweatstack-0.95.0 → sweatstack-0.97.0}/skills/sweatstack-python/SKILL.md +1 -1
- {sweatstack-0.95.0 → sweatstack-0.97.0}/skills/sweatstack-python/api.md +1 -1
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/_frames.py +3 -6
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/_transport.py +16 -16
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/cli.py +7 -14
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/client.py +9 -10
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/exceptions.py +1 -1
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/fastapi/dependencies.py +4 -4
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/fastapi/routes.py +12 -12
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/openapi_schemas.py +22 -453
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/oauth.py +54 -12
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/streamlit.py +16 -14
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_account_status.py +3 -3
- sweatstack-0.97.0/tests/test_authorization_url.py +103 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_exceptions.py +7 -7
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_frames.py +2 -23
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_identity.py +3 -3
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_portal.py +3 -3
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_resources.py +3 -4
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_tests.py +5 -5
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_trace_test_linking.py +4 -4
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_transport.py +44 -22
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_user_agent.py +10 -10
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_webhooks.py +10 -10
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_wire_names.py +1 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_write_timezone_validation.py +2 -2
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/typing/output_types.py +1 -1
- {sweatstack-0.95.0 → sweatstack-0.97.0}/uv.lock +70 -508
- {sweatstack-0.95.0 → sweatstack-0.97.0}/.git-blame-ignore-revs +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/.gitignore +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/.gitleaks.toml +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/.python-version +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/CONTRIBUTING.md +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/LICENSE +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/Makefile +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/README.md +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/UPGRADING.md +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/evals/__init__.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/evals/agent/__init__.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/evals/agent/agent.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/evals/agent/cases.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/evals/agent/evaluators.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/evals/agent/harness.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/evals/agent/lifecycle.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/evals/agent/run.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/examples/fastapi_webhooks_example.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/001a_tests.md +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/001b_metadata.md +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/001c_dailies.md +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/003_trace_test_linking.md +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/004_codebase_hygiene.md +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/005_ost_sport_bridge.md +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/006_output_backends.md +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/007_account_status_userinfo_portal.md +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/008_sdk_documentation.md +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/009_road_to_1_0.md +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/010_credentials_one_source.md +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/011_user_agent_on_every_request.md +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/skills/sweatstack-python/data-models.md +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/skills/sweatstack-python/fastapi.md +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/skills/sweatstack-python/streamlit.md +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/__init__.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/_docs.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/_renames.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/constants.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/fastapi/__init__.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/fastapi/config.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/fastapi/models.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/fastapi/session.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/fastapi/token_stores.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/fastapi/webhooks.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/py.typed +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/__init__.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/_app_metadata.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/_base.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/activities.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/dailies.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/portal.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/profile.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/teams.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/tests.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/traces.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/users.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/schemas.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/utils.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/__init__.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/conftest.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_access_token_cache.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_changelog.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_credentials.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_dailies.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_docs_check.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_docstrings.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_dtype_conversion.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_longitudinal_mean_max_after.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_metadata.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_output.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_public_hygiene.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_public_surface.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_renames.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_segmentation.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_skill.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_sport_ost.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_streamlit_auth.py +0 -0
- {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_teams.py +0 -0
|
@@ -12,7 +12,7 @@ jobs:
|
|
|
12
12
|
strategy:
|
|
13
13
|
fail-fast: false
|
|
14
14
|
matrix:
|
|
15
|
-
python: ["3.
|
|
15
|
+
python: ["3.11", "3.12", "3.13"]
|
|
16
16
|
include:
|
|
17
17
|
# The lock file tracks the newest pandas; this leg keeps the declared
|
|
18
18
|
# lower bound (pyproject `pandas>=2.2.3`) honest.
|
|
@@ -87,7 +87,7 @@ src/sweatstack/
|
|
|
87
87
|
├── _frames.py # output= backends: parquet/models -> pandas/polars/arrow/bytes.
|
|
88
88
|
├── _renames.py # Removed names -> replacement hints. Permanent; see "Breaking changes".
|
|
89
89
|
├── _transport.py # Retry policy and the per-request Session on the client's connection pool.
|
|
90
|
-
├── exceptions.py # Public error contract. No
|
|
90
|
+
├── exceptions.py # Public error contract. No HTTP-library types leak.
|
|
91
91
|
├── client.py # Client: config, auth, delegation, transport (_request), resource attributes,
|
|
92
92
|
│ # and the module-level interface (one shared default client).
|
|
93
93
|
├── resources/ # One module per URL segment: activities.py, traces.py, tests.py, ...
|
|
@@ -233,8 +233,8 @@ Both kinds take `output` as a keyword-only parameter and carry `@overload` stubs
|
|
|
233
233
|
`Literal` values (clone the neighbours); `tests/typing/output_types.py` pins them.
|
|
234
234
|
|
|
235
235
|
**Query parameters** are only sent when the caller supplied a non-default value. Repeatable
|
|
236
|
-
ones go through `self._wire(value)` (R8). Always pass `.value` for enums into `
|
|
237
|
-
`
|
|
236
|
+
ones go through `self._wire(value)` (R8). Always pass `.value` for enums into `httpx2` params:
|
|
237
|
+
`httpx2` calls `str()`, which gives `"TraceResolution.linked"`, not `"linked"`.
|
|
238
238
|
|
|
239
239
|
|
|
240
240
|
## Full-replace `replace` methods
|
|
@@ -309,7 +309,7 @@ construct fails the suite, not a user's notebook.
|
|
|
309
309
|
## Exceptions
|
|
310
310
|
|
|
311
311
|
The hierarchy in `sweatstack/exceptions.py` is the **public** error
|
|
312
|
-
contract — consumers should never need to import `
|
|
312
|
+
contract — consumers should never need to import `httpx2`.
|
|
313
313
|
|
|
314
314
|
```
|
|
315
315
|
SweatStackError
|
|
@@ -11,6 +11,39 @@ for a coding agent, are in `UPGRADING.md` next to this file and at
|
|
|
11
11
|
https://docs.sweatstack.no/learn/python/upgrading/.
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
## [0.97.0] - 2026-10-22
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Requires Python 3.11 or later. On Python 3.10, pip and uv keep installing 0.96.
|
|
19
|
+
- The SDK sends its requests with [`httpx2`](https://github.com/pydantic/httpx2), the maintained
|
|
20
|
+
continuation of `httpx`. Calling the SDK is unchanged, and errors are still the `SweatStack*`
|
|
21
|
+
exceptions.
|
|
22
|
+
- HTTPS certificates are checked against the operating system's trust store instead of the
|
|
23
|
+
`certifi` bundle that `httpx` shipped. A root certificate installed on the machine, such as a
|
|
24
|
+
company proxy's, is now trusted. A Linux image without a CA bundle (the `ca-certificates`
|
|
25
|
+
package) can no longer connect; install it, or point `SSL_CERT_FILE` at a bundle.
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
## [0.96.0] - 2026-10-16
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- `client.oauth.authorization_url()` takes `nonce`, `integration` and `email_auth_mode`, the
|
|
33
|
+
authorization parameters it was missing. `integration="garmin_connect"` sends the user straight
|
|
34
|
+
to Garmin, skipping the sign-in chooser.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- No sign-in link sends `prompt` any more: `client.oauth.authorization_url()` defaults to
|
|
39
|
+
`prompt=None` instead of `"none"`, and the FastAPI plugin and the Streamlit helper leave it out.
|
|
40
|
+
Users see what they saw before: SweatStack skips the consent screen by itself when the user's
|
|
41
|
+
grant already covers the request.
|
|
42
|
+
- The Streamlit helper separates scopes with spaces, as the other sign-in links do.
|
|
43
|
+
- `StreamlitAuth.get_authorization_url()` raises `ValueError` without a client ID or redirect
|
|
44
|
+
URI, instead of building a link that fails on the server.
|
|
45
|
+
|
|
46
|
+
|
|
14
47
|
## [0.95.0] - 2026-10-13
|
|
15
48
|
|
|
16
49
|
Scopes are categories now. Request what your app uses. Breaking: the default scopes change
|
|
@@ -17,7 +17,7 @@ uv run python -c "..." # ad-hoc scripts
|
|
|
17
17
|
uv run generate-response-models # regenerate OpenAPI schemas (see below)
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
Python ≥ 3.
|
|
20
|
+
Python ≥ 3.11 is supported; develop against the version pinned in
|
|
21
21
|
`.python-version`.
|
|
22
22
|
|
|
23
23
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sweatstack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.97.0
|
|
4
4
|
Summary: The official Python client for SweatStack
|
|
5
5
|
Project-URL: Homepage, https://sweatstack.no
|
|
6
6
|
Project-URL: Documentation, https://docs.sweatstack.no/learn/python/
|
|
@@ -16,14 +16,13 @@ Classifier: Development Status :: 4 - Beta
|
|
|
16
16
|
Classifier: Intended Audience :: Developers
|
|
17
17
|
Classifier: License :: OSI Approved :: MIT License
|
|
18
18
|
Classifier: Programming Language :: Python :: 3
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
20
19
|
Classifier: Programming Language :: Python :: 3.11
|
|
21
20
|
Classifier: Programming Language :: Python :: 3.12
|
|
22
21
|
Classifier: Programming Language :: Python :: 3.13
|
|
23
22
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
-
Requires-Python: >=3.
|
|
23
|
+
Requires-Python: >=3.11
|
|
25
24
|
Requires-Dist: email-validator>=2.2.0
|
|
26
|
-
Requires-Dist:
|
|
25
|
+
Requires-Dist: httpx2>=2.16.0
|
|
27
26
|
Requires-Dist: open-sport-taxonomy[pydantic]>=0.10.0
|
|
28
27
|
Requires-Dist: platformdirs>=4.0.0
|
|
29
28
|
Requires-Dist: pydantic>=2.10.5
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# /// script
|
|
3
3
|
# dependencies = [
|
|
4
|
-
# "
|
|
4
|
+
# "httpx2",
|
|
5
5
|
# ]
|
|
6
6
|
# ///
|
|
7
7
|
"""
|
|
@@ -32,9 +32,9 @@ import hmac
|
|
|
32
32
|
import json
|
|
33
33
|
import sys
|
|
34
34
|
import time
|
|
35
|
-
from datetime import
|
|
35
|
+
from datetime import UTC, datetime
|
|
36
36
|
|
|
37
|
-
import
|
|
37
|
+
import httpx2
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
def create_signature(payload: bytes, secret: str, timestamp: int) -> str:
|
|
@@ -55,14 +55,14 @@ def send_webhook(
|
|
|
55
55
|
resource_id: str,
|
|
56
56
|
secret: str,
|
|
57
57
|
verbose: bool = False,
|
|
58
|
-
) ->
|
|
58
|
+
) -> httpx2.Response:
|
|
59
59
|
"""Send a webhook to the specified URL."""
|
|
60
60
|
# Create payload
|
|
61
61
|
payload = {
|
|
62
62
|
"user_id": user_id,
|
|
63
63
|
"event_type": event_type,
|
|
64
64
|
"resource_id": resource_id,
|
|
65
|
-
"timestamp": datetime.now(
|
|
65
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
payload_bytes = json.dumps(payload).encode()
|
|
@@ -79,7 +79,7 @@ def send_webhook(
|
|
|
79
79
|
print(f"{'=' * 60}\n")
|
|
80
80
|
|
|
81
81
|
# Send request
|
|
82
|
-
response =
|
|
82
|
+
response = httpx2.post(
|
|
83
83
|
url,
|
|
84
84
|
content=payload_bytes,
|
|
85
85
|
headers={
|
|
@@ -176,10 +176,10 @@ Examples:
|
|
|
176
176
|
if args.verbose and response.headers:
|
|
177
177
|
print(f" Response headers: {dict(response.headers)}")
|
|
178
178
|
|
|
179
|
-
except
|
|
179
|
+
except httpx2.ConnectError:
|
|
180
180
|
print(f"✗ Connection failed - is the server running at {args.url}?")
|
|
181
181
|
sys.exit(1)
|
|
182
|
-
except
|
|
182
|
+
except httpx2.TimeoutException:
|
|
183
183
|
print("✗ Request timed out")
|
|
184
184
|
sys.exit(1)
|
|
185
185
|
except Exception as e:
|
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sweatstack"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.97.0"
|
|
4
4
|
description = "The official Python client for SweatStack"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
7
7
|
authors = [
|
|
8
8
|
{ name = "Aart Goossens", email = "aart@sweatstack.no" }
|
|
9
9
|
]
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
11
|
keywords = ["sweatstack", "sports", "fitness", "api", "client"]
|
|
12
12
|
classifiers = [
|
|
13
13
|
"Development Status :: 4 - Beta",
|
|
14
14
|
"Intended Audience :: Developers",
|
|
15
15
|
"License :: OSI Approved :: MIT License",
|
|
16
16
|
"Programming Language :: Python :: 3",
|
|
17
|
-
"Programming Language :: Python :: 3.10",
|
|
18
17
|
"Programming Language :: Python :: 3.11",
|
|
19
18
|
"Programming Language :: Python :: 3.12",
|
|
20
19
|
"Programming Language :: Python :: 3.13",
|
|
@@ -24,7 +23,7 @@ classifiers = [
|
|
|
24
23
|
# consumers and anyone who only needs the models. CI's bare-install job keeps it so.
|
|
25
24
|
dependencies = [
|
|
26
25
|
"email-validator>=2.2.0",
|
|
27
|
-
"
|
|
26
|
+
"httpx2>=2.16.0",
|
|
28
27
|
"open-sport-taxonomy[pydantic]>=0.10.0",
|
|
29
28
|
"platformdirs>=4.0.0",
|
|
30
29
|
"pydantic>=2.10.5",
|
|
@@ -91,7 +90,7 @@ evals = [
|
|
|
91
90
|
|
|
92
91
|
[tool.ruff]
|
|
93
92
|
line-length = 100
|
|
94
|
-
target-version = "
|
|
93
|
+
target-version = "py311"
|
|
95
94
|
extend-exclude = ["src/sweatstack/openapi_schemas.py"]
|
|
96
95
|
|
|
97
96
|
[tool.ruff.lint]
|
|
@@ -98,7 +98,7 @@ pandas (`summary.power.mean`) and structs in Polars (`.unnest("summary")`).
|
|
|
98
98
|
|
|
99
99
|
## Errors
|
|
100
100
|
|
|
101
|
-
Everything derives from `SweatStackError`; never import `
|
|
101
|
+
Everything derives from `SweatStackError`; never import `httpx2`. `SweatStackAPIError` (has
|
|
102
102
|
`status_code`, `url`, `method`, `request_id`, `body`) splits into `SweatStackAuthError` (401,
|
|
103
103
|
403), `SweatStackNotFoundError` (404), `SweatStackRateLimitError` (429, `retry_after`),
|
|
104
104
|
`SweatStackBadRequestError` (other 4xx) and `SweatStackServerError` (5xx). Also
|
|
@@ -107,7 +107,7 @@ client.portal.sessions.create("manage-integrations", return_url=None) # user to
|
|
|
107
107
|
|
|
108
108
|
client.oauth.userinfo() # OpenID claims + issue (needs openid; profile and email add the name and email claims)
|
|
109
109
|
verifier, challenge = client.oauth.generate_pkce_params()
|
|
110
|
-
client.oauth.authorization_url(client_id="YOUR_CLIENT_ID", redirect_uri="http://localhost:8000/callback", code_challenge=challenge)
|
|
110
|
+
client.oauth.authorization_url(client_id="YOUR_CLIENT_ID", redirect_uri="http://localhost:8000/callback", code_challenge=challenge) # + scope, state, nonce, integration="garmin_connect", email_auth_mode; leave prompt out
|
|
111
111
|
client.oauth.exchange_code("code", client_id="YOUR_CLIENT_ID", code_verifier=verifier) # persist=True saves on this machine; never in a server
|
|
112
112
|
```
|
|
113
113
|
|
|
@@ -22,7 +22,7 @@ from __future__ import annotations
|
|
|
22
22
|
import json
|
|
23
23
|
import warnings
|
|
24
24
|
from collections.abc import Sequence
|
|
25
|
-
from datetime import
|
|
25
|
+
from datetime import UTC, datetime
|
|
26
26
|
from enum import Enum
|
|
27
27
|
from functools import cache
|
|
28
28
|
from importlib import import_module
|
|
@@ -363,7 +363,6 @@ def field_types(model: type[BaseModel]) -> dict[str, FieldType]:
|
|
|
363
363
|
}
|
|
364
364
|
|
|
365
365
|
|
|
366
|
-
_DATETIME_FORMATS = frozenset({"date-time", "naive-date-time"})
|
|
367
366
|
_STRING_FORMATS = {
|
|
368
367
|
"date-time": "datetime",
|
|
369
368
|
"naive-date-time": "naive_datetime",
|
|
@@ -392,8 +391,6 @@ def _field_type(node: dict, definitions: dict, path: str, stack: tuple[str, ...]
|
|
|
392
391
|
return _field_type(options[0], definitions, path, stack)
|
|
393
392
|
if options and all(option.get("type") in ("integer", "number") for option in options):
|
|
394
393
|
return "float"
|
|
395
|
-
if options and all(option.get("format") in _DATETIME_FORMATS for option in options):
|
|
396
|
-
return "datetime" # aware | naive: naive values are taken as UTC
|
|
397
394
|
return _fallback(path, node)
|
|
398
395
|
|
|
399
396
|
if "enum" in node or "const" in node:
|
|
@@ -492,7 +489,7 @@ def _plain_value(value: Any, node: FieldType) -> Any:
|
|
|
492
489
|
if node == "string":
|
|
493
490
|
return value if isinstance(value, str) else str(value) # Sport and other value objects
|
|
494
491
|
if node == "datetime" and isinstance(value, datetime) and value.tzinfo is None:
|
|
495
|
-
return value.replace(tzinfo=
|
|
492
|
+
return value.replace(tzinfo=UTC) # naive value in a UTC column
|
|
496
493
|
if node == "naive_datetime" and isinstance(value, datetime) and value.tzinfo is not None:
|
|
497
|
-
return value.astimezone(
|
|
494
|
+
return value.astimezone(UTC).replace(tzinfo=None)
|
|
498
495
|
return value # date, timedelta, bool, int, float, datetime already matching
|
|
@@ -4,7 +4,7 @@ The policy follows the OpenAI, Anthropic and Stripe clients, with one difference
|
|
|
4
4
|
never retried, because the SweatStack API has no idempotency keys and a retried create could
|
|
5
5
|
create twice.
|
|
6
6
|
|
|
7
|
-
This module is also the only place in the package that calls ``
|
|
7
|
+
This module is also the only place in the package that calls ``httpx2`` to make a request:
|
|
8
8
|
through a client's pool, or through :func:`post` for the token calls the FastAPI and Streamlit
|
|
9
9
|
integrations make without a client. Both put the SDK's ``User-Agent`` on the wire, and
|
|
10
10
|
``tests/test_transport.py`` fails on a request made from anywhere else.
|
|
@@ -17,7 +17,7 @@ from collections.abc import Iterator
|
|
|
17
17
|
from contextlib import contextmanager
|
|
18
18
|
from typing import Any
|
|
19
19
|
|
|
20
|
-
import
|
|
20
|
+
import httpx2
|
|
21
21
|
|
|
22
22
|
from .constants import USER_AGENT
|
|
23
23
|
|
|
@@ -42,7 +42,7 @@ def backoff(attempt: int) -> float:
|
|
|
42
42
|
return min(0.5 * 2**attempt * random.uniform(0.75, 1.25), MAX_BACKOFF)
|
|
43
43
|
|
|
44
44
|
|
|
45
|
-
def retry_after(response:
|
|
45
|
+
def retry_after(response: httpx2.Response) -> float | None:
|
|
46
46
|
"""The response's ``Retry-After`` in seconds, if it sent one as a number."""
|
|
47
47
|
try:
|
|
48
48
|
return float(response.headers["retry-after"])
|
|
@@ -55,7 +55,7 @@ def wait_before_retry(
|
|
|
55
55
|
attempt: int,
|
|
56
56
|
max_retries: int,
|
|
57
57
|
waited: float,
|
|
58
|
-
response:
|
|
58
|
+
response: httpx2.Response | None = None,
|
|
59
59
|
) -> float | None:
|
|
60
60
|
"""How long to wait before retrying, or ``None`` to stop.
|
|
61
61
|
|
|
@@ -80,16 +80,16 @@ def wait_before_retry(
|
|
|
80
80
|
return wait
|
|
81
81
|
|
|
82
82
|
|
|
83
|
-
def post(url: str, **kwargs: Any) ->
|
|
84
|
-
"""``
|
|
83
|
+
def post(url: str, **kwargs: Any) -> httpx2.Response:
|
|
84
|
+
"""``httpx2.post`` with the SDK's ``User-Agent``; the caller's headers win on a clash.
|
|
85
85
|
|
|
86
86
|
For the one-off token calls the integrations make without a ``Client``. It adds the header
|
|
87
87
|
and nothing else: no retry (a ``POST`` is never retried), no error translation and no
|
|
88
88
|
timeout, so each caller keeps its own.
|
|
89
89
|
"""
|
|
90
|
-
headers =
|
|
90
|
+
headers = httpx2.Headers({"User-Agent": USER_AGENT})
|
|
91
91
|
headers.update(kwargs.pop("headers", None) or {})
|
|
92
|
-
return
|
|
92
|
+
return httpx2.post(url, headers=headers, **kwargs)
|
|
93
93
|
|
|
94
94
|
|
|
95
95
|
class Session:
|
|
@@ -100,29 +100,29 @@ class Session:
|
|
|
100
100
|
endpoints must never see it.
|
|
101
101
|
"""
|
|
102
102
|
|
|
103
|
-
def __init__(self, http:
|
|
103
|
+
def __init__(self, http: httpx2.Client, headers: httpx2.Headers, timeout: float) -> None:
|
|
104
104
|
self._http = http
|
|
105
105
|
self.headers = headers
|
|
106
106
|
self._timeout = timeout
|
|
107
107
|
|
|
108
|
-
def request(self, method: str, url: str, **kwargs: Any) ->
|
|
108
|
+
def request(self, method: str, url: str, **kwargs: Any) -> httpx2.Response:
|
|
109
109
|
return self._http.request(method, url, headers=self.headers, **kwargs)
|
|
110
110
|
|
|
111
|
-
def get(self, url: str, **kwargs: Any) ->
|
|
111
|
+
def get(self, url: str, **kwargs: Any) -> httpx2.Response:
|
|
112
112
|
return self.request("GET", url, **kwargs)
|
|
113
113
|
|
|
114
|
-
def post(self, url: str, **kwargs: Any) ->
|
|
114
|
+
def post(self, url: str, **kwargs: Any) -> httpx2.Response:
|
|
115
115
|
return self.request("POST", url, **kwargs)
|
|
116
116
|
|
|
117
|
-
def put(self, url: str, **kwargs: Any) ->
|
|
117
|
+
def put(self, url: str, **kwargs: Any) -> httpx2.Response:
|
|
118
118
|
return self.request("PUT", url, **kwargs)
|
|
119
119
|
|
|
120
|
-
def delete(self, url: str, **kwargs: Any) ->
|
|
120
|
+
def delete(self, url: str, **kwargs: Any) -> httpx2.Response:
|
|
121
121
|
return self.request("DELETE", url, **kwargs)
|
|
122
122
|
|
|
123
123
|
@contextmanager
|
|
124
|
-
def stream(self, method: str, url: str, **kwargs: Any) -> Iterator[
|
|
124
|
+
def stream(self, method: str, url: str, **kwargs: Any) -> Iterator[httpx2.Response]:
|
|
125
125
|
"""A streaming request. No read timeout: a stream may stay quiet for a while."""
|
|
126
|
-
timeout =
|
|
126
|
+
timeout = httpx2.Timeout(self._timeout, read=None)
|
|
127
127
|
with self._http.stream(method, url, headers=self.headers, timeout=timeout, **kwargs) as r:
|
|
128
128
|
yield r
|
|
@@ -2,7 +2,7 @@ import ast
|
|
|
2
2
|
import re
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
|
|
5
|
-
import
|
|
5
|
+
import httpx2
|
|
6
6
|
from datamodel_code_generator import DataModelType, InputFileType, generate
|
|
7
7
|
|
|
8
8
|
|
|
@@ -58,18 +58,14 @@ def _bind_sport_to_ost(path: Path) -> None:
|
|
|
58
58
|
path.write_text("".join(out))
|
|
59
59
|
|
|
60
60
|
|
|
61
|
-
# Fields the server sends without a UTC offset, though the OpenAPI schema says date-time.
|
|
62
|
-
_NAIVE_OR_AWARE = {"registered_at", "backfill_loaded_until"}
|
|
63
|
-
|
|
64
|
-
|
|
65
61
|
def _restore_naive_local_datetimes(path: Path) -> None:
|
|
66
62
|
"""Type local timestamps as ``NaiveDatetime`` rather than ``AwareDatetime``.
|
|
67
63
|
|
|
68
64
|
The API returns *local* timestamps without a timezone, but datamodel-codegen types every
|
|
69
65
|
``date-time`` field as ``AwareDatetime`` -- which rejects a naive value. Retype the local fields
|
|
70
|
-
(those whose name ends in ``_local``) back to ``NaiveDatetime
|
|
71
|
-
|
|
72
|
-
manual fixups this file has needed in the past).
|
|
66
|
+
(those whose name ends in ``_local``) back to ``NaiveDatetime``; every other timestamp the server
|
|
67
|
+
returns is UTC with ``Z``. AST-anchored and idempotent, so it survives regeneration (and replaces
|
|
68
|
+
the manual fixups this file has needed in the past).
|
|
73
69
|
"""
|
|
74
70
|
src = path.read_text()
|
|
75
71
|
tree = ast.parse(src)
|
|
@@ -83,12 +79,9 @@ def _restore_naive_local_datetimes(path: Path) -> None:
|
|
|
83
79
|
annotation = ast.get_source_segment(src, node.annotation)
|
|
84
80
|
if annotation is None:
|
|
85
81
|
continue
|
|
86
|
-
if name.endswith("_local") and "AwareDatetime" in annotation:
|
|
87
|
-
retyped = annotation.replace("AwareDatetime", "NaiveDatetime")
|
|
88
|
-
elif name in _NAIVE_OR_AWARE and "NaiveDatetime" not in annotation:
|
|
89
|
-
retyped = annotation.replace("AwareDatetime", "AwareDatetime | NaiveDatetime", 1)
|
|
90
|
-
else:
|
|
82
|
+
if not (name.endswith("_local") and "AwareDatetime" in annotation):
|
|
91
83
|
continue
|
|
84
|
+
retyped = annotation.replace("AwareDatetime", "NaiveDatetime")
|
|
92
85
|
i = node.lineno - 1
|
|
93
86
|
replace[i] = lines[i].replace(annotation, retyped, 1)
|
|
94
87
|
|
|
@@ -102,7 +95,7 @@ def _restore_naive_local_datetimes(path: Path) -> None:
|
|
|
102
95
|
|
|
103
96
|
|
|
104
97
|
def generate_response_models():
|
|
105
|
-
response =
|
|
98
|
+
response = httpx2.get("http://localhost:8080/openapi.json")
|
|
106
99
|
response.raise_for_status()
|
|
107
100
|
output_directory = Path(__file__).parent
|
|
108
101
|
output = Path(output_directory / "openapi_schemas.py")
|
|
@@ -20,7 +20,7 @@ from pathlib import Path
|
|
|
20
20
|
from typing import Any, Literal
|
|
21
21
|
from urllib.parse import parse_qs, urlparse
|
|
22
22
|
|
|
23
|
-
import
|
|
23
|
+
import httpx2
|
|
24
24
|
from platformdirs import user_cache_dir, user_data_dir
|
|
25
25
|
from pydantic import SecretStr
|
|
26
26
|
|
|
@@ -642,7 +642,6 @@ class Client:
|
|
|
642
642
|
redirect_uri=f"http://localhost:{port}",
|
|
643
643
|
code_challenge=code_challenge,
|
|
644
644
|
scope=DEFAULT_SCOPE,
|
|
645
|
-
prompt=None,
|
|
646
645
|
)
|
|
647
646
|
|
|
648
647
|
webbrowser.open(authorization_url)
|
|
@@ -876,7 +875,7 @@ class Client:
|
|
|
876
875
|
# Transport: the internal API the resources use
|
|
877
876
|
# -------------------------------------------------------------------------
|
|
878
877
|
|
|
879
|
-
def _pool(self) ->
|
|
878
|
+
def _pool(self) -> httpx2.Client:
|
|
880
879
|
"""The client's connection pool, created on first use and kept for its lifetime.
|
|
881
880
|
|
|
882
881
|
Rebuilt if ``url`` changes. Authorization is per request (see ``_http_client``), so the
|
|
@@ -886,7 +885,7 @@ class Client:
|
|
|
886
885
|
built = self.__dict__.get("_pool_entry")
|
|
887
886
|
if built is not None and built[0] == url:
|
|
888
887
|
return built[1]
|
|
889
|
-
pool =
|
|
888
|
+
pool = httpx2.Client(
|
|
890
889
|
base_url=url,
|
|
891
890
|
headers={"User-Agent": USER_AGENT},
|
|
892
891
|
timeout=self.timeout,
|
|
@@ -903,7 +902,7 @@ class Client:
|
|
|
903
902
|
"""A request scope on the client's connection pool, with the right Authorization header.
|
|
904
903
|
|
|
905
904
|
Transport-level errors (DNS, timeouts, connection refused) are re-raised as
|
|
906
|
-
SweatStackConnectionError so consumers never see
|
|
905
|
+
SweatStackConnectionError so consumers never see the HTTP library's types.
|
|
907
906
|
|
|
908
907
|
Args:
|
|
909
908
|
skip_token_check: Use the raw token without the expiry check (used during
|
|
@@ -912,7 +911,7 @@ class Client:
|
|
|
912
911
|
For the token exchange, which authenticates with the app's own credentials in
|
|
913
912
|
the body, so a user's bearer is never sent where it is not needed.
|
|
914
913
|
"""
|
|
915
|
-
headers =
|
|
914
|
+
headers = httpx2.Headers({"User-Agent": USER_AGENT})
|
|
916
915
|
if not auth:
|
|
917
916
|
token = None
|
|
918
917
|
elif skip_token_check:
|
|
@@ -925,7 +924,7 @@ class Client:
|
|
|
925
924
|
|
|
926
925
|
try:
|
|
927
926
|
yield _transport.Session(self._pool(), headers, self.timeout)
|
|
928
|
-
except
|
|
927
|
+
except httpx2.HTTPError as exc:
|
|
929
928
|
raise SweatStackConnectionError(str(exc)) from exc
|
|
930
929
|
|
|
931
930
|
def _request(
|
|
@@ -935,7 +934,7 @@ class Client:
|
|
|
935
934
|
*,
|
|
936
935
|
auth: bool = True,
|
|
937
936
|
**kwargs: Any,
|
|
938
|
-
) ->
|
|
937
|
+
) -> httpx2.Response:
|
|
939
938
|
"""Send one request, retrying when safe, and raise the typed exception for an error status.
|
|
940
939
|
|
|
941
940
|
Every resource method that makes a single request goes through here. Retries follow
|
|
@@ -982,7 +981,7 @@ class Client:
|
|
|
982
981
|
waited += wait
|
|
983
982
|
attempt += 1
|
|
984
983
|
|
|
985
|
-
def _raise_for_status(self, response:
|
|
984
|
+
def _raise_for_status(self, response: httpx2.Response) -> None:
|
|
986
985
|
if response.is_success:
|
|
987
986
|
return
|
|
988
987
|
|
|
@@ -1009,7 +1008,7 @@ class Client:
|
|
|
1009
1008
|
raise SweatStackAPIError(**common)
|
|
1010
1009
|
|
|
1011
1010
|
@staticmethod
|
|
1012
|
-
def _parse_error_body(response:
|
|
1011
|
+
def _parse_error_body(response: httpx2.Response) -> dict | str | None:
|
|
1013
1012
|
try:
|
|
1014
1013
|
return response.json()
|
|
1015
1014
|
except Exception:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Typed exception hierarchy for the SweatStack client.
|
|
2
2
|
|
|
3
3
|
This module defines all exceptions raised by the SweatStack library.
|
|
4
|
-
No
|
|
4
|
+
No HTTP-library types are exposed — this is the public error contract.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
|
|
@@ -6,11 +6,11 @@ import hashlib
|
|
|
6
6
|
import logging
|
|
7
7
|
import time
|
|
8
8
|
from dataclasses import dataclass
|
|
9
|
-
from datetime import
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
10
|
from typing import Annotated, NoReturn
|
|
11
11
|
from urllib.parse import quote
|
|
12
12
|
|
|
13
|
-
import
|
|
13
|
+
import httpx2
|
|
14
14
|
from fastapi import Depends, HTTPException, Request, Response
|
|
15
15
|
|
|
16
16
|
from .. import _transport
|
|
@@ -37,7 +37,7 @@ TOKEN_EXPIRY_MARGIN = 5
|
|
|
37
37
|
# endpoint is normally sub-second; 10 seconds is generous enough to
|
|
38
38
|
# absorb a one-off spike but tight enough that a hung server doesn't
|
|
39
39
|
# pin a threadpool worker for minutes.
|
|
40
|
-
REFRESH_HTTP_TIMEOUT =
|
|
40
|
+
REFRESH_HTTP_TIMEOUT = httpx2.Timeout(10.0, connect=5.0)
|
|
41
41
|
|
|
42
42
|
# Maximum time to wait for the per-session refresh lock. Set above
|
|
43
43
|
# ``REFRESH_HTTP_TIMEOUT`` plus a small slack so that under normal
|
|
@@ -103,7 +103,7 @@ def _is_token_expiring(token: str) -> bool:
|
|
|
103
103
|
def _extract_expiry(access_token: str) -> datetime:
|
|
104
104
|
"""Extract expiry time from JWT access token."""
|
|
105
105
|
body = decode_jwt_body(access_token)
|
|
106
|
-
return datetime.fromtimestamp(body["exp"], tz=
|
|
106
|
+
return datetime.fromtimestamp(body["exp"], tz=UTC)
|
|
107
107
|
|
|
108
108
|
|
|
109
109
|
def _refresh_access_token(
|
|
@@ -6,15 +6,16 @@ import base64
|
|
|
6
6
|
import json
|
|
7
7
|
import logging
|
|
8
8
|
import secrets
|
|
9
|
-
from urllib.parse import
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
10
|
|
|
11
|
-
import
|
|
11
|
+
import httpx2
|
|
12
12
|
from fastapi import APIRouter, FastAPI, HTTPException, Request, Response
|
|
13
13
|
from fastapi.responses import RedirectResponse
|
|
14
14
|
|
|
15
15
|
from .. import _transport
|
|
16
16
|
from ..constants import DEFAULT_URL
|
|
17
17
|
from ..exceptions import SweatStackAPIError, SweatStackConnectionError
|
|
18
|
+
from ..resources.oauth import _build_authorization_url
|
|
18
19
|
from ..utils import decode_jwt_body
|
|
19
20
|
from .config import get_config
|
|
20
21
|
from .dependencies import _extract_expiry
|
|
@@ -107,7 +108,7 @@ def _fetch_delegated_token(
|
|
|
107
108
|
headers={"Authorization": f"Bearer {principal_tokens.access_token}"},
|
|
108
109
|
json=body,
|
|
109
110
|
)
|
|
110
|
-
except
|
|
111
|
+
except httpx2.HTTPError as exc:
|
|
111
112
|
raise SweatStackConnectionError(str(exc)) from exc
|
|
112
113
|
|
|
113
114
|
if response.status_code == 403:
|
|
@@ -162,14 +163,13 @@ def create_router() -> APIRouter:
|
|
|
162
163
|
state = create_state(validated_next)
|
|
163
164
|
|
|
164
165
|
# Build authorization URL
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
"
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
auth_url = f"{DEFAULT_URL}/oauth/authorize?{urlencode(params)}"
|
|
166
|
+
auth_url = _build_authorization_url(
|
|
167
|
+
DEFAULT_URL,
|
|
168
|
+
client_id=config.client_id,
|
|
169
|
+
redirect_uri=config.redirect_uri,
|
|
170
|
+
scope=" ".join(config.scopes),
|
|
171
|
+
state=state,
|
|
172
|
+
)
|
|
173
173
|
|
|
174
174
|
# Set state cookie and redirect
|
|
175
175
|
response = RedirectResponse(url=auth_url, status_code=302)
|
|
@@ -222,7 +222,7 @@ def create_router() -> APIRouter:
|
|
|
222
222
|
"redirect_uri": config.redirect_uri,
|
|
223
223
|
},
|
|
224
224
|
)
|
|
225
|
-
except
|
|
225
|
+
except httpx2.HTTPError as e:
|
|
226
226
|
logger.error("Token exchange error: %s", e)
|
|
227
227
|
return error_redirect("token_exchange_failed")
|
|
228
228
|
|