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.
Files changed (114) hide show
  1. {sweatstack-0.95.0 → sweatstack-0.97.0}/.github/workflows/ci.yml +1 -1
  2. {sweatstack-0.95.0 → sweatstack-0.97.0}/AGENTS.md +4 -4
  3. {sweatstack-0.95.0 → sweatstack-0.97.0}/CHANGELOG.md +33 -0
  4. {sweatstack-0.95.0 → sweatstack-0.97.0}/DEVELOPMENT.md +1 -1
  5. {sweatstack-0.95.0 → sweatstack-0.97.0}/PKG-INFO +3 -4
  6. {sweatstack-0.95.0 → sweatstack-0.97.0}/examples/send_webhook.py +8 -8
  7. {sweatstack-0.95.0 → sweatstack-0.97.0}/pyproject.toml +4 -5
  8. {sweatstack-0.95.0 → sweatstack-0.97.0}/skills/sweatstack-python/SKILL.md +1 -1
  9. {sweatstack-0.95.0 → sweatstack-0.97.0}/skills/sweatstack-python/api.md +1 -1
  10. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/_frames.py +3 -6
  11. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/_transport.py +16 -16
  12. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/cli.py +7 -14
  13. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/client.py +9 -10
  14. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/exceptions.py +1 -1
  15. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/fastapi/dependencies.py +4 -4
  16. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/fastapi/routes.py +12 -12
  17. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/openapi_schemas.py +22 -453
  18. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/oauth.py +54 -12
  19. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/streamlit.py +16 -14
  20. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_account_status.py +3 -3
  21. sweatstack-0.97.0/tests/test_authorization_url.py +103 -0
  22. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_exceptions.py +7 -7
  23. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_frames.py +2 -23
  24. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_identity.py +3 -3
  25. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_portal.py +3 -3
  26. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_resources.py +3 -4
  27. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_tests.py +5 -5
  28. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_trace_test_linking.py +4 -4
  29. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_transport.py +44 -22
  30. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_user_agent.py +10 -10
  31. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_webhooks.py +10 -10
  32. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_wire_names.py +1 -0
  33. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_write_timezone_validation.py +2 -2
  34. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/typing/output_types.py +1 -1
  35. {sweatstack-0.95.0 → sweatstack-0.97.0}/uv.lock +70 -508
  36. {sweatstack-0.95.0 → sweatstack-0.97.0}/.git-blame-ignore-revs +0 -0
  37. {sweatstack-0.95.0 → sweatstack-0.97.0}/.gitignore +0 -0
  38. {sweatstack-0.95.0 → sweatstack-0.97.0}/.gitleaks.toml +0 -0
  39. {sweatstack-0.95.0 → sweatstack-0.97.0}/.python-version +0 -0
  40. {sweatstack-0.95.0 → sweatstack-0.97.0}/CONTRIBUTING.md +0 -0
  41. {sweatstack-0.95.0 → sweatstack-0.97.0}/LICENSE +0 -0
  42. {sweatstack-0.95.0 → sweatstack-0.97.0}/Makefile +0 -0
  43. {sweatstack-0.95.0 → sweatstack-0.97.0}/README.md +0 -0
  44. {sweatstack-0.95.0 → sweatstack-0.97.0}/UPGRADING.md +0 -0
  45. {sweatstack-0.95.0 → sweatstack-0.97.0}/evals/__init__.py +0 -0
  46. {sweatstack-0.95.0 → sweatstack-0.97.0}/evals/agent/__init__.py +0 -0
  47. {sweatstack-0.95.0 → sweatstack-0.97.0}/evals/agent/agent.py +0 -0
  48. {sweatstack-0.95.0 → sweatstack-0.97.0}/evals/agent/cases.py +0 -0
  49. {sweatstack-0.95.0 → sweatstack-0.97.0}/evals/agent/evaluators.py +0 -0
  50. {sweatstack-0.95.0 → sweatstack-0.97.0}/evals/agent/harness.py +0 -0
  51. {sweatstack-0.95.0 → sweatstack-0.97.0}/evals/agent/lifecycle.py +0 -0
  52. {sweatstack-0.95.0 → sweatstack-0.97.0}/evals/agent/run.py +0 -0
  53. {sweatstack-0.95.0 → sweatstack-0.97.0}/examples/fastapi_webhooks_example.py +0 -0
  54. {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/001a_tests.md +0 -0
  55. {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/001b_metadata.md +0 -0
  56. {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/001c_dailies.md +0 -0
  57. {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
  58. {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/003_trace_test_linking.md +0 -0
  59. {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/004_codebase_hygiene.md +0 -0
  60. {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/005_ost_sport_bridge.md +0 -0
  61. {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/006_output_backends.md +0 -0
  62. {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/007_account_status_userinfo_portal.md +0 -0
  63. {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/008_sdk_documentation.md +0 -0
  64. {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/009_road_to_1_0.md +0 -0
  65. {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/010_credentials_one_source.md +0 -0
  66. {sweatstack-0.95.0 → sweatstack-0.97.0}/plans/011_user_agent_on_every_request.md +0 -0
  67. {sweatstack-0.95.0 → sweatstack-0.97.0}/skills/sweatstack-python/data-models.md +0 -0
  68. {sweatstack-0.95.0 → sweatstack-0.97.0}/skills/sweatstack-python/fastapi.md +0 -0
  69. {sweatstack-0.95.0 → sweatstack-0.97.0}/skills/sweatstack-python/streamlit.md +0 -0
  70. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/__init__.py +0 -0
  71. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/_docs.py +0 -0
  72. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/_renames.py +0 -0
  73. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/constants.py +0 -0
  74. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/fastapi/__init__.py +0 -0
  75. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
  76. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/fastapi/config.py +0 -0
  77. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/fastapi/models.py +0 -0
  78. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/fastapi/session.py +0 -0
  79. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/fastapi/token_stores.py +0 -0
  80. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/fastapi/webhooks.py +0 -0
  81. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/py.typed +0 -0
  82. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/__init__.py +0 -0
  83. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/_app_metadata.py +0 -0
  84. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/_base.py +0 -0
  85. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/activities.py +0 -0
  86. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/dailies.py +0 -0
  87. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/portal.py +0 -0
  88. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/profile.py +0 -0
  89. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/teams.py +0 -0
  90. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/tests.py +0 -0
  91. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/traces.py +0 -0
  92. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/resources/users.py +0 -0
  93. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/schemas.py +0 -0
  94. {sweatstack-0.95.0 → sweatstack-0.97.0}/src/sweatstack/utils.py +0 -0
  95. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/__init__.py +0 -0
  96. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/conftest.py +0 -0
  97. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_access_token_cache.py +0 -0
  98. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_changelog.py +0 -0
  99. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_credentials.py +0 -0
  100. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_dailies.py +0 -0
  101. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_docs_check.py +0 -0
  102. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_docstrings.py +0 -0
  103. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_dtype_conversion.py +0 -0
  104. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_longitudinal_mean_max_after.py +0 -0
  105. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_metadata.py +0 -0
  106. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_output.py +0 -0
  107. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_public_hygiene.py +0 -0
  108. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_public_surface.py +0 -0
  109. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_renames.py +0 -0
  110. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_segmentation.py +0 -0
  111. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_skill.py +0 -0
  112. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_sport_ost.py +0 -0
  113. {sweatstack-0.95.0 → sweatstack-0.97.0}/tests/test_streamlit_auth.py +0 -0
  114. {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.10", "3.11", "3.12", "3.13"]
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 httpx types leak.
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 `httpx` params:
237
- `httpx` calls `str()`, which gives `"TraceResolution.linked"`, not `"linked"`.
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 `httpx`.
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.10 is supported; develop against the version pinned in
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.95.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.10
23
+ Requires-Python: >=3.11
25
24
  Requires-Dist: email-validator>=2.2.0
26
- Requires-Dist: httpx>=0.28.1
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
- # "httpx",
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 datetime, timezone
35
+ from datetime import UTC, datetime
36
36
 
37
- import httpx
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
- ) -> httpx.Response:
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(timezone.utc).isoformat(),
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 = httpx.post(
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 httpx.ConnectError:
179
+ except httpx2.ConnectError:
180
180
  print(f"✗ Connection failed - is the server running at {args.url}?")
181
181
  sys.exit(1)
182
- except httpx.TimeoutException:
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.95.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"
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
- "httpx>=0.28.1",
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 = "py310"
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 `httpx`. `SweatStackAPIError` (has
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 datetime, timezone
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=timezone.utc) # naive value in a UTC column
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(timezone.utc).replace(tzinfo=None)
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 ``httpx`` to make a request:
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 httpx
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: httpx.Response) -> float | None:
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: httpx.Response | None = None,
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) -> httpx.Response:
84
- """``httpx.post`` with the SDK's ``User-Agent``; the caller's headers win on a clash.
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 = httpx.Headers({"User-Agent": USER_AGENT})
90
+ headers = httpx2.Headers({"User-Agent": USER_AGENT})
91
91
  headers.update(kwargs.pop("headers", None) or {})
92
- return httpx.post(url, headers=headers, **kwargs)
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: httpx.Client, headers: httpx.Headers, timeout: float) -> None:
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) -> httpx.Response:
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) -> httpx.Response:
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) -> httpx.Response:
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) -> httpx.Response:
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) -> httpx.Response:
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[httpx.Response]:
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 = httpx.Timeout(self._timeout, read=None)
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 httpx
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``, and let the fields the server
71
- sends without an offset (``registered_at``, ``backfill_loaded_until``) accept either. AST-anchored and idempotent, so it survives regeneration (and replaces the
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 = httpx.get("http://localhost:8080/openapi.json")
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 httpx
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) -> httpx.Client:
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 = httpx.Client(
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 raw httpx types.
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 = httpx.Headers({"User-Agent": USER_AGENT})
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 httpx.HTTPError as exc:
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
- ) -> httpx.Response:
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: httpx.Response) -> None:
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: httpx.Response) -> dict | str | None:
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 httpx types are exposed — this is the public error contract.
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 datetime, timezone
9
+ from datetime import UTC, datetime
10
10
  from typing import Annotated, NoReturn
11
11
  from urllib.parse import quote
12
12
 
13
- import httpx
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 = httpx.Timeout(10.0, connect=5.0)
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=timezone.utc)
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 urlencode, urlparse
9
+ from urllib.parse import urlparse
10
10
 
11
- import httpx
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 httpx.HTTPError as exc:
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
- params = {
166
- "client_id": config.client_id,
167
- "redirect_uri": config.redirect_uri,
168
- "scope": " ".join(config.scopes),
169
- "state": state,
170
- "prompt": "none",
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 httpx.HTTPError as e:
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