sweatstack 0.47.0__py3-none-any.whl → 0.48.0__py3-none-any.whl

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/client.py CHANGED
@@ -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
@@ -49,6 +51,110 @@ AUTH_SUCCESSFUL_RESPONSE = """<!DOCTYPE html>
49
51
  OAUTH2_CLIENT_ID = "5382f68b0d254378"
50
52
 
51
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
+
52
158
  class TokenStorageMixin:
53
159
  """Mixin for handling persistent token storage using platformdirs."""
54
160
 
@@ -434,7 +540,7 @@ class DelegationMixin:
434
540
  )
435
541
 
436
542
 
437
- class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin):
543
+ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin, LocalCacheMixin):
438
544
  def __init__(
439
545
  self,
440
546
  api_key: str | None = None,
@@ -891,6 +997,12 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin):
891
997
  if adaptive_sampling_on is not None:
892
998
  params["adaptive_sampling_on"] = self._enums_to_strings([adaptive_sampling_on])[0]
893
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
+
894
1006
  with self._http_client() as client:
895
1007
  response = client.get(
896
1008
  url="/api/v1/activities/longitudinal-data",
@@ -898,8 +1010,12 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin):
898
1010
  )
899
1011
  self._raise_for_status(response)
900
1012
 
1013
+ if self._cache_enabled():
1014
+ self._write_longitudinal_cache(cache_key, response.content)
1015
+
901
1016
  df = pd.read_parquet(BytesIO(response.content))
902
- return self._postprocess_dataframe(df)
1017
+
1018
+ return self._postprocess_dataframe(df)
903
1019
 
904
1020
  def get_longitudinal_mean_max(
905
1021
  self,
@@ -1359,6 +1475,7 @@ _generate_singleton_methods(
1359
1475
 
1360
1476
  "get_sports",
1361
1477
  "get_tags",
1478
+ "clear_cache",
1362
1479
 
1363
1480
  "switch_user",
1364
1481
  "switch_back",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sweatstack
3
- Version: 0.47.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
  sweatstack/__init__.py,sha256=tiVfgKlswRPaDMEy0gA7u8rveqEYZTA_kyB9lJ3J6Sc,21
2
2
  sweatstack/cli.py,sha256=N1NWOgEZR2yaJvIXxo9qvp_jFlypZYb0nujpbVNYQ6A,720
3
- sweatstack/client.py,sha256=31iiErq1nWk-zFgApTN_iCWn7an7bB3VOBygGZgAOJ8,49517
3
+ sweatstack/client.py,sha256=389f_XghylIeN-q4FPXFprYZ3Bje-b4d4Ko42bhzf5s,53950
4
4
  sweatstack/constants.py,sha256=fGO6ksOv5HeISv9lHRoYm4besW1GTveXS8YD3K0ljg0,41
5
5
  sweatstack/ipython_init.py,sha256=OtBB9dQvyLXklD4kA2x1swaVtU9u73fG4V4-zz4YRAg,139
6
6
  sweatstack/jupyterlab_oauth2_startup.py,sha256=YcjXvzeZ459vL_dCkFi1IxX_RNAu80ZX9rwa0OXJfTM,1023
@@ -11,7 +11,7 @@ sweatstack/streamlit.py,sha256=_PER03s0dYu5eF1MZdewPDqSvYHqMr0lZLu_EnGit3Y,13257
11
11
  sweatstack/sweatshell.py,sha256=MYLNcWbOdceqKJ3S0Pe8dwHXEeYsGJNjQoYUXpMTftA,333
12
12
  sweatstack/utils.py,sha256=AwHRdC1ziOZ5o9RBIB21Uxm-DoClVRAJSVvgsmSmvps,1801
13
13
  sweatstack/Sweat Stack examples/Getting started.ipynb,sha256=k2hiSffWecoQ0VxjdpDcgFzBXDQiYEebhnAYlu8cgX8,6335204
14
- sweatstack-0.47.0.dist-info/METADATA,sha256=UuRlROP5y90JVM6eopj0PklP0WvdkB1TWxy6RwALrS0,852
15
- sweatstack-0.47.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
16
- sweatstack-0.47.0.dist-info/entry_points.txt,sha256=kCzOUQI3dqbTpEYqtgYDeiKFaqaA7BMlV6D24BMzCFU,208
17
- sweatstack-0.47.0.dist-info/RECORD,,
14
+ sweatstack-0.48.0.dist-info/METADATA,sha256=HVIrAr7pmRHAVvSrmiV8hp8KN_BWSsSGc1Mrqr462js,852
15
+ sweatstack-0.48.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
16
+ sweatstack-0.48.0.dist-info/entry_points.txt,sha256=kCzOUQI3dqbTpEYqtgYDeiKFaqaA7BMlV6D24BMzCFU,208
17
+ sweatstack-0.48.0.dist-info/RECORD,,