sweatstack 0.87.0__tar.gz → 0.88.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 (68) hide show
  1. {sweatstack-0.87.0 → sweatstack-0.88.0}/CHANGELOG.md +12 -0
  2. {sweatstack-0.87.0 → sweatstack-0.88.0}/PKG-INFO +1 -1
  3. {sweatstack-0.87.0 → sweatstack-0.88.0}/pyproject.toml +1 -1
  4. {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/client.py +50 -14
  5. {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/dependencies.py +0 -9
  6. {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/streamlit.py +2 -2
  7. {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_access_token_cache.py +5 -5
  8. {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_longitudinal_mean_max_after.py +19 -6
  9. sweatstack-0.88.0/tests/test_write_timezone_validation.py +72 -0
  10. {sweatstack-0.87.0 → sweatstack-0.88.0}/uv.lock +1 -1
  11. {sweatstack-0.87.0 → sweatstack-0.88.0}/.claude/settings.local.json +0 -0
  12. {sweatstack-0.87.0 → sweatstack-0.88.0}/.claude/skills/sweatstack-python/SKILL.md +0 -0
  13. {sweatstack-0.87.0 → sweatstack-0.88.0}/.claude/skills/sweatstack-python/client.md +0 -0
  14. {sweatstack-0.87.0 → sweatstack-0.88.0}/.claude/skills/sweatstack-python/data-models.md +0 -0
  15. {sweatstack-0.87.0 → sweatstack-0.88.0}/.claude/skills/sweatstack-python/fastapi.md +0 -0
  16. {sweatstack-0.87.0 → sweatstack-0.88.0}/.claude/skills/sweatstack-python/streamlit.md +0 -0
  17. {sweatstack-0.87.0 → sweatstack-0.88.0}/.gitignore +0 -0
  18. {sweatstack-0.87.0 → sweatstack-0.88.0}/.python-version +0 -0
  19. {sweatstack-0.87.0 → sweatstack-0.88.0}/AGENTS.md +0 -0
  20. {sweatstack-0.87.0 → sweatstack-0.88.0}/CONTRIBUTING.md +0 -0
  21. {sweatstack-0.87.0 → sweatstack-0.88.0}/DEVELOPMENT.md +0 -0
  22. {sweatstack-0.87.0 → sweatstack-0.88.0}/LICENSE +0 -0
  23. {sweatstack-0.87.0 → sweatstack-0.88.0}/Makefile +0 -0
  24. {sweatstack-0.87.0 → sweatstack-0.88.0}/README.md +0 -0
  25. {sweatstack-0.87.0 → sweatstack-0.88.0}/docs/conf.py +0 -0
  26. {sweatstack-0.87.0 → sweatstack-0.88.0}/docs/everything.rst +0 -0
  27. {sweatstack-0.87.0 → sweatstack-0.88.0}/docs/index.rst +0 -0
  28. {sweatstack-0.87.0 → sweatstack-0.88.0}/examples/fastapi_webhooks_example.py +0 -0
  29. {sweatstack-0.87.0 → sweatstack-0.88.0}/examples/send_webhook.py +0 -0
  30. {sweatstack-0.87.0 → sweatstack-0.88.0}/plans/001a_tests.md +0 -0
  31. {sweatstack-0.87.0 → sweatstack-0.88.0}/plans/001b_metadata.md +0 -0
  32. {sweatstack-0.87.0 → sweatstack-0.88.0}/plans/001c_dailies.md +0 -0
  33. {sweatstack-0.87.0 → sweatstack-0.88.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
  34. {sweatstack-0.87.0 → sweatstack-0.88.0}/plans/003_trace_test_linking.md +0 -0
  35. {sweatstack-0.87.0 → sweatstack-0.88.0}/plans/004_codebase_hygiene.md +0 -0
  36. {sweatstack-0.87.0 → sweatstack-0.88.0}/plans/005_ost_sport_bridge.md +0 -0
  37. {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
  38. {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/__init__.py +0 -0
  39. {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/cli.py +0 -0
  40. {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/constants.py +0 -0
  41. {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/exceptions.py +0 -0
  42. {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/__init__.py +0 -0
  43. {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
  44. {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/config.py +0 -0
  45. {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/models.py +0 -0
  46. {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/routes.py +0 -0
  47. {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/session.py +0 -0
  48. {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/token_stores.py +0 -0
  49. {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/webhooks.py +0 -0
  50. {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/ipython_init.py +0 -0
  51. {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
  52. {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/openapi_schemas.py +0 -0
  53. {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/py.typed +0 -0
  54. {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/schemas.py +0 -0
  55. {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/sweatshell.py +0 -0
  56. {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/utils.py +0 -0
  57. {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/__init__.py +0 -0
  58. {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_dailies.py +0 -0
  59. {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_dtype_conversion.py +0 -0
  60. {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_exceptions.py +0 -0
  61. {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_metadata.py +0 -0
  62. {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_public_surface.py +0 -0
  63. {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_segmentation.py +0 -0
  64. {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_sport_ost.py +0 -0
  65. {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_teams.py +0 -0
  66. {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_tests.py +0 -0
  67. {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_trace_test_linking.py +0 -0
  68. {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_webhooks.py +0 -0
@@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
 
9
+ ## [0.88.0] - 2026-08-06
10
+
11
+ ### Changed
12
+
13
+ - **Breaking:** `create_trace`, `update_trace`, `create_test`, and `update_test` now require timezone-aware datetimes for `timestamp`, `start`, and `end`. A naive datetime raises `ValueError` before the request is sent. Attach a zone, e.g. `datetime(..., tzinfo=ZoneInfo("Europe/Amsterdam"))` or `datetime.now(timezone.utc)`; the offset is stored alongside the instant.
14
+ - `start`, `end`, and `timestamp` on responses are absolute UTC instants (ISO 8601 with a `Z` suffix), no longer a fixed per-record local offset. For wall-clock display use the companion `start_local` / `end_local` / `timestamp_local` fields, which are always present. The Streamlit activity selector now labels activities by their local date.
15
+ - Requires a SweatStack server with offset-based timezone handling (plan 033). Against an older server the aware-datetime writes still work, but responses keep the previous fixed-offset `start`/`end`/`timestamp`.
16
+
17
+ ### Removed
18
+
19
+ - Token refresh no longer sends a `tz` field. The server derives all timezone information from the offsets stored with each record, so the client has nothing to pass.
20
+
9
21
  ## [0.87.0] - 2026-06-30
10
22
 
11
23
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sweatstack
3
- Version: 0.87.0
3
+ Version: 0.88.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/getting-started/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sweatstack"
3
- version = "0.87.0"
3
+ version = "0.88.0"
4
4
  description = "The official Python client for SweatStack"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -780,11 +780,10 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
780
780
 
781
781
  return access_token, refresh_token
782
782
 
783
- def _do_token_refresh(self, tz: str, refresh_token: str) -> str:
783
+ def _do_token_refresh(self, refresh_token: str) -> str:
784
784
  """Exchange refresh token for a new access token.
785
785
 
786
786
  Args:
787
- tz: Timezone from the expired token's JWT claims.
788
787
  refresh_token: The refresh token to use.
789
788
 
790
789
  Returns:
@@ -799,7 +798,6 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
799
798
  data={
800
799
  "grant_type": "refresh_token",
801
800
  "refresh_token": refresh_token,
802
- "tz": tz,
803
801
  "client_id": self.client_id,
804
802
  "client_secret": self._client_secret.get_secret_value() if self._client_secret else None,
805
803
  },
@@ -845,8 +843,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
845
843
  "Call client.authenticate(force=True) to re-authenticate."
846
844
  )
847
845
 
848
- tz = payload.get("tz", "UTC")
849
- new_access_token = self._do_token_refresh(tz, refresh_token)
846
+ new_access_token = self._do_token_refresh(refresh_token)
850
847
 
851
848
  # Update instance state
852
849
  self._api_key = SecretStr(new_access_token)
@@ -1025,6 +1022,26 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1025
1022
  out.append(value)
1026
1023
  return out
1027
1024
 
1025
+ @staticmethod
1026
+ def _require_aware(value: datetime, param: str) -> datetime:
1027
+ """Guard a write timestamp: it must carry an explicit UTC offset.
1028
+
1029
+ The API stores each timestamp as an absolute instant paired with its
1030
+ local offset, so it rejects naive datetimes with HTTP 422. Failing fast
1031
+ here names the offending argument and shows how to fix it, which the raw
1032
+ server error does not.
1033
+
1034
+ Raises:
1035
+ ValueError: If ``value`` is timezone-naive.
1036
+ """
1037
+ if value.tzinfo is None or value.tzinfo.utcoffset(value) is None:
1038
+ raise ValueError(
1039
+ f"{param} must be timezone-aware; got naive {value!r}. "
1040
+ f"Attach a zone, e.g. datetime(..., tzinfo=ZoneInfo('Europe/Amsterdam')) "
1041
+ f"or datetime.now(timezone.utc)."
1042
+ )
1043
+ return value
1044
+
1028
1045
  def _get_activities_generator(
1029
1046
  self,
1030
1047
  *,
@@ -1805,7 +1822,8 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1805
1822
  measurement values.
1806
1823
 
1807
1824
  Args:
1808
- timestamp: The date and time when the trace was recorded.
1825
+ timestamp: The date and time when the trace was recorded. Must be
1826
+ timezone-aware; the offset is stored alongside the instant.
1809
1827
  lactate: Optional blood lactate concentration in mmol/L.
1810
1828
  rpe: Optional rating of perceived exertion (typically on a scale of 1-10).
1811
1829
  notes: Optional text notes associated with this trace.
@@ -1815,7 +1833,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1815
1833
  tags: Optional list of tags to associate with this trace.
1816
1834
  sport: Optional sport to associate with this trace.
1817
1835
  test_id: Optional ID of a test to explicitly link this trace to.
1818
- The link is independent of timestamp — a linked trace appears
1836
+ The link is independent of timestamp: a linked trace appears
1819
1837
  in the test's traces list regardless of whether its timestamp
1820
1838
  falls inside the test window.
1821
1839
 
@@ -1823,10 +1841,12 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1823
1841
  TraceDetails: The created trace object with all details.
1824
1842
 
1825
1843
  Raises:
1844
+ ValueError: If ``timestamp`` is timezone-naive.
1826
1845
  SweatStackNotFoundError: If ``test_id`` references a test that
1827
1846
  does not exist.
1828
1847
  SweatStackAPIError: If the API request fails for any other reason.
1829
1848
  """
1849
+ self._require_aware(timestamp, "timestamp")
1830
1850
  sport = self._enums_to_strings([sport])[0] if sport else None
1831
1851
  with self._http_client() as client:
1832
1852
  response = client.post(
@@ -1874,7 +1894,8 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1874
1894
 
1875
1895
  Args:
1876
1896
  trace_id: The unique identifier of the trace to update.
1877
- timestamp: The date and time when the trace was recorded.
1897
+ timestamp: The date and time when the trace was recorded. Must be
1898
+ timezone-aware; the offset is stored alongside the instant.
1878
1899
  lactate: Optional blood lactate concentration in mmol/L.
1879
1900
  rpe: Optional rating of perceived exertion (typically on a scale of 1-10).
1880
1901
  notes: Optional text notes associated with this trace.
@@ -1887,10 +1908,12 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1887
1908
  Pass ``None`` (or omit) to leave the trace unlinked.
1888
1909
 
1889
1910
  Raises:
1911
+ ValueError: If ``timestamp`` is timezone-naive.
1890
1912
  SweatStackNotFoundError: If ``trace_id`` does not exist, or if
1891
1913
  ``test_id`` references a test that does not exist.
1892
1914
  SweatStackAPIError: If the API request fails for any other reason.
1893
1915
  """
1916
+ self._require_aware(timestamp, "timestamp")
1894
1917
  sport = self._enums_to_strings([sport])[0] if sport else None
1895
1918
  with self._http_client() as client:
1896
1919
  response = client.put(
@@ -2082,9 +2105,11 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
2082
2105
 
2083
2106
  Args:
2084
2107
  sport: The sport for this test. Can be a Sport enum or string ID.
2085
- start: The start time of the test.
2108
+ start: The start time of the test. Must be timezone-aware; the
2109
+ offset is stored alongside the instant.
2086
2110
  title: Optional title for the test.
2087
- end: Optional end time. Defaults to start + 3 hours server-side.
2111
+ end: Optional end time. Must be timezone-aware when given. Defaults
2112
+ to start + 3 hours server-side.
2088
2113
  results: Optional structured test results (thresholds, capacities, etc.).
2089
2114
  tags: Optional list of tags to associate with this test.
2090
2115
 
@@ -2092,8 +2117,12 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
2092
2117
  TestSummary: The created test.
2093
2118
 
2094
2119
  Raises:
2120
+ ValueError: If ``start`` or ``end`` is timezone-naive.
2095
2121
  SweatStackAPIError: If the API request fails.
2096
2122
  """
2123
+ self._require_aware(start, "start")
2124
+ if end is not None:
2125
+ self._require_aware(end, "end")
2097
2126
  sport = self._enums_to_strings([sport])[0]
2098
2127
  with self._http_client() as client:
2099
2128
  response = client.post(
@@ -2130,16 +2159,22 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
2130
2159
  Args:
2131
2160
  test_id: The unique identifier of the test to update.
2132
2161
  sport: The sport for this test. Can be a Sport enum or string ID.
2133
- start: The start time of the test.
2162
+ start: The start time of the test. Must be timezone-aware; the
2163
+ offset is stored alongside the instant.
2134
2164
  title: Optional title for the test.
2135
- end: Optional end time. Defaults to start + 3 hours server-side.
2165
+ end: Optional end time. Must be timezone-aware when given. Defaults
2166
+ to start + 3 hours server-side.
2136
2167
  results: Optional structured test results (thresholds, capacities, etc.).
2137
2168
  tags: Optional list of tags to associate with this test.
2138
2169
 
2139
2170
  Raises:
2171
+ ValueError: If ``start`` or ``end`` is timezone-naive.
2140
2172
  SweatStackNotFoundError: If the test does not exist.
2141
2173
  SweatStackAPIError: If the API request fails for any other reason.
2142
2174
  """
2175
+ self._require_aware(start, "start")
2176
+ if end is not None:
2177
+ self._require_aware(end, "end")
2143
2178
  sport = self._enums_to_strings([sport])[0]
2144
2179
  with self._http_client() as client:
2145
2180
  response = client.put(
@@ -2613,8 +2648,9 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
2613
2648
  """Uploads activity files (CSV or FIT).
2614
2649
 
2615
2650
  CSV files require the ``sport`` parameter and must contain a ``timestamp``
2616
- column with ISO 8601 datetimes. FIT files include sport metadata so
2617
- ``sport`` is optional for them.
2651
+ column with **offset-aware** ISO 8601 datetimes (e.g. ``...+02:00`` or
2652
+ ``...Z``); naive timestamps are rejected during processing. FIT files
2653
+ include sport metadata so ``sport`` is optional for them.
2618
2654
 
2619
2655
  Args:
2620
2656
  files: A file path, or a list of file paths, to upload.
@@ -102,17 +102,10 @@ def _extract_expiry(access_token: str) -> datetime:
102
102
  return datetime.fromtimestamp(body["exp"], tz=timezone.utc)
103
103
 
104
104
 
105
- def _extract_timezone(access_token: str) -> str:
106
- """Extract timezone from JWT access token."""
107
- body = decode_jwt_body(access_token)
108
- return body.get("tz", "UTC")
109
-
110
-
111
105
  def _refresh_access_token(
112
106
  refresh_token: str,
113
107
  client_id: str,
114
108
  client_secret: str,
115
- tz: str,
116
109
  ) -> tuple[str, str | None]:
117
110
  """Exchange a refresh token for a new access token.
118
111
 
@@ -133,7 +126,6 @@ def _refresh_access_token(
133
126
  "refresh_token": refresh_token,
134
127
  "client_id": client_id,
135
128
  "client_secret": client_secret,
136
- "tz": tz,
137
129
  },
138
130
  timeout=REFRESH_HTTP_TIMEOUT,
139
131
  )
@@ -217,7 +209,6 @@ def _resolve_access_token(
217
209
  refresh_token=refresh_token,
218
210
  client_id=config.client_id,
219
211
  client_secret=config.client_secret.get_secret_value(),
220
- tz=_extract_timezone(access_token),
221
212
  )
222
213
  except Exception:
223
214
  # Don't let a failed refresh strand a stale token in the cache.
@@ -74,7 +74,7 @@ class StreamlitAuth:
74
74
  # Use the authenticated client
75
75
  st.write("Welcome to SweatStack")
76
76
  latest_activity = auth.client.get_latest_activity()
77
- st.write(f"Latest activity: {latest_activity.sport} on {latest_activity.start}")
77
+ st.write(f"Latest activity: {latest_activity.sport} on {latest_activity.start_local}")
78
78
 
79
79
  # Switch between accessible users (admin feature)
80
80
  with st.sidebar:
@@ -466,7 +466,7 @@ class StreamlitAuth:
466
466
  selected_activity = st.selectbox(
467
467
  "Select an activity",
468
468
  activities,
469
- format_func=lambda activity: f"{activity.start.date().isoformat()} {activity.sport.label}",
469
+ format_func=lambda activity: f"{activity.start_local.date().isoformat()} {activity.sport.label}",
470
470
  )
471
471
  return selected_activity
472
472
 
@@ -69,14 +69,14 @@ def configure_app():
69
69
  # ---------------------------------------------------------------------------
70
70
 
71
71
 
72
- def _jwt(*, exp: float, tz: str = "UTC", sub: str = "user_1") -> str:
72
+ def _jwt(*, exp: float, sub: str = "user_1") -> str:
73
73
  """Build a JWT-shaped string whose body decodes to the given claims.
74
74
 
75
- The signature segment is unused — ``decode_jwt_body`` only reads the
76
- body — but we include a non-empty value so the token shape is
75
+ The signature segment is unused (``decode_jwt_body`` only reads the
76
+ body), but we include a non-empty value so the token shape is
77
77
  realistic.
78
78
  """
79
- body = {"exp": int(exp), "tz": tz, "sub": sub}
79
+ body = {"exp": int(exp), "sub": sub}
80
80
  encoded = base64.urlsafe_b64encode(json.dumps(body).encode()).rstrip(b"=").decode()
81
81
  return f"header.{encoded}.signature"
82
82
 
@@ -360,7 +360,7 @@ class TestResolveAccessToken:
360
360
  call_count = 0
361
361
  call_count_lock = threading.Lock()
362
362
 
363
- def slow_refresh(*, refresh_token, client_id, client_secret, tz):
363
+ def slow_refresh(*, refresh_token, client_id, client_secret):
364
364
  nonlocal call_count
365
365
  with call_count_lock:
366
366
  call_count += 1
@@ -47,16 +47,16 @@ def _call(client, **kwargs):
47
47
  return result, http.get.call_args.kwargs["params"]
48
48
 
49
49
 
50
- def test_after_list_is_sent_and_shape_is_metric_indexed(client):
50
+ def test_after_list_is_sent_and_default_power_is_duration_indexed(client):
51
51
  result, params = _call(client, after=[0, 500])
52
52
  # repeated query param
53
53
  assert params["after"] == [0, 500]
54
- # shape parallels the no-after curve: metric value is the index, `after` a column
55
- assert result.index.name == "power"
54
+ # `by` unset for after+power resolves to `duration` server-side (0.85.0+): the frame is
55
+ # duration-indexed, with `after` a column (mirroring the no-after curve's index).
56
+ assert result.index.name == "duration"
57
+ assert "power" in result.columns
56
58
  assert "after" in result.columns
57
59
  assert set(result["after"].unique()) == {0.0, 500.0}
58
- # postprocessed to standard dtypes
59
- assert result["duration"].dtype == "timedelta64[ns]"
60
60
 
61
61
 
62
62
  def test_single_after_is_normalised_to_a_list(client):
@@ -94,9 +94,22 @@ def _index_free_by_duration_response() -> bytes:
94
94
  return buf.getvalue()
95
95
 
96
96
 
97
- def test_by_defaults_to_intensity_and_is_sent(client):
97
+ def test_by_defaults_to_none_and_is_omitted(client):
98
+ # `by` unset -> omitted from the request so the server picks the orientation (0.85.0+),
99
+ # rather than the client forcing the deprecated value-indexed path.
98
100
  _result, params = _call(client, after=[0, 500])
101
+ assert "by" not in params
102
+
103
+
104
+ def test_after_with_explicit_intensity_is_metric_indexed_and_warns(client):
105
+ # The deprecated value-indexed orientation stays metric-indexed, is sent explicitly,
106
+ # and warns (preserves the coverage the old default-intensity test used to give).
107
+ with pytest.warns(DeprecationWarning):
108
+ result, params = _call(client, after=[0, 500], by="intensity")
99
109
  assert params["by"] == "intensity"
110
+ assert result.index.name == "power"
111
+ assert "after" in result.columns
112
+ assert result["duration"].dtype == "timedelta64[ns]"
100
113
 
101
114
 
102
115
  def test_by_duration_is_sent_and_duration_indexed(client):
@@ -0,0 +1,72 @@
1
+ """The write methods reject timezone-naive datetimes before hitting the API.
2
+
3
+ The server stores every timestamp as an absolute instant plus its local offset
4
+ and rejects naive datetimes with HTTP 422. The client guards the same contract
5
+ at the call site (``Client._require_aware``) so callers get a clear, immediate
6
+ error naming the offending argument instead of an opaque server response.
7
+
8
+ All offline: the guard runs before ``_http_client`` is ever touched, so a bare
9
+ ``Client.__new__(Client)`` instance is enough.
10
+ """
11
+
12
+ from datetime import datetime, timezone
13
+ from zoneinfo import ZoneInfo
14
+
15
+ import pytest
16
+
17
+ from sweatstack import Sport
18
+ from sweatstack.client import Client
19
+
20
+
21
+ NAIVE = datetime(2026, 3, 15, 9, 0)
22
+ AWARE_UTC = datetime(2026, 3, 15, 9, 0, tzinfo=timezone.utc)
23
+ AWARE_ZONE = datetime(2026, 3, 15, 9, 0, tzinfo=ZoneInfo("Europe/Amsterdam"))
24
+
25
+
26
+ class TestRequireAware:
27
+ def test_naive_rejected_with_helpful_message(self):
28
+ with pytest.raises(ValueError) as excinfo:
29
+ Client._require_aware(NAIVE, "timestamp")
30
+ message = str(excinfo.value)
31
+ assert "timestamp" in message
32
+ assert "timezone-aware" in message
33
+
34
+ @pytest.mark.parametrize("value", [AWARE_UTC, AWARE_ZONE])
35
+ def test_aware_passes_through_unchanged(self, value: datetime):
36
+ assert Client._require_aware(value, "timestamp") is value
37
+
38
+
39
+ class TestTraceWritesRejectNaive:
40
+ def test_create_trace_rejects_naive_timestamp(self):
41
+ client = Client.__new__(Client)
42
+ with pytest.raises(ValueError, match="timestamp must be timezone-aware"):
43
+ client.create_trace(timestamp=NAIVE, lactate=2.0)
44
+
45
+ def test_update_trace_rejects_naive_timestamp(self):
46
+ client = Client.__new__(Client)
47
+ with pytest.raises(ValueError, match="timestamp must be timezone-aware"):
48
+ client.update_trace("trace_1", timestamp=NAIVE, lactate=2.0)
49
+
50
+
51
+ class TestTestWritesRejectNaive:
52
+ def test_create_test_rejects_naive_start(self):
53
+ client = Client.__new__(Client)
54
+ with pytest.raises(ValueError, match="start must be timezone-aware"):
55
+ client.create_test(sport=Sport("cycling"), start=NAIVE)
56
+
57
+ def test_create_test_rejects_naive_end(self):
58
+ client = Client.__new__(Client)
59
+ with pytest.raises(ValueError, match="end must be timezone-aware"):
60
+ client.create_test(sport=Sport("cycling"), start=AWARE_UTC, end=NAIVE)
61
+
62
+ def test_update_test_rejects_naive_start(self):
63
+ client = Client.__new__(Client)
64
+ with pytest.raises(ValueError, match="start must be timezone-aware"):
65
+ client.update_test("test_1", sport=Sport("cycling"), start=NAIVE)
66
+
67
+ def test_update_test_rejects_naive_end(self):
68
+ client = Client.__new__(Client)
69
+ with pytest.raises(ValueError, match="end must be timezone-aware"):
70
+ client.update_test(
71
+ "test_1", sport=Sport("cycling"), start=AWARE_UTC, end=NAIVE
72
+ )
@@ -2600,7 +2600,7 @@ wheels = [
2600
2600
 
2601
2601
  [[package]]
2602
2602
  name = "sweatstack"
2603
- version = "0.87.0"
2603
+ version = "0.88.0"
2604
2604
  source = { editable = "." }
2605
2605
  dependencies = [
2606
2606
  { name = "email-validator" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes