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.
- {sweatstack-0.86.0 → sweatstack-0.88.0}/.claude/settings.local.json +2 -1
- {sweatstack-0.86.0 → sweatstack-0.88.0}/CHANGELOG.md +19 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/PKG-INFO +1 -1
- {sweatstack-0.86.0 → sweatstack-0.88.0}/pyproject.toml +1 -1
- {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/client.py +70 -34
- {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/dependencies.py +0 -9
- {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/streamlit.py +2 -2
- {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/test_access_token_cache.py +5 -5
- {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/test_longitudinal_mean_max_after.py +19 -6
- sweatstack-0.88.0/tests/test_segmentation.py +70 -0
- sweatstack-0.88.0/tests/test_write_timezone_validation.py +72 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/uv.lock +1 -1
- {sweatstack-0.86.0 → sweatstack-0.88.0}/.claude/skills/sweatstack-python/SKILL.md +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/.claude/skills/sweatstack-python/client.md +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/.claude/skills/sweatstack-python/data-models.md +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/.claude/skills/sweatstack-python/fastapi.md +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/.claude/skills/sweatstack-python/streamlit.md +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/.gitignore +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/.python-version +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/AGENTS.md +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/CONTRIBUTING.md +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/DEVELOPMENT.md +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/LICENSE +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/Makefile +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/README.md +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/docs/conf.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/docs/everything.rst +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/docs/index.rst +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/examples/fastapi_webhooks_example.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/examples/send_webhook.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/plans/001a_tests.md +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/plans/001b_metadata.md +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/plans/001c_dailies.md +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/plans/003_trace_test_linking.md +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/plans/004_codebase_hygiene.md +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/plans/005_ost_sport_bridge.md +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/__init__.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/cli.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/constants.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/exceptions.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/__init__.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/config.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/models.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/routes.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/session.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/token_stores.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/webhooks.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/ipython_init.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/openapi_schemas.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/py.typed +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/schemas.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/sweatshell.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/utils.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/__init__.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/test_dailies.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/test_dtype_conversion.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/test_exceptions.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/test_metadata.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/test_public_surface.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/test_sport_ost.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/test_teams.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/test_tests.py +0 -0
- {sweatstack-0.86.0 → sweatstack-0.88.0}/tests/test_trace_test_linking.py +0 -0
- {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
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1228
|
-
(
|
|
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
|
-
|
|
1233
|
-
either "power" or "speed" data. If None, no
|
|
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
|
|
1245
|
-
params["
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1435
|
-
params["
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
2617
|
-
``
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
|
76
|
-
body
|
|
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), "
|
|
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
|
|
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
|
|
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
|
-
#
|
|
55
|
-
|
|
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
|
|
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
|
+
)
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sweatstack-0.86.0 → sweatstack-0.88.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb
RENAMED
|
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
|
|
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
|
|
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
|
|
File without changes
|