sweatstack 0.46.0__tar.gz → 0.48.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.48.0/.claude/settings.local.json +8 -0
- {sweatstack-0.46.0 → sweatstack-0.48.0}/CHANGELOG.md +15 -0
- {sweatstack-0.46.0 → sweatstack-0.48.0}/PKG-INFO +1 -1
- {sweatstack-0.46.0 → sweatstack-0.48.0}/pyproject.toml +1 -1
- {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/client.py +186 -4
- {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/openapi_schemas.py +8 -1
- {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/schemas.py +1 -1
- {sweatstack-0.46.0 → sweatstack-0.48.0}/uv.lock +1 -1
- {sweatstack-0.46.0 → sweatstack-0.48.0}/.gitignore +0 -0
- {sweatstack-0.46.0 → sweatstack-0.48.0}/.python-version +0 -0
- {sweatstack-0.46.0 → sweatstack-0.48.0}/DEVELOPMENT.md +0 -0
- {sweatstack-0.46.0 → sweatstack-0.48.0}/Makefile +0 -0
- {sweatstack-0.46.0 → sweatstack-0.48.0}/README.md +0 -0
- {sweatstack-0.46.0 → sweatstack-0.48.0}/playground/.ipynb_checkpoints/Untitled-checkpoint.ipynb +0 -0
- {sweatstack-0.46.0 → sweatstack-0.48.0}/playground/README.md +0 -0
- {sweatstack-0.46.0 → sweatstack-0.48.0}/playground/Sweat Stack examples/Getting started.ipynb +0 -0
- {sweatstack-0.46.0 → sweatstack-0.48.0}/playground/Untitled.ipynb +0 -0
- {sweatstack-0.46.0 → sweatstack-0.48.0}/playground/hello.py +0 -0
- {sweatstack-0.46.0 → sweatstack-0.48.0}/playground/pyproject.toml +0 -0
- {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
- {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/__init__.py +0 -0
- {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/cli.py +0 -0
- {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/constants.py +0 -0
- {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/ipython_init.py +0 -0
- {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
- {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/py.typed +0 -0
- {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/streamlit.py +0 -0
- {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/sweatshell.py +0 -0
- {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/utils.py +0 -0
|
@@ -6,6 +6,21 @@ 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.48.0] - 2025-08-12
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Added optional local caching of longitudinal data, enabled by setting the `SWEATSTACK_CACHE_ENABLED` environment variable to `true`. The cache directory can be specified by setting the `SWEATSTACK_CACHE_DIR` environment variable. The cache can be cleared by calling `ss.clear_cache()`.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
## [0.47.0] - 2025-08-07
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Added a new `ss.get_backfill_status()` method that returns the current backfill status from the activities backfill-status endpoint.
|
|
21
|
+
- Added a new `ss.watch_backfill_status()` method that watches the backfill status from the activities backfill-status endpoint.
|
|
22
|
+
|
|
23
|
+
|
|
9
24
|
## [0.46.0] - 2025-08-01
|
|
10
25
|
|
|
11
26
|
### Added
|
|
@@ -6,6 +6,8 @@ import hashlib
|
|
|
6
6
|
import logging
|
|
7
7
|
import os
|
|
8
8
|
import secrets
|
|
9
|
+
import shutil
|
|
10
|
+
import tempfile
|
|
9
11
|
import time
|
|
10
12
|
import urllib
|
|
11
13
|
import webbrowser
|
|
@@ -16,7 +18,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
|
16
18
|
from importlib.metadata import version
|
|
17
19
|
from io import BytesIO
|
|
18
20
|
from pathlib import Path
|
|
19
|
-
from typing import Any, Generator, get_type_hints, List, Literal
|
|
21
|
+
from typing import Any, Dict, Generator, get_type_hints, List, Literal
|
|
20
22
|
from urllib.parse import parse_qs, urlparse
|
|
21
23
|
|
|
22
24
|
import httpx
|
|
@@ -25,7 +27,8 @@ from platformdirs import user_data_dir
|
|
|
25
27
|
|
|
26
28
|
from .constants import DEFAULT_URL
|
|
27
29
|
from .schemas import (
|
|
28
|
-
ActivityDetails, ActivitySummary, Metric, Sport,
|
|
30
|
+
ActivityDetails, ActivitySummary, BackfillStatus, Metric, Sport,
|
|
31
|
+
TraceDetails, UserInfoResponse, UserSummary
|
|
29
32
|
)
|
|
30
33
|
from .utils import decode_jwt_body, make_dataframe_streamlit_compatible
|
|
31
34
|
|
|
@@ -48,6 +51,110 @@ AUTH_SUCCESSFUL_RESPONSE = """<!DOCTYPE html>
|
|
|
48
51
|
OAUTH2_CLIENT_ID = "5382f68b0d254378"
|
|
49
52
|
|
|
50
53
|
|
|
54
|
+
class LocalCacheMixin:
|
|
55
|
+
"""Mixin for handling local filesystem caching of API responses."""
|
|
56
|
+
|
|
57
|
+
def _cache_enabled(self) -> bool:
|
|
58
|
+
"""Check if local caching is enabled."""
|
|
59
|
+
return bool(os.getenv("SWEATSTACK_LOCAL_CACHE"))
|
|
60
|
+
|
|
61
|
+
def _log_cache_error(self, operation: str, error: Exception) -> None:
|
|
62
|
+
"""Log cache operation errors with context."""
|
|
63
|
+
cache_location = os.getenv("SWEATSTACK_CACHE_DIR") or tempfile.gettempdir()
|
|
64
|
+
try:
|
|
65
|
+
user_id = self._get_user_id_from_token()
|
|
66
|
+
except Exception:
|
|
67
|
+
user_id = "unknown"
|
|
68
|
+
|
|
69
|
+
logging.warning(
|
|
70
|
+
f"Failed to {operation} cache despite SWEATSTACK_LOCAL_CACHE being enabled. "
|
|
71
|
+
f"Cache directory: {cache_location}/sweatstack/{user_id}. "
|
|
72
|
+
f"Error: {error}"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def _get_user_id_from_token(self) -> str:
|
|
76
|
+
"""Extract user ID from the JWT token."""
|
|
77
|
+
if not self.api_key:
|
|
78
|
+
raise ValueError("Not authenticated. Please call authenticate() or login() first.")
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
jwt_body = decode_jwt_body(self.api_key)
|
|
82
|
+
user_id = jwt_body.get("sub")
|
|
83
|
+
if not user_id:
|
|
84
|
+
raise ValueError("Unable to extract user ID from token")
|
|
85
|
+
return user_id
|
|
86
|
+
except Exception as e:
|
|
87
|
+
raise ValueError(f"Invalid authentication token: {e}")
|
|
88
|
+
|
|
89
|
+
def _get_cache_dir(self) -> Path:
|
|
90
|
+
"""Get cache directory for current user."""
|
|
91
|
+
user_id = self._get_user_id_from_token()
|
|
92
|
+
|
|
93
|
+
if cache_location := os.getenv("SWEATSTACK_CACHE_DIR"):
|
|
94
|
+
cache_dir = Path(cache_location) / user_id
|
|
95
|
+
else:
|
|
96
|
+
cache_dir = Path(tempfile.gettempdir()) / "sweatstack" / user_id
|
|
97
|
+
|
|
98
|
+
cache_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
99
|
+
return cache_dir
|
|
100
|
+
|
|
101
|
+
def _generate_longitudinal_cache_key(self, **params) -> str:
|
|
102
|
+
"""Generate cache key for longitudinal data requests."""
|
|
103
|
+
normalized_params = {}
|
|
104
|
+
|
|
105
|
+
for key, value in params.items():
|
|
106
|
+
if value is None:
|
|
107
|
+
continue
|
|
108
|
+
elif isinstance(value, list):
|
|
109
|
+
normalized_params[key] = sorted([
|
|
110
|
+
v.value if hasattr(v, 'value') else str(v) for v in value
|
|
111
|
+
])
|
|
112
|
+
elif hasattr(value, 'value'):
|
|
113
|
+
normalized_params[key] = value.value
|
|
114
|
+
elif isinstance(value, (date, datetime)):
|
|
115
|
+
normalized_params[key] = value.isoformat()
|
|
116
|
+
else:
|
|
117
|
+
normalized_params[key] = str(value)
|
|
118
|
+
|
|
119
|
+
cache_data = f"longitudinal_data:{json.dumps(normalized_params, sort_keys=True)}"
|
|
120
|
+
return hashlib.sha256(cache_data.encode()).hexdigest()[:16]
|
|
121
|
+
|
|
122
|
+
def _read_longitudinal_cache(self, cache_key: str) -> pd.DataFrame | None:
|
|
123
|
+
"""Try to read cached longitudinal data."""
|
|
124
|
+
try:
|
|
125
|
+
cache_dir = self._get_cache_dir()
|
|
126
|
+
cache_file = cache_dir / f"longitudinal-{cache_key}.parquet"
|
|
127
|
+
|
|
128
|
+
if cache_file.exists():
|
|
129
|
+
return pd.read_parquet(cache_file)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
self._log_cache_error("read", e)
|
|
132
|
+
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
def _write_longitudinal_cache(self, cache_key: str, content: bytes) -> None:
|
|
136
|
+
"""Write longitudinal data to cache."""
|
|
137
|
+
try:
|
|
138
|
+
cache_dir = self._get_cache_dir()
|
|
139
|
+
cache_file = cache_dir / f"longitudinal-{cache_key}.parquet"
|
|
140
|
+
cache_file.write_bytes(content)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
self._log_cache_error("write", e)
|
|
143
|
+
|
|
144
|
+
def clear_cache(self) -> None:
|
|
145
|
+
"""Clear all cached data for the current user.
|
|
146
|
+
|
|
147
|
+
This removes all cached data (longitudinal data, etc.) from the temporary
|
|
148
|
+
directory for the currently authenticated user.
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
cache_dir = self._get_cache_dir()
|
|
152
|
+
if cache_dir.exists():
|
|
153
|
+
shutil.rmtree(cache_dir)
|
|
154
|
+
except Exception as e:
|
|
155
|
+
self._log_cache_error("clear", e)
|
|
156
|
+
|
|
157
|
+
|
|
51
158
|
class TokenStorageMixin:
|
|
52
159
|
"""Mixin for handling persistent token storage using platformdirs."""
|
|
53
160
|
|
|
@@ -433,7 +540,7 @@ class DelegationMixin:
|
|
|
433
540
|
)
|
|
434
541
|
|
|
435
542
|
|
|
436
|
-
class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin):
|
|
543
|
+
class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin, LocalCacheMixin):
|
|
437
544
|
def __init__(
|
|
438
545
|
self,
|
|
439
546
|
api_key: str | None = None,
|
|
@@ -890,6 +997,12 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin):
|
|
|
890
997
|
if adaptive_sampling_on is not None:
|
|
891
998
|
params["adaptive_sampling_on"] = self._enums_to_strings([adaptive_sampling_on])[0]
|
|
892
999
|
|
|
1000
|
+
if self._cache_enabled():
|
|
1001
|
+
cache_key = self._generate_longitudinal_cache_key(**params)
|
|
1002
|
+
cached_df = self._read_longitudinal_cache(cache_key)
|
|
1003
|
+
if cached_df is not None:
|
|
1004
|
+
return self._postprocess_dataframe(cached_df)
|
|
1005
|
+
|
|
893
1006
|
with self._http_client() as client:
|
|
894
1007
|
response = client.get(
|
|
895
1008
|
url="/api/v1/activities/longitudinal-data",
|
|
@@ -897,8 +1010,12 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin):
|
|
|
897
1010
|
)
|
|
898
1011
|
self._raise_for_status(response)
|
|
899
1012
|
|
|
1013
|
+
if self._cache_enabled():
|
|
1014
|
+
self._write_longitudinal_cache(cache_key, response.content)
|
|
1015
|
+
|
|
900
1016
|
df = pd.read_parquet(BytesIO(response.content))
|
|
901
|
-
|
|
1017
|
+
|
|
1018
|
+
return self._postprocess_dataframe(df)
|
|
902
1019
|
|
|
903
1020
|
def get_longitudinal_mean_max(
|
|
904
1021
|
self,
|
|
@@ -1230,6 +1347,67 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin):
|
|
|
1230
1347
|
|
|
1231
1348
|
return self._get_user_by_id(user_id)
|
|
1232
1349
|
|
|
1350
|
+
def _parse_backfill_line(self, line: str) -> BackfillStatus | None:
|
|
1351
|
+
"""Parse a single NDJSON line from backfill status stream."""
|
|
1352
|
+
try:
|
|
1353
|
+
return BackfillStatus.model_validate_json(line)
|
|
1354
|
+
except Exception:
|
|
1355
|
+
pass
|
|
1356
|
+
return None
|
|
1357
|
+
|
|
1358
|
+
def watch_backfill_status(self, *, auto_reconnect: bool = False) -> Generator[BackfillStatus, None, None]:
|
|
1359
|
+
"""Watches backfill status from the activities backfill-status endpoint.
|
|
1360
|
+
|
|
1361
|
+
This method connects to the backfill status event stream and yields
|
|
1362
|
+
backfill_loaded_until timestamps as they are received. The connection
|
|
1363
|
+
automatically closes after 60 seconds, but can be configured to auto-reconnect.
|
|
1364
|
+
|
|
1365
|
+
Args:
|
|
1366
|
+
auto_reconnect: Whether to automatically reconnect when the connection
|
|
1367
|
+
closes and continue receiving updates. Defaults to False.
|
|
1368
|
+
|
|
1369
|
+
Yields:
|
|
1370
|
+
BackfillStatus: A BackfillStatus object for each received message.
|
|
1371
|
+
|
|
1372
|
+
Raises:
|
|
1373
|
+
HTTPStatusError: If the API request fails.
|
|
1374
|
+
"""
|
|
1375
|
+
while True:
|
|
1376
|
+
try:
|
|
1377
|
+
with self._http_client() as client:
|
|
1378
|
+
with client.stream("GET", "/api/v1/activities/backfill-status") as response:
|
|
1379
|
+
self._raise_for_status(response)
|
|
1380
|
+
|
|
1381
|
+
for line in response.iter_lines():
|
|
1382
|
+
if line.strip():
|
|
1383
|
+
parsed = self._parse_backfill_line(line)
|
|
1384
|
+
if parsed:
|
|
1385
|
+
yield parsed
|
|
1386
|
+
|
|
1387
|
+
except httpx.RequestError:
|
|
1388
|
+
if not auto_reconnect:
|
|
1389
|
+
raise
|
|
1390
|
+
time.sleep(1)
|
|
1391
|
+
if not auto_reconnect:
|
|
1392
|
+
break
|
|
1393
|
+
|
|
1394
|
+
def get_backfill_status(self) -> BackfillStatus:
|
|
1395
|
+
"""Gets the current backfill status from the activities backfill-status endpoint.
|
|
1396
|
+
|
|
1397
|
+
This method connects to the backfill status event stream and returns
|
|
1398
|
+
the first backfill_loaded_until timestamp received.
|
|
1399
|
+
|
|
1400
|
+
Returns:
|
|
1401
|
+
BackfillStatus: A BackfillStatus object containing the current backfill status.
|
|
1402
|
+
|
|
1403
|
+
Raises:
|
|
1404
|
+
HTTPStatusError: If the API request fails.
|
|
1405
|
+
ValueError: If no status message is received.
|
|
1406
|
+
"""
|
|
1407
|
+
for status in self.watch_backfill_status(auto_reconnect=False):
|
|
1408
|
+
return status
|
|
1409
|
+
raise ValueError("No backfill status received")
|
|
1410
|
+
|
|
1233
1411
|
|
|
1234
1412
|
_default_client = Client()
|
|
1235
1413
|
|
|
@@ -1276,6 +1454,9 @@ _generate_singleton_methods(
|
|
|
1276
1454
|
"get_userinfo",
|
|
1277
1455
|
"whoami",
|
|
1278
1456
|
|
|
1457
|
+
"get_backfill_status",
|
|
1458
|
+
"watch_backfill_status",
|
|
1459
|
+
|
|
1279
1460
|
"get_activities",
|
|
1280
1461
|
|
|
1281
1462
|
"get_activity",
|
|
@@ -1294,6 +1475,7 @@ _generate_singleton_methods(
|
|
|
1294
1475
|
|
|
1295
1476
|
"get_sports",
|
|
1296
1477
|
"get_tags",
|
|
1478
|
+
"clear_cache",
|
|
1297
1479
|
|
|
1298
1480
|
"switch_user",
|
|
1299
1481
|
"switch_back",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# generated by datamodel-codegen:
|
|
2
2
|
# filename: openapi.json
|
|
3
|
-
# timestamp: 2025-08-
|
|
3
|
+
# timestamp: 2025-08-07T11:42:52+00:00
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
@@ -36,6 +36,12 @@ class Prompt(Enum):
|
|
|
36
36
|
select_account = 'select_account'
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
class BackfillStatus(BaseModel):
|
|
40
|
+
backfill_loaded_until: Optional[datetime] = Field(
|
|
41
|
+
..., title='Backfill Loaded Until'
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
39
45
|
class BodyAddEmailPartialsAddEmailPost(BaseModel):
|
|
40
46
|
email: EmailStr = Field(..., title='Email')
|
|
41
47
|
|
|
@@ -664,6 +670,7 @@ class BodyLoginPostLoginPost(BaseModel):
|
|
|
664
670
|
email: EmailStr = Field(..., title='Email')
|
|
665
671
|
password: SecretStr = Field(..., title='Password')
|
|
666
672
|
tz: Tz = Field(..., title='Tz')
|
|
673
|
+
state: Optional[str] = Field(None, title='State')
|
|
667
674
|
|
|
668
675
|
|
|
669
676
|
class BodyRegisterPostRegisterPost(BaseModel):
|
|
@@ -2,7 +2,7 @@ from enum import Enum
|
|
|
2
2
|
from typing import List, Union
|
|
3
3
|
|
|
4
4
|
from .openapi_schemas import (
|
|
5
|
-
ActivityDetails, ActivitySummary, Metric, Scope, Sport,
|
|
5
|
+
ActivityDetails, ActivitySummary, BackfillStatus, Metric, Scope, Sport,
|
|
6
6
|
TraceDetails, UserInfoResponse, UserSummary
|
|
7
7
|
)
|
|
8
8
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sweatstack-0.46.0 → sweatstack-0.48.0}/playground/.ipynb_checkpoints/Untitled-checkpoint.ipynb
RENAMED
|
File without changes
|
|
File without changes
|
{sweatstack-0.46.0 → sweatstack-0.48.0}/playground/Sweat Stack examples/Getting started.ipynb
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sweatstack-0.46.0 → sweatstack-0.48.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
|