sweatstack 0.86.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.86.0 → sweatstack-0.88.0}/.claude/settings.local.json +2 -1
  2. {sweatstack-0.86.0 → sweatstack-0.88.0}/CHANGELOG.md +19 -0
  3. {sweatstack-0.86.0 → sweatstack-0.88.0}/PKG-INFO +1 -1
  4. {sweatstack-0.86.0 → sweatstack-0.88.0}/pyproject.toml +1 -1
  5. {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/client.py +70 -34
  6. {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/dependencies.py +0 -9
  7. {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/streamlit.py +2 -2
  8. {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/test_access_token_cache.py +5 -5
  9. {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/test_longitudinal_mean_max_after.py +19 -6
  10. sweatstack-0.88.0/tests/test_segmentation.py +70 -0
  11. sweatstack-0.88.0/tests/test_write_timezone_validation.py +72 -0
  12. {sweatstack-0.86.0 → sweatstack-0.88.0}/uv.lock +1 -1
  13. {sweatstack-0.86.0 → sweatstack-0.88.0}/.claude/skills/sweatstack-python/SKILL.md +0 -0
  14. {sweatstack-0.86.0 → sweatstack-0.88.0}/.claude/skills/sweatstack-python/client.md +0 -0
  15. {sweatstack-0.86.0 → sweatstack-0.88.0}/.claude/skills/sweatstack-python/data-models.md +0 -0
  16. {sweatstack-0.86.0 → sweatstack-0.88.0}/.claude/skills/sweatstack-python/fastapi.md +0 -0
  17. {sweatstack-0.86.0 → sweatstack-0.88.0}/.claude/skills/sweatstack-python/streamlit.md +0 -0
  18. {sweatstack-0.86.0 → sweatstack-0.88.0}/.gitignore +0 -0
  19. {sweatstack-0.86.0 → sweatstack-0.88.0}/.python-version +0 -0
  20. {sweatstack-0.86.0 → sweatstack-0.88.0}/AGENTS.md +0 -0
  21. {sweatstack-0.86.0 → sweatstack-0.88.0}/CONTRIBUTING.md +0 -0
  22. {sweatstack-0.86.0 → sweatstack-0.88.0}/DEVELOPMENT.md +0 -0
  23. {sweatstack-0.86.0 → sweatstack-0.88.0}/LICENSE +0 -0
  24. {sweatstack-0.86.0 → sweatstack-0.88.0}/Makefile +0 -0
  25. {sweatstack-0.86.0 → sweatstack-0.88.0}/README.md +0 -0
  26. {sweatstack-0.86.0 → sweatstack-0.88.0}/docs/conf.py +0 -0
  27. {sweatstack-0.86.0 → sweatstack-0.88.0}/docs/everything.rst +0 -0
  28. {sweatstack-0.86.0 → sweatstack-0.88.0}/docs/index.rst +0 -0
  29. {sweatstack-0.86.0 → sweatstack-0.88.0}/examples/fastapi_webhooks_example.py +0 -0
  30. {sweatstack-0.86.0 → sweatstack-0.88.0}/examples/send_webhook.py +0 -0
  31. {sweatstack-0.86.0 → sweatstack-0.88.0}/plans/001a_tests.md +0 -0
  32. {sweatstack-0.86.0 → sweatstack-0.88.0}/plans/001b_metadata.md +0 -0
  33. {sweatstack-0.86.0 → sweatstack-0.88.0}/plans/001c_dailies.md +0 -0
  34. {sweatstack-0.86.0 → sweatstack-0.88.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
  35. {sweatstack-0.86.0 → sweatstack-0.88.0}/plans/003_trace_test_linking.md +0 -0
  36. {sweatstack-0.86.0 → sweatstack-0.88.0}/plans/004_codebase_hygiene.md +0 -0
  37. {sweatstack-0.86.0 → sweatstack-0.88.0}/plans/005_ost_sport_bridge.md +0 -0
  38. {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
  39. {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/__init__.py +0 -0
  40. {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/cli.py +0 -0
  41. {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/constants.py +0 -0
  42. {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/exceptions.py +0 -0
  43. {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/__init__.py +0 -0
  44. {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
  45. {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/config.py +0 -0
  46. {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/models.py +0 -0
  47. {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/routes.py +0 -0
  48. {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/session.py +0 -0
  49. {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/token_stores.py +0 -0
  50. {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/webhooks.py +0 -0
  51. {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/ipython_init.py +0 -0
  52. {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
  53. {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/openapi_schemas.py +0 -0
  54. {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/py.typed +0 -0
  55. {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/schemas.py +0 -0
  56. {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/sweatshell.py +0 -0
  57. {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/utils.py +0 -0
  58. {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/__init__.py +0 -0
  59. {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/test_dailies.py +0 -0
  60. {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/test_dtype_conversion.py +0 -0
  61. {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/test_exceptions.py +0 -0
  62. {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/test_metadata.py +0 -0
  63. {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/test_public_surface.py +0 -0
  64. {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/test_sport_ost.py +0 -0
  65. {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/test_teams.py +0 -0
  66. {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/test_tests.py +0 -0
  67. {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/test_trace_test_linking.py +0 -0
  68. {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/test_webhooks.py +0 -0
@@ -31,7 +31,8 @@
31
31
  "Bash(sed -n '42,50p' src/sweatstack/client.py)",
32
32
  "Bash(sed -n '1017,1022p' src/sweatstack/client.py)",
33
33
  "Bash(sed -i '' 's/sport=cycling\\\\.road&sport=cycling\\\\.tt/sport=cycling.road\\\\&sport=cycling.time_trial/' docs-dev/changelog.md)",
34
- "Bash(sed -n '25p' docs-dev/changelog.md)"
34
+ "Bash(sed -n '25p' docs-dev/changelog.md)",
35
+ "Bash(uv build *)"
35
36
  ],
36
37
  "deny": []
37
38
  }
@@ -6,6 +6,25 @@ 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
+
21
+ ## [0.87.0] - 2026-06-30
22
+
23
+ ### Changed
24
+
25
+ - **Breaking:** the codec previously named NLEC is now AISC (Adaptive Intensity Segmentation Codec), and its parameters are renamed: `nlec_on` is now `segmentation_on` (on `get_activity_data`, `get_latest_activity_data`, `get_longitudinal_data`) and `nlec` is now `segmentation` (on `get_activity_mean_max`, `get_latest_activity_mean_max`). No backwards-compatible aliases.
26
+
27
+
9
28
  ## [0.86.0] - 2026-06-18
10
29
 
11
30
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sweatstack
3
- Version: 0.86.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.86.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
  *,
@@ -1219,18 +1236,18 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1219
1236
  def get_activity_data(
1220
1237
  self,
1221
1238
  activity_id: str,
1222
- nlec_on: Literal["power", "speed"] | None = None,
1239
+ segmentation_on: Literal["power", "speed"] | None = None,
1223
1240
  metrics: list[Metric | str] | None = None,
1224
1241
  ) -> pd.DataFrame:
1225
1242
  """Gets the raw data for a specific activity.
1226
1243
 
1227
- This method retrieves the time-series data for a given activity, with optional NLEC
1228
- (near-lossless effort codec) downsampling to reduce data points for visualization.
1244
+ This method retrieves the time-series data for a given activity, with optional AISC
1245
+ (Adaptive Intensity Segmentation Codec) downsampling to reduce data points for visualization.
1229
1246
 
1230
1247
  Args:
1231
1248
  activity_id: The unique identifier of the activity.
1232
- nlec_on: Downsample with NLEC (near-lossless effort codec), keyed on
1233
- either "power" or "speed" data. If None, no NLEC is applied.
1249
+ segmentation_on: Downsample with AISC (Adaptive Intensity Segmentation Codec), keyed on
1250
+ either "power" or "speed" data. If None, no AISC is applied.
1234
1251
  metrics: Optional list of metrics to include in the results. Can be a list of Metric enums or strings.
1235
1252
 
1236
1253
  Returns:
@@ -1241,8 +1258,8 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1241
1258
  SweatStackAPIError: If the API request fails for any other reason.
1242
1259
  """
1243
1260
  params = {}
1244
- if nlec_on is not None:
1245
- params["nlec_on"] = nlec_on
1261
+ if segmentation_on is not None:
1262
+ params["segmentation_on"] = segmentation_on
1246
1263
  if metrics is not None:
1247
1264
  params["metrics"] = self._enums_to_strings(metrics)
1248
1265
 
@@ -1260,7 +1277,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1260
1277
  self,
1261
1278
  activity_id: str,
1262
1279
  metric: Literal[Metric.power, Metric.speed] | Literal["power", "speed"],
1263
- nlec: bool = False,
1280
+ segmentation: bool = False,
1264
1281
  ) -> pd.DataFrame:
1265
1282
  """Gets the mean-max data for a specific activity.
1266
1283
 
@@ -1270,7 +1287,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1270
1287
  Args:
1271
1288
  activity_id: The unique identifier of the activity.
1272
1289
  metric: The metric to calculate mean-max values for, either "power" or "speed".
1273
- nlec: Downsample with NLEC (near-lossless effort codec) to reduce data points
1290
+ segmentation: Downsample with AISC (Adaptive Intensity Segmentation Codec) to reduce data points
1274
1291
  for visualization. Defaults to False.
1275
1292
 
1276
1293
  Returns:
@@ -1286,7 +1303,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1286
1303
  url=f"/api/v1/activities/{activity_id}/mean-max",
1287
1304
  params={
1288
1305
  "metric": metric,
1289
- "nlec": nlec,
1306
+ "segmentation": segmentation,
1290
1307
  },
1291
1308
  )
1292
1309
  self._raise_for_status(response)
@@ -1332,7 +1349,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1332
1349
  def get_latest_activity_data(
1333
1350
  self,
1334
1351
  sport: Sport | str | None = None,
1335
- nlec_on: Literal["power", "speed"] | None = None,
1352
+ segmentation_on: Literal["power", "speed"] | None = None,
1336
1353
  metrics: list[Metric | str] | None = None,
1337
1354
  ) -> pd.DataFrame:
1338
1355
  """Gets the data for the latest activity of a specific sport.
@@ -1342,7 +1359,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1342
1359
 
1343
1360
  Args:
1344
1361
  sport: Optional sport to filter by. Can be a Sport enum or string.
1345
- nlec_on: Metric to downsample on with NLEC (near-lossless effort codec); omit to disable.
1362
+ segmentation_on: Metric to downsample on with AISC (Adaptive Intensity Segmentation Codec); omit to disable.
1346
1363
  Can be either "power" or "speed". Defaults to None.
1347
1364
  metrics: Optional list of metrics to include in the results. Can be a list of Metric enums or strings.
1348
1365
 
@@ -1353,13 +1370,13 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1353
1370
  SweatStackAPIError: If the API request fails.
1354
1371
  """
1355
1372
  activity = self.get_latest_activity(sport=sport)
1356
- return self.get_activity_data(activity.id, nlec_on, metrics=metrics)
1373
+ return self.get_activity_data(activity.id, segmentation_on, metrics=metrics)
1357
1374
 
1358
1375
  def get_latest_activity_mean_max(
1359
1376
  self,
1360
1377
  metric: Literal[Metric.power, Metric.speed] | Literal["power", "speed"],
1361
1378
  sport: Sport | str | None = None,
1362
- nlec: bool = False,
1379
+ segmentation: bool = False,
1363
1380
  ) -> pd.DataFrame:
1364
1381
  """Gets the mean-max curve for the latest activity of a specific sport.
1365
1382
 
@@ -1369,7 +1386,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1369
1386
  Args:
1370
1387
  metric: The metric to calculate the mean-max curve for. Can be either "power" or "speed".
1371
1388
  sport: Optional sport to filter by. Can be a Sport enum or string.
1372
- nlec: Downsample the mean-max curve with NLEC (near-lossless effort codec).
1389
+ segmentation: Downsample the mean-max curve with AISC (Adaptive Intensity Segmentation Codec).
1373
1390
  Defaults to False.
1374
1391
 
1375
1392
  Returns:
@@ -1379,7 +1396,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1379
1396
  SweatStackAPIError: If the API request fails.
1380
1397
  """
1381
1398
  activity = self.get_latest_activity(sport=sport)
1382
- return self.get_activity_mean_max(activity.id, metric, nlec)
1399
+ return self.get_activity_mean_max(activity.id, metric, segmentation)
1383
1400
 
1384
1401
  def get_longitudinal_data(
1385
1402
  self,
@@ -1389,7 +1406,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1389
1406
  start: date | str,
1390
1407
  end: date | str | None = None,
1391
1408
  metrics: list[Metric | str] | None = None,
1392
- nlec_on: Literal[Metric.power, Metric.speed] | Literal["power", "speed"] | None = None,
1409
+ segmentation_on: Literal[Metric.power, Metric.speed] | Literal["power", "speed"] | None = None,
1393
1410
  ) -> pd.DataFrame:
1394
1411
  """Gets longitudinal data for activities within a specified date range.
1395
1412
 
@@ -1402,7 +1419,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1402
1419
  start: The start date for the data range. Can be a date object or string in ISO format.
1403
1420
  end: Optional end date for the data range. Can be a date object or string in ISO format.
1404
1421
  metrics: Optional list of metrics to include in the results. Can be a list of Metric enums or strings.
1405
- nlec_on: Metric to downsample on with NLEC (near-lossless effort codec); omit to disable.
1422
+ segmentation_on: Metric to downsample on with AISC (Adaptive Intensity Segmentation Codec); omit to disable.
1406
1423
  Can be either "power" or "speed". Defaults to None.
1407
1424
 
1408
1425
  Returns:
@@ -1431,8 +1448,8 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1431
1448
  params["end"] = end
1432
1449
  if metrics is not None:
1433
1450
  params["metrics"] = self._enums_to_strings(metrics)
1434
- if nlec_on is not None:
1435
- params["nlec_on"] = self._enums_to_strings([nlec_on])[0]
1451
+ if segmentation_on is not None:
1452
+ params["segmentation_on"] = self._enums_to_strings([segmentation_on])[0]
1436
1453
 
1437
1454
  if self._cache_enabled():
1438
1455
  cache_key = self._generate_cache_key("longitudinal_data", **params)
@@ -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,70 @@
1
+ """AISC (Adaptive Intensity Segmentation Codec) parameters are sent on the wire.
2
+
3
+ Guards the `segmentation_on` / `segmentation` query-param contract (renamed from `nlec_on` / `nlec`
4
+ in 0.87.0) and the clean break — the old names must no longer be accepted.
5
+ """
6
+
7
+ from unittest.mock import MagicMock, patch
8
+
9
+ import pandas as pd
10
+ import pytest
11
+
12
+ from sweatstack.client import Client
13
+
14
+
15
+ @pytest.fixture
16
+ def client():
17
+ c = Client.__new__(Client)
18
+ c.url = "https://test.sweatstack.no"
19
+ c._access_token = None
20
+ c.streamlit_compatible = False
21
+ c.skip_token_expiry_check = True
22
+ return c
23
+
24
+
25
+ def _params_sent(client, call):
26
+ """Run `call`, mocking out HTTP + parquet, and return the query params sent to the server."""
27
+ response = MagicMock(status_code=200, content=b"")
28
+ http = MagicMock()
29
+ http.__enter__ = MagicMock(return_value=http)
30
+ http.__exit__ = MagicMock(return_value=False)
31
+ http.get.return_value = response
32
+
33
+ with patch.object(client, "_http_client", return_value=http), \
34
+ patch.object(client, "_raise_for_status"), \
35
+ patch.object(client, "_postprocess_dataframe", side_effect=lambda df: df), \
36
+ patch.object(client, "_cache_enabled", return_value=False), \
37
+ patch("sweatstack.client.pd.read_parquet", return_value=pd.DataFrame()):
38
+ call()
39
+ return http.get.call_args.kwargs["params"]
40
+
41
+
42
+ def test_get_activity_data_sends_segmentation_on(client):
43
+ params = _params_sent(client, lambda: client.get_activity_data("a", segmentation_on="power"))
44
+ assert params["segmentation_on"] == "power"
45
+
46
+
47
+ def test_get_activity_data_omits_segmentation_when_unset(client):
48
+ params = _params_sent(client, lambda: client.get_activity_data("a"))
49
+ assert "segmentation_on" not in params
50
+
51
+
52
+ def test_get_longitudinal_data_sends_segmentation_on(client):
53
+ params = _params_sent(
54
+ client,
55
+ lambda: client.get_longitudinal_data(sports=["running"], start="2024-01-01", segmentation_on="power"),
56
+ )
57
+ assert params["segmentation_on"] == "power"
58
+
59
+
60
+ def test_get_activity_mean_max_sends_segmentation(client):
61
+ params = _params_sent(client, lambda: client.get_activity_mean_max("a", "power", segmentation=True))
62
+ assert params["segmentation"] is True
63
+
64
+
65
+ def test_old_nlec_kwargs_are_rejected(client):
66
+ # Clean break: the pre-0.87.0 names must raise, not silently no-op.
67
+ with pytest.raises(TypeError):
68
+ client.get_activity_data("a", nlec_on="power")
69
+ with pytest.raises(TypeError):
70
+ client.get_activity_mean_max("a", "power", nlec=True)
@@ -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.86.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