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.
Files changed (29) hide show
  1. sweatstack-0.48.0/.claude/settings.local.json +8 -0
  2. {sweatstack-0.46.0 → sweatstack-0.48.0}/CHANGELOG.md +15 -0
  3. {sweatstack-0.46.0 → sweatstack-0.48.0}/PKG-INFO +1 -1
  4. {sweatstack-0.46.0 → sweatstack-0.48.0}/pyproject.toml +1 -1
  5. {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/client.py +186 -4
  6. {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/openapi_schemas.py +8 -1
  7. {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/schemas.py +1 -1
  8. {sweatstack-0.46.0 → sweatstack-0.48.0}/uv.lock +1 -1
  9. {sweatstack-0.46.0 → sweatstack-0.48.0}/.gitignore +0 -0
  10. {sweatstack-0.46.0 → sweatstack-0.48.0}/.python-version +0 -0
  11. {sweatstack-0.46.0 → sweatstack-0.48.0}/DEVELOPMENT.md +0 -0
  12. {sweatstack-0.46.0 → sweatstack-0.48.0}/Makefile +0 -0
  13. {sweatstack-0.46.0 → sweatstack-0.48.0}/README.md +0 -0
  14. {sweatstack-0.46.0 → sweatstack-0.48.0}/playground/.ipynb_checkpoints/Untitled-checkpoint.ipynb +0 -0
  15. {sweatstack-0.46.0 → sweatstack-0.48.0}/playground/README.md +0 -0
  16. {sweatstack-0.46.0 → sweatstack-0.48.0}/playground/Sweat Stack examples/Getting started.ipynb +0 -0
  17. {sweatstack-0.46.0 → sweatstack-0.48.0}/playground/Untitled.ipynb +0 -0
  18. {sweatstack-0.46.0 → sweatstack-0.48.0}/playground/hello.py +0 -0
  19. {sweatstack-0.46.0 → sweatstack-0.48.0}/playground/pyproject.toml +0 -0
  20. {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
  21. {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/__init__.py +0 -0
  22. {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/cli.py +0 -0
  23. {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/constants.py +0 -0
  24. {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/ipython_init.py +0 -0
  25. {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
  26. {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/py.typed +0 -0
  27. {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/streamlit.py +0 -0
  28. {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/sweatshell.py +0 -0
  29. {sweatstack-0.46.0 → sweatstack-0.48.0}/src/sweatstack/utils.py +0 -0
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(python:*)"
5
+ ],
6
+ "deny": []
7
+ }
8
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sweatstack
3
- Version: 0.46.0
3
+ Version: 0.48.0
4
4
  Summary: The official Python client for SweatStack
5
5
  Author-email: Aart Goossens <aart@gssns.io>
6
6
  Requires-Python: >=3.9
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sweatstack"
3
- version = "0.46.0"
3
+ version = "0.48.0"
4
4
  description = "The official Python client for SweatStack"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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, TraceDetails, UserInfoResponse, UserSummary
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
- return self._postprocess_dataframe(df)
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-01T16:59:37+00:00
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
 
@@ -1875,7 +1875,7 @@ wheels = [
1875
1875
 
1876
1876
  [[package]]
1877
1877
  name = "sweatstack"
1878
- version = "0.45.0"
1878
+ version = "0.46.0"
1879
1879
  source = { editable = "." }
1880
1880
  dependencies = [
1881
1881
  { name = "email-validator" },
File without changes
File without changes
File without changes
File without changes
File without changes