sweatstack 0.96.0__tar.gz → 0.98.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.96.0 → sweatstack-0.98.0}/.github/workflows/ci.yml +1 -1
  2. {sweatstack-0.96.0 → sweatstack-0.98.0}/AGENTS.md +5 -5
  3. {sweatstack-0.96.0 → sweatstack-0.98.0}/CHANGELOG.md +46 -0
  4. {sweatstack-0.96.0 → sweatstack-0.98.0}/DEVELOPMENT.md +1 -1
  5. {sweatstack-0.96.0 → sweatstack-0.98.0}/PKG-INFO +4 -4
  6. {sweatstack-0.96.0 → sweatstack-0.98.0}/examples/send_webhook.py +8 -8
  7. {sweatstack-0.96.0 → sweatstack-0.98.0}/pyproject.toml +6 -5
  8. {sweatstack-0.96.0 → sweatstack-0.98.0}/skills/sweatstack-python/SKILL.md +3 -2
  9. {sweatstack-0.96.0 → sweatstack-0.98.0}/skills/sweatstack-python/api.md +2 -1
  10. {sweatstack-0.96.0 → sweatstack-0.98.0}/skills/sweatstack-python/data-models.md +2 -1
  11. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/_frames.py +15 -7
  12. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/_transport.py +16 -16
  13. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/cli.py +2 -2
  14. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/client.py +96 -23
  15. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/exceptions.py +1 -1
  16. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/dependencies.py +4 -4
  17. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/routes.py +3 -3
  18. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/schemas.py +15 -6
  19. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/streamlit.py +2 -2
  20. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_account_status.py +3 -3
  21. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_credentials.py +148 -1
  22. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_dailies.py +47 -2
  23. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_exceptions.py +7 -7
  24. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_frames.py +2 -2
  25. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_identity.py +3 -3
  26. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_output.py +22 -0
  27. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_portal.py +3 -3
  28. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_resources.py +1 -1
  29. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_tests.py +5 -5
  30. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_trace_test_linking.py +4 -4
  31. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_transport.py +44 -22
  32. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_user_agent.py +10 -10
  33. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_webhooks.py +10 -10
  34. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_write_timezone_validation.py +2 -2
  35. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/typing/output_types.py +1 -1
  36. {sweatstack-0.96.0 → sweatstack-0.98.0}/uv.lock +72 -508
  37. {sweatstack-0.96.0 → sweatstack-0.98.0}/.git-blame-ignore-revs +0 -0
  38. {sweatstack-0.96.0 → sweatstack-0.98.0}/.gitignore +0 -0
  39. {sweatstack-0.96.0 → sweatstack-0.98.0}/.gitleaks.toml +0 -0
  40. {sweatstack-0.96.0 → sweatstack-0.98.0}/.python-version +0 -0
  41. {sweatstack-0.96.0 → sweatstack-0.98.0}/CONTRIBUTING.md +0 -0
  42. {sweatstack-0.96.0 → sweatstack-0.98.0}/LICENSE +0 -0
  43. {sweatstack-0.96.0 → sweatstack-0.98.0}/Makefile +0 -0
  44. {sweatstack-0.96.0 → sweatstack-0.98.0}/README.md +0 -0
  45. {sweatstack-0.96.0 → sweatstack-0.98.0}/UPGRADING.md +0 -0
  46. {sweatstack-0.96.0 → sweatstack-0.98.0}/evals/__init__.py +0 -0
  47. {sweatstack-0.96.0 → sweatstack-0.98.0}/evals/agent/__init__.py +0 -0
  48. {sweatstack-0.96.0 → sweatstack-0.98.0}/evals/agent/agent.py +0 -0
  49. {sweatstack-0.96.0 → sweatstack-0.98.0}/evals/agent/cases.py +0 -0
  50. {sweatstack-0.96.0 → sweatstack-0.98.0}/evals/agent/evaluators.py +0 -0
  51. {sweatstack-0.96.0 → sweatstack-0.98.0}/evals/agent/harness.py +0 -0
  52. {sweatstack-0.96.0 → sweatstack-0.98.0}/evals/agent/lifecycle.py +0 -0
  53. {sweatstack-0.96.0 → sweatstack-0.98.0}/evals/agent/run.py +0 -0
  54. {sweatstack-0.96.0 → sweatstack-0.98.0}/examples/fastapi_webhooks_example.py +0 -0
  55. {sweatstack-0.96.0 → sweatstack-0.98.0}/plans/001a_tests.md +0 -0
  56. {sweatstack-0.96.0 → sweatstack-0.98.0}/plans/001b_metadata.md +0 -0
  57. {sweatstack-0.96.0 → sweatstack-0.98.0}/plans/001c_dailies.md +0 -0
  58. {sweatstack-0.96.0 → sweatstack-0.98.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
  59. {sweatstack-0.96.0 → sweatstack-0.98.0}/plans/003_trace_test_linking.md +0 -0
  60. {sweatstack-0.96.0 → sweatstack-0.98.0}/plans/004_codebase_hygiene.md +0 -0
  61. {sweatstack-0.96.0 → sweatstack-0.98.0}/plans/005_ost_sport_bridge.md +0 -0
  62. {sweatstack-0.96.0 → sweatstack-0.98.0}/plans/006_output_backends.md +0 -0
  63. {sweatstack-0.96.0 → sweatstack-0.98.0}/plans/007_account_status_userinfo_portal.md +0 -0
  64. {sweatstack-0.96.0 → sweatstack-0.98.0}/plans/008_sdk_documentation.md +0 -0
  65. {sweatstack-0.96.0 → sweatstack-0.98.0}/plans/009_road_to_1_0.md +0 -0
  66. {sweatstack-0.96.0 → sweatstack-0.98.0}/plans/010_credentials_one_source.md +0 -0
  67. {sweatstack-0.96.0 → sweatstack-0.98.0}/plans/011_user_agent_on_every_request.md +0 -0
  68. {sweatstack-0.96.0 → sweatstack-0.98.0}/skills/sweatstack-python/fastapi.md +0 -0
  69. {sweatstack-0.96.0 → sweatstack-0.98.0}/skills/sweatstack-python/streamlit.md +0 -0
  70. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/__init__.py +0 -0
  71. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/_docs.py +0 -0
  72. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/_renames.py +0 -0
  73. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/constants.py +0 -0
  74. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/__init__.py +0 -0
  75. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
  76. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/config.py +0 -0
  77. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/models.py +0 -0
  78. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/session.py +0 -0
  79. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/token_stores.py +0 -0
  80. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/fastapi/webhooks.py +0 -0
  81. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/openapi_schemas.py +0 -0
  82. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/py.typed +0 -0
  83. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/resources/__init__.py +0 -0
  84. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/resources/_app_metadata.py +0 -0
  85. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/resources/_base.py +0 -0
  86. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/resources/activities.py +0 -0
  87. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/resources/dailies.py +0 -0
  88. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/resources/oauth.py +0 -0
  89. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/resources/portal.py +0 -0
  90. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/resources/profile.py +0 -0
  91. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/resources/teams.py +0 -0
  92. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/resources/tests.py +0 -0
  93. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/resources/traces.py +0 -0
  94. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/resources/users.py +0 -0
  95. {sweatstack-0.96.0 → sweatstack-0.98.0}/src/sweatstack/utils.py +0 -0
  96. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/__init__.py +0 -0
  97. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/conftest.py +0 -0
  98. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_access_token_cache.py +0 -0
  99. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_authorization_url.py +0 -0
  100. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_changelog.py +0 -0
  101. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_docs_check.py +0 -0
  102. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_docstrings.py +0 -0
  103. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_dtype_conversion.py +0 -0
  104. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_longitudinal_mean_max_after.py +0 -0
  105. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_metadata.py +0 -0
  106. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_public_hygiene.py +0 -0
  107. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_public_surface.py +0 -0
  108. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_renames.py +0 -0
  109. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_segmentation.py +0 -0
  110. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_skill.py +0 -0
  111. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_sport_ost.py +0 -0
  112. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_streamlit_auth.py +0 -0
  113. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_teams.py +0 -0
  114. {sweatstack-0.96.0 → sweatstack-0.98.0}/tests/test_wire_names.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, ...
@@ -137,7 +137,7 @@ it automatically.
137
137
  - **Enum-typed params accept `Enum | str`** and route through
138
138
  `_enums_to_strings`. Don't introduce strict-enum-only parameters.
139
139
  - **Open enums stay open.** When the server documents an enum as an open
140
- set (metrics, scopes, daily measures, status codes, capabilities),
140
+ set (metrics, scopes, daily measures, sources, status codes, capabilities),
141
141
  register it with `_open_enum(...)` in `schemas.py` so unknown values
142
142
  parse as pseudo-members. Leave closed sets strict.
143
143
  - **Never retry a request the server can't safely repeat.** `_transport.RETRYABLE_METHODS`
@@ -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,52 @@ 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.98.0] - 2026-10-30
15
+
16
+ ### Added
17
+
18
+ - `SWEATSTACK_CLIENT_ID` and `SWEATSTACK_CLIENT_SECRET` name the app that issued
19
+ `SWEATSTACK_API_KEY` and `SWEATSTACK_REFRESH_TOKEN`, so a client that takes its tokens from the
20
+ environment renews them with that app. Before, it renewed with the SDK's own client, which
21
+ SweatStack refuses for a token issued to another app: tokens your app hands to a notebook, a
22
+ job or a sandbox stopped working once the access token expired. Set the secret only if your app
23
+ exchanged the sign-in code with it. The variables never apply to tokens saved by
24
+ `authenticate()` or passed to `Client`, and `client_id=` and `client_secret=` given to `Client`
25
+ always win.
26
+
27
+ ### Changed
28
+
29
+ - If your environment sets `SWEATSTACK_CLIENT_ID` for the Streamlit or FastAPI helper and also
30
+ holds a `SWEATSTACK_REFRESH_TOKEN` that `authenticate()` issued, a plain `Client()` there now
31
+ renews that token with your app, which SweatStack refuses: the renewal fails once the access
32
+ token expires, with an error that says why. Unset `SWEATSTACK_CLIENT_ID` and
33
+ `SWEATSTACK_CLIENT_SECRET` for that process, or pass them to the helper as `client_id=` and
34
+ `client_secret=` instead.
35
+ - `DailySource` is an open enum, like `Metric` and `DailyMeasure`: a `source` this version does
36
+ not know (on a daily, a marker or a max effort) becomes a `DailySource` with that value instead
37
+ of failing the whole response, so a new source on SweatStack never breaks this version.
38
+
39
+ ### Fixed
40
+
41
+ - Polars output works in the browser (Pyodide, as in JupyterLite). Pyodide's Polars has no
42
+ Parquet reader, so every time series and mean-max call raised an `AttributeError`; the client
43
+ now reads through pyarrow there, and `sweatstack[polars]` installs pyarrow on Pyodide.
44
+
45
+
46
+ ## [0.97.0] - 2026-10-22
47
+
48
+ ### Changed
49
+
50
+ - Requires Python 3.11 or later. On Python 3.10, pip and uv keep installing 0.96.
51
+ - The SDK sends its requests with [`httpx2`](https://github.com/pydantic/httpx2), the maintained
52
+ continuation of `httpx`. Calling the SDK is unchanged, and errors are still the `SweatStack*`
53
+ exceptions.
54
+ - HTTPS certificates are checked against the operating system's trust store instead of the
55
+ `certifi` bundle that `httpx` shipped. A root certificate installed on the machine, such as a
56
+ company proxy's, is now trusted. A Linux image without a CA bundle (the `ca-certificates`
57
+ package) can no longer connect; install it, or point `SSL_CERT_FILE` at a bundle.
58
+
59
+
14
60
  ## [0.96.0] - 2026-10-16
15
61
 
16
62
  ### Added
@@ -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.96.0
3
+ Version: 0.98.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
@@ -37,6 +36,7 @@ Requires-Dist: pandas>=2.2.3; extra == 'pandas'
37
36
  Requires-Dist: pyarrow>=20.0.0; extra == 'pandas'
38
37
  Provides-Extra: polars
39
38
  Requires-Dist: polars>=1.30; extra == 'polars'
39
+ Requires-Dist: pyarrow>=20.0.0; (sys_platform == 'emscripten') and extra == 'polars'
40
40
  Provides-Extra: streamlit
41
41
  Requires-Dist: pandas>=2.2.3; extra == 'streamlit'
42
42
  Requires-Dist: pyarrow>=20.0.0; extra == 'streamlit'
@@ -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.96.0"
3
+ version = "0.98.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",
@@ -54,6 +53,8 @@ polars = [
54
53
  # >=1.30 verified: reads the server's float16 columns (as Float32 before Float16
55
54
  # support landed, as Float16 after; the client casts to Float32 either way).
56
55
  "polars>=1.30",
56
+ # Pyodide's Polars has no Parquet reader; the client reads through pyarrow there.
57
+ "pyarrow>=20.0.0; sys_platform == 'emscripten'",
57
58
  ]
58
59
  streamlit = [
59
60
  "sweatstack[pandas]",
@@ -91,7 +92,7 @@ evals = [
91
92
 
92
93
  [tool.ruff]
93
94
  line-length = 100
94
- target-version = "py310"
95
+ target-version = "py311"
95
96
  extend-exclude = ["src/sweatstack/openapi_schemas.py"]
96
97
 
97
98
  [tool.ruff.lint]
@@ -88,7 +88,8 @@ pandas (`summary.power.mean`) and structs in Polars (`.unnest("summary")`).
88
88
  ## Authentication
89
89
 
90
90
  - Scripts and notebooks: `client.authenticate()` (browser once, tokens saved on the machine).
91
- - Headless: set `SWEATSTACK_API_KEY` (and `SWEATSTACK_REFRESH_TOKEN` for refresh); no
91
+ - Headless: set `SWEATSTACK_API_KEY` (and `SWEATSTACK_REFRESH_TOKEN` for refresh, with
92
+ `SWEATSTACK_CLIENT_ID` and `SWEATSTACK_CLIENT_SECRET` when your own app issued them); no
92
93
  `authenticate()` call.
93
94
  - Apps where other people sign in: the Streamlit or FastAPI helper, see
94
95
  [streamlit.md](streamlit.md) and [fastapi.md](fastapi.md). In your own server, build one
@@ -98,7 +99,7 @@ pandas (`summary.power.mean`) and structs in Polars (`.unnest("summary")`).
98
99
 
99
100
  ## Errors
100
101
 
101
- Everything derives from `SweatStackError`; never import `httpx`. `SweatStackAPIError` (has
102
+ Everything derives from `SweatStackError`; never import `httpx2`. `SweatStackAPIError` (has
102
103
  `status_code`, `url`, `method`, `request_id`, `body`) splits into `SweatStackAuthError` (401,
103
104
  403), `SweatStackNotFoundError` (404), `SweatStackRateLimitError` (429, `retry_after`),
104
105
  `SweatStackBadRequestError` (other 4xx) and `SweatStackServerError` (5xx). Also
@@ -124,7 +124,8 @@ GET, PUT and DELETE are retried (connection errors, timeouts, 408, 429, 5xx) up
124
124
  `max_retries` times; POST never is. Use `max_retries=0` inside web requests.
125
125
 
126
126
  The access and refresh token come from one source: the arguments, else `SWEATSTACK_API_KEY`
127
- and `SWEATSTACK_REFRESH_TOKEN`, else the tokens `authenticate()` saved on this machine. A
127
+ and `SWEATSTACK_REFRESH_TOKEN` (renewed by the app in `SWEATSTACK_CLIENT_ID` and
128
+ `SWEATSTACK_CLIENT_SECRET`, if set), else the tokens `authenticate()` saved on this machine. A
128
129
  client given only an access token cannot renew it and raises `SweatStackTokenRefreshError` on
129
130
  expiry. In a server, build each user's client from that user's tokens with
130
131
  `ambient_credentials=False`: it then never reads or writes the machine's tokens.
@@ -48,7 +48,8 @@ taxonomy is preserved (`is_standard` is `False`) instead of failing.
48
48
  `end_local`, `results` (`TestResults`: thresholds `lt1`, `lt2`, `vt1`, `vt2`, `mlss`, `fatmax`
49
49
  as `Marker`s, plus `vo2max`, `critical_power`, `w_prime`, ...), `tags`, `created_by`;
50
50
  `TestDetails` adds the resolved `traces` and overlapping `activities`.
51
- - **DailyResponse**: `date`, `value`, `status`, `source`.
51
+ - **DailyResponse**: `date`, `value`, `status`, `source` (`DailySource`, an open set: `manual`,
52
+ `garmin_connect`, ...; markers and max efforts carry it too).
52
53
  - **UserSummary** (`users.list()`): `id`, `first_name`, `last_name`, `display_name`, `scopes`,
53
54
  `is_managed`.
54
55
  - **UserResponse** (`users.create()`, `retrieve()`, `update()`): `id`, `first_name`,
@@ -20,9 +20,10 @@ package imports without any of them (enforced by CI's bare-install job).
20
20
  from __future__ import annotations
21
21
 
22
22
  import json
23
+ import sys
23
24
  import warnings
24
25
  from collections.abc import Sequence
25
- from datetime import datetime, timezone
26
+ from datetime import UTC, datetime
26
27
  from enum import Enum
27
28
  from functools import cache
28
29
  from importlib import import_module
@@ -185,15 +186,22 @@ def parquet_to_pandas(content: bytes) -> pd.DataFrame:
185
186
 
186
187
  def parquet_to_polars(content: bytes) -> pl.DataFrame:
187
188
  """Parquet bytes to a Polars DataFrame with the wire dtypes, except
188
- Float16 -> Float32 (Polars supports few operations on Float16)."""
189
+ Float16 -> Float32 (Polars supports few operations on Float16).
190
+
191
+ In the browser (Pyodide), Polars is built without its Parquet reader, so the bytes go
192
+ through pyarrow, which Pyodide ships, and ``pl.from_arrow``."""
189
193
  pl = require("polars")
190
194
 
191
- df = pl.read_parquet(BytesIO(content))
195
+ if sys.platform == "emscripten":
196
+ df = pl.from_arrow(parquet_to_arrow(content))
197
+ else:
198
+ df = pl.read_parquet(BytesIO(content))
199
+ metadata = pl.read_parquet_metadata(BytesIO(content))
200
+ df = df.select(_index_columns_first(df.columns, metadata.get("pandas")))
192
201
  float16 = getattr(pl, "Float16", None) # older Polars already reads float16 as Float32
193
202
  if float16 is not None:
194
203
  df = df.cast({name: pl.Float32 for name, dtype in df.schema.items() if dtype == float16})
195
- metadata = pl.read_parquet_metadata(BytesIO(content))
196
- return df.select(_index_columns_first(df.columns, metadata.get("pandas")))
204
+ return df
197
205
 
198
206
 
199
207
  def parquet_to_arrow(content: bytes) -> Any:
@@ -489,7 +497,7 @@ def _plain_value(value: Any, node: FieldType) -> Any:
489
497
  if node == "string":
490
498
  return value if isinstance(value, str) else str(value) # Sport and other value objects
491
499
  if node == "datetime" and isinstance(value, datetime) and value.tzinfo is None:
492
- return value.replace(tzinfo=timezone.utc) # naive value in a UTC column
500
+ return value.replace(tzinfo=UTC) # naive value in a UTC column
493
501
  if node == "naive_datetime" and isinstance(value, datetime) and value.tzinfo is not None:
494
- return value.astimezone(timezone.utc).replace(tzinfo=None)
502
+ return value.astimezone(UTC).replace(tzinfo=None)
495
503
  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
 
@@ -95,7 +95,7 @@ def _restore_naive_local_datetimes(path: Path) -> None:
95
95
 
96
96
 
97
97
  def generate_response_models():
98
- response = httpx.get("http://localhost:8080/openapi.json")
98
+ response = httpx2.get("http://localhost:8080/openapi.json")
99
99
  response.raise_for_status()
100
100
  output_directory = Path(__file__).parent
101
101
  output = Path(output_directory / "openapi_schemas.py")