sweatstack 0.47.0__py3-none-any.whl → 0.49.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 +126 -3
- {sweatstack-0.47.0.dist-info → sweatstack-0.49.0.dist-info}/METADATA +1 -1
- {sweatstack-0.47.0.dist-info → sweatstack-0.49.0.dist-info}/RECORD +5 -5
- {sweatstack-0.47.0.dist-info → sweatstack-0.49.0.dist-info}/WHEEL +0 -0
- {sweatstack-0.47.0.dist-info → sweatstack-0.49.0.dist-info}/entry_points.txt +0 -0
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,
|
|
@@ -723,6 +829,7 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin):
|
|
|
723
829
|
self,
|
|
724
830
|
activity_id: str,
|
|
725
831
|
adaptive_sampling_on: Literal["power", "speed"] | None = None,
|
|
832
|
+
metrics: list[Metric | str] | None = None,
|
|
726
833
|
) -> pd.DataFrame:
|
|
727
834
|
"""Gets the raw data for a specific activity.
|
|
728
835
|
|
|
@@ -733,6 +840,7 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin):
|
|
|
733
840
|
activity_id: The unique identifier of the activity.
|
|
734
841
|
adaptive_sampling_on: Optional parameter to apply adaptive sampling on
|
|
735
842
|
either "power" or "speed" data. If None, no adaptive sampling is applied.
|
|
843
|
+
metrics: Optional list of metrics to include in the results. Can be a list of Metric enums or strings.
|
|
736
844
|
|
|
737
845
|
Returns:
|
|
738
846
|
pd.DataFrame: A pandas DataFrame containing the activity's time-series data.
|
|
@@ -743,6 +851,8 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin):
|
|
|
743
851
|
params = {}
|
|
744
852
|
if adaptive_sampling_on is not None:
|
|
745
853
|
params["adaptive_sampling_on"] = adaptive_sampling_on
|
|
854
|
+
if metrics is not None:
|
|
855
|
+
params["metrics"] = self._enums_to_strings(metrics)
|
|
746
856
|
|
|
747
857
|
with self._http_client() as client:
|
|
748
858
|
response = client.get(
|
|
@@ -794,6 +904,7 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin):
|
|
|
794
904
|
self,
|
|
795
905
|
sport: Sport | str | None = None,
|
|
796
906
|
adaptive_sampling_on: Literal["power", "speed"] | None = None,
|
|
907
|
+
metrics: list[Metric | str] | None = None,
|
|
797
908
|
) -> pd.DataFrame:
|
|
798
909
|
"""Gets the data for the latest activity of a specific sport.
|
|
799
910
|
|
|
@@ -804,6 +915,7 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin):
|
|
|
804
915
|
sport: Optional sport to filter by. Can be a Sport enum or string.
|
|
805
916
|
adaptive_sampling_on: Optional metric to apply adaptive sampling for visualization.
|
|
806
917
|
Can be either "power" or "speed". Defaults to None.
|
|
918
|
+
metrics: Optional list of metrics to include in the results. Can be a list of Metric enums or strings.
|
|
807
919
|
|
|
808
920
|
Returns:
|
|
809
921
|
pd.DataFrame: A pandas DataFrame containing the activity data.
|
|
@@ -812,7 +924,7 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin):
|
|
|
812
924
|
HTTPStatusError: If the API request fails.
|
|
813
925
|
"""
|
|
814
926
|
activity = self.get_latest_activity(sport=sport)
|
|
815
|
-
return self.get_activity_data(activity.id, adaptive_sampling_on)
|
|
927
|
+
return self.get_activity_data(activity.id, adaptive_sampling_on, metrics=metrics)
|
|
816
928
|
|
|
817
929
|
def get_latest_activity_mean_max(
|
|
818
930
|
self,
|
|
@@ -891,6 +1003,12 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin):
|
|
|
891
1003
|
if adaptive_sampling_on is not None:
|
|
892
1004
|
params["adaptive_sampling_on"] = self._enums_to_strings([adaptive_sampling_on])[0]
|
|
893
1005
|
|
|
1006
|
+
if self._cache_enabled():
|
|
1007
|
+
cache_key = self._generate_longitudinal_cache_key(**params)
|
|
1008
|
+
cached_df = self._read_longitudinal_cache(cache_key)
|
|
1009
|
+
if cached_df is not None:
|
|
1010
|
+
return self._postprocess_dataframe(cached_df)
|
|
1011
|
+
|
|
894
1012
|
with self._http_client() as client:
|
|
895
1013
|
response = client.get(
|
|
896
1014
|
url="/api/v1/activities/longitudinal-data",
|
|
@@ -898,8 +1016,12 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin):
|
|
|
898
1016
|
)
|
|
899
1017
|
self._raise_for_status(response)
|
|
900
1018
|
|
|
1019
|
+
if self._cache_enabled():
|
|
1020
|
+
self._write_longitudinal_cache(cache_key, response.content)
|
|
1021
|
+
|
|
901
1022
|
df = pd.read_parquet(BytesIO(response.content))
|
|
902
|
-
|
|
1023
|
+
|
|
1024
|
+
return self._postprocess_dataframe(df)
|
|
903
1025
|
|
|
904
1026
|
def get_longitudinal_mean_max(
|
|
905
1027
|
self,
|
|
@@ -1359,6 +1481,7 @@ _generate_singleton_methods(
|
|
|
1359
1481
|
|
|
1360
1482
|
"get_sports",
|
|
1361
1483
|
"get_tags",
|
|
1484
|
+
"clear_cache",
|
|
1362
1485
|
|
|
1363
1486
|
"switch_user",
|
|
1364
1487
|
"switch_back",
|
|
@@ -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=
|
|
3
|
+
sweatstack/client.py,sha256=m2caYKmtzhpKlxb5p3mIZM6PE0fDzVkwNbQBC7MMQ3c,54395
|
|
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.
|
|
15
|
-
sweatstack-0.
|
|
16
|
-
sweatstack-0.
|
|
17
|
-
sweatstack-0.
|
|
14
|
+
sweatstack-0.49.0.dist-info/METADATA,sha256=Wcakp5WSWs0lnFjbWovJDWZ08nt8qAcExPVXSVYs3vw,852
|
|
15
|
+
sweatstack-0.49.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
16
|
+
sweatstack-0.49.0.dist-info/entry_points.txt,sha256=kCzOUQI3dqbTpEYqtgYDeiKFaqaA7BMlV6D24BMzCFU,208
|
|
17
|
+
sweatstack-0.49.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|