sweatstack 0.53.0__py3-none-any.whl → 0.55.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 +315 -46
- sweatstack/schemas.py +37 -14
- sweatstack/streamlit.py +104 -5
- {sweatstack-0.53.0.dist-info → sweatstack-0.55.0.dist-info}/METADATA +1 -1
- {sweatstack-0.53.0.dist-info → sweatstack-0.55.0.dist-info}/RECORD +7 -7
- {sweatstack-0.53.0.dist-info → sweatstack-0.55.0.dist-info}/WHEEL +0 -0
- {sweatstack-0.53.0.dist-info → sweatstack-0.55.0.dist-info}/entry_points.txt +0 -0
sweatstack/client.py
CHANGED
|
@@ -28,7 +28,7 @@ from platformdirs import user_data_dir
|
|
|
28
28
|
from .constants import DEFAULT_URL
|
|
29
29
|
from .schemas import (
|
|
30
30
|
ActivityDetails, ActivitySummary, BackfillStatus, Metric, Sport,
|
|
31
|
-
TraceDetails, UserInfoResponse, UserSummary
|
|
31
|
+
TokenResponse, TraceDetails, UserInfoResponse, UserSummary
|
|
32
32
|
)
|
|
33
33
|
from .utils import decode_jwt_body, make_dataframe_streamlit_compatible
|
|
34
34
|
|
|
@@ -51,8 +51,16 @@ AUTH_SUCCESSFUL_RESPONSE = """<!DOCTYPE html>
|
|
|
51
51
|
OAUTH2_CLIENT_ID = "5382f68b0d254378"
|
|
52
52
|
|
|
53
53
|
|
|
54
|
-
class
|
|
55
|
-
"""Mixin for handling local filesystem caching of API responses.
|
|
54
|
+
class _LocalCacheMixin:
|
|
55
|
+
"""Mixin for handling local filesystem caching of API responses.
|
|
56
|
+
|
|
57
|
+
Caching is controlled via environment variables:
|
|
58
|
+
|
|
59
|
+
- :envvar:`SWEATSTACK_LOCAL_CACHE` - Enable/disable caching
|
|
60
|
+
- :envvar:`SWEATSTACK_CACHE_DIR` - Custom cache directory location
|
|
61
|
+
|
|
62
|
+
Use :meth:`clear_cache` to remove all cached data for the current user.
|
|
63
|
+
"""
|
|
56
64
|
|
|
57
65
|
def _cache_enabled(self) -> bool:
|
|
58
66
|
"""Check if local caching is enabled."""
|
|
@@ -155,7 +163,7 @@ class LocalCacheMixin:
|
|
|
155
163
|
self._log_cache_error("clear", e)
|
|
156
164
|
|
|
157
165
|
|
|
158
|
-
class
|
|
166
|
+
class _TokenStorageMixin:
|
|
159
167
|
"""Mixin for handling persistent token storage using platformdirs."""
|
|
160
168
|
|
|
161
169
|
def _get_token_file_path(self) -> Path:
|
|
@@ -200,7 +208,123 @@ except ImportError:
|
|
|
200
208
|
__version__ = "unknown"
|
|
201
209
|
|
|
202
210
|
|
|
203
|
-
class
|
|
211
|
+
class _OAuth2Mixin:
|
|
212
|
+
"""OAuth2 authentication methods for the Client class."""
|
|
213
|
+
|
|
214
|
+
def generate_pkce_params(self) -> tuple[str, str]:
|
|
215
|
+
"""Generate PKCE parameters for OAuth2 authorization.
|
|
216
|
+
|
|
217
|
+
This method generates a code verifier and its corresponding code challenge
|
|
218
|
+
for use in the PKCE (Proof Key for Code Exchange) OAuth2 flow.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
tuple[str, str]: A tuple of (code_verifier, code_challenge)
|
|
222
|
+
"""
|
|
223
|
+
code_verifier = secrets.token_urlsafe(32)
|
|
224
|
+
code_challenge = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
|
225
|
+
code_challenge = base64.urlsafe_b64encode(code_challenge).rstrip(b"=").decode("ascii")
|
|
226
|
+
return code_verifier, code_challenge
|
|
227
|
+
|
|
228
|
+
def get_authorization_url(
|
|
229
|
+
self,
|
|
230
|
+
client_id: str,
|
|
231
|
+
redirect_uri: str,
|
|
232
|
+
code_challenge: str | None = None,
|
|
233
|
+
scope: str = "data:read data:write profile",
|
|
234
|
+
prompt: str = "none",
|
|
235
|
+
state: str | None = None,
|
|
236
|
+
) -> str:
|
|
237
|
+
"""Generate OAuth2 authorization URL.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
client_id: OAuth2 client ID
|
|
241
|
+
redirect_uri: Redirect URI for OAuth callback
|
|
242
|
+
code_challenge: Optional PKCE code challenge for enhanced security
|
|
243
|
+
scope: OAuth2 scopes (default: "data:read data:write profile")
|
|
244
|
+
prompt: OAuth2 prompt parameter (default: "none")
|
|
245
|
+
state: Optional state parameter for CSRF protection
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
str: The authorization URL to redirect the user to
|
|
249
|
+
"""
|
|
250
|
+
params = {
|
|
251
|
+
"client_id": client_id,
|
|
252
|
+
"redirect_uri": redirect_uri,
|
|
253
|
+
"scope": scope,
|
|
254
|
+
"prompt": prompt,
|
|
255
|
+
}
|
|
256
|
+
if code_challenge:
|
|
257
|
+
params["code_challenge"] = code_challenge
|
|
258
|
+
params["code_challenge_method"] = "S256"
|
|
259
|
+
if state:
|
|
260
|
+
params["state"] = state
|
|
261
|
+
|
|
262
|
+
base_url = self.url
|
|
263
|
+
path = "/oauth/authorize"
|
|
264
|
+
return urllib.parse.urljoin(base_url, path + "?" + urllib.parse.urlencode(params))
|
|
265
|
+
|
|
266
|
+
def exchange_code_for_token(
|
|
267
|
+
self,
|
|
268
|
+
code: str,
|
|
269
|
+
client_id: str,
|
|
270
|
+
code_verifier: str | None = None,
|
|
271
|
+
client_secret: str | None = None,
|
|
272
|
+
redirect_uri: str | None = None,
|
|
273
|
+
persist: bool = True,
|
|
274
|
+
) -> TokenResponse:
|
|
275
|
+
"""Exchange authorization code for access and refresh tokens.
|
|
276
|
+
|
|
277
|
+
This method exchanges an authorization code for tokens and automatically
|
|
278
|
+
sets them on the client instance.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
code: The authorization code received from the OAuth callback
|
|
282
|
+
client_id: OAuth2 client ID
|
|
283
|
+
code_verifier: PKCE code verifier (required if PKCE was used in authorization)
|
|
284
|
+
client_secret: Client secret for standard OAuth2 flow
|
|
285
|
+
redirect_uri: Redirect URI if required by the server
|
|
286
|
+
persist: Whether to persist tokens to storage (default: True)
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
TokenResponse: The token response containing access_token, refresh_token, etc.
|
|
290
|
+
|
|
291
|
+
Raises:
|
|
292
|
+
HTTPStatusError: If the token exchange fails
|
|
293
|
+
"""
|
|
294
|
+
token_data = {
|
|
295
|
+
"grant_type": "authorization_code",
|
|
296
|
+
"client_id": client_id,
|
|
297
|
+
"code": code,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if code_verifier:
|
|
301
|
+
token_data["code_verifier"] = code_verifier
|
|
302
|
+
if client_secret:
|
|
303
|
+
token_data["client_secret"] = client_secret
|
|
304
|
+
if redirect_uri:
|
|
305
|
+
token_data["redirect_uri"] = redirect_uri
|
|
306
|
+
|
|
307
|
+
response = httpx.post(
|
|
308
|
+
f"{self.url}/api/v1/oauth/token",
|
|
309
|
+
data=token_data,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
self._raise_for_status(response)
|
|
314
|
+
except httpx.HTTPStatusError as e:
|
|
315
|
+
raise Exception(f"Token exchange failed: {e}") from e
|
|
316
|
+
|
|
317
|
+
token_response = TokenResponse.model_validate(response.json())
|
|
318
|
+
|
|
319
|
+
self.api_key = token_response.access_token
|
|
320
|
+
self.jwt = token_response.access_token # For backward compatibility
|
|
321
|
+
self.refresh_token = token_response.refresh_token
|
|
322
|
+
|
|
323
|
+
if persist:
|
|
324
|
+
self._save_tokens(token_response.access_token, token_response.refresh_token)
|
|
325
|
+
|
|
326
|
+
return token_response
|
|
327
|
+
|
|
204
328
|
def login(self, persist_api_key: bool = True):
|
|
205
329
|
"""Initiates the OAuth2 login flow for SweatStack authentication.
|
|
206
330
|
|
|
@@ -242,9 +366,7 @@ class OAuth2Mixin:
|
|
|
242
366
|
self.wfile.write(AUTH_SUCCESSFUL_RESPONSE.encode())
|
|
243
367
|
self.server.server_close()
|
|
244
368
|
|
|
245
|
-
code_verifier =
|
|
246
|
-
code_challenge = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
|
247
|
-
code_challenge = base64.urlsafe_b64encode(code_challenge).rstrip(b"=").decode("ascii")
|
|
369
|
+
code_verifier, code_challenge = self.generate_pkce_params()
|
|
248
370
|
|
|
249
371
|
while True:
|
|
250
372
|
port = random.randint(8000, 9000)
|
|
@@ -255,16 +377,15 @@ class OAuth2Mixin:
|
|
|
255
377
|
continue
|
|
256
378
|
|
|
257
379
|
redirect_uri = f"http://localhost:{port}"
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
"
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
authorization_url = urllib.parse.urljoin(base_url, path + "?" + urllib.parse.urlencode(params))
|
|
380
|
+
|
|
381
|
+
authorization_url = self.get_authorization_url(
|
|
382
|
+
client_id=OAUTH2_CLIENT_ID,
|
|
383
|
+
redirect_uri=redirect_uri,
|
|
384
|
+
code_challenge=code_challenge,
|
|
385
|
+
scope="data:read data:write profile",
|
|
386
|
+
prompt="none",
|
|
387
|
+
)
|
|
388
|
+
|
|
268
389
|
webbrowser.open(authorization_url)
|
|
269
390
|
|
|
270
391
|
print(f"Waiting for authorization... (listening on port {port})")
|
|
@@ -278,29 +399,17 @@ class OAuth2Mixin:
|
|
|
278
399
|
raise Exception("SweatStack Python login timed out after 30 seconds. Please try again.")
|
|
279
400
|
|
|
280
401
|
if hasattr(server, "code"):
|
|
281
|
-
token_data = {
|
|
282
|
-
"grant_type": "authorization_code",
|
|
283
|
-
"client_id": OAUTH2_CLIENT_ID,
|
|
284
|
-
"code": server.code,
|
|
285
|
-
"code_verifier": code_verifier,
|
|
286
|
-
}
|
|
287
|
-
response = httpx.post(
|
|
288
|
-
f"{self.url}/api/v1/oauth/token",
|
|
289
|
-
data=token_data,
|
|
290
|
-
)
|
|
291
402
|
try:
|
|
292
|
-
self.
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
self._save_tokens(self.api_key, self.refresh_token)
|
|
303
|
-
print(f"SweatStack Python login successful.")
|
|
403
|
+
token_response = self.exchange_code_for_token(
|
|
404
|
+
code=server.code,
|
|
405
|
+
client_id=OAUTH2_CLIENT_ID,
|
|
406
|
+
code_verifier=code_verifier,
|
|
407
|
+
persist=persist_api_key,
|
|
408
|
+
)
|
|
409
|
+
self.jwt = token_response.access_token
|
|
410
|
+
print("SweatStack Python login successful.")
|
|
411
|
+
except Exception as e:
|
|
412
|
+
raise Exception("SweatStack Python login failed. Please try again.") from e
|
|
304
413
|
else:
|
|
305
414
|
raise Exception("SweatStack Python login failed. Please try again.")
|
|
306
415
|
|
|
@@ -337,7 +446,9 @@ class OAuth2Mixin:
|
|
|
337
446
|
self.login(persist_api_key=persist_api_key)
|
|
338
447
|
|
|
339
448
|
|
|
340
|
-
class
|
|
449
|
+
class _DelegationMixin:
|
|
450
|
+
"""User delegation methods for accessing data on behalf of other users."""
|
|
451
|
+
|
|
341
452
|
def _validate_user(self, user: str | UserSummary):
|
|
342
453
|
if isinstance(user, UserSummary):
|
|
343
454
|
return user.id
|
|
@@ -540,7 +651,18 @@ class DelegationMixin:
|
|
|
540
651
|
)
|
|
541
652
|
|
|
542
653
|
|
|
543
|
-
class Client(
|
|
654
|
+
class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixin):
|
|
655
|
+
"""SweatStack API client for accessing activities, traces, and user data.
|
|
656
|
+
|
|
657
|
+
The Client handles authentication, API requests, and data retrieval from SweatStack.
|
|
658
|
+
You can initialize it with credentials or use authenticate()/login() for OAuth2.
|
|
659
|
+
|
|
660
|
+
Example:
|
|
661
|
+
client = Client()
|
|
662
|
+
client.authenticate()
|
|
663
|
+
activities = client.get_activities(limit=10)
|
|
664
|
+
"""
|
|
665
|
+
|
|
544
666
|
def __init__(
|
|
545
667
|
self,
|
|
546
668
|
api_key: str | None = None,
|
|
@@ -548,6 +670,14 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin, LocalCacheMixin):
|
|
|
548
670
|
url: str | None = None,
|
|
549
671
|
streamlit_compatible: bool = False,
|
|
550
672
|
):
|
|
673
|
+
"""Initialize a SweatStack client.
|
|
674
|
+
|
|
675
|
+
Args:
|
|
676
|
+
api_key: Optional API access token. If not provided, will check environment or storage.
|
|
677
|
+
refresh_token: Optional refresh token for automatic token renewal.
|
|
678
|
+
url: Optional SweatStack instance URL. Defaults to production.
|
|
679
|
+
streamlit_compatible: Set to True when using in Streamlit apps.
|
|
680
|
+
"""
|
|
551
681
|
self.api_key = api_key
|
|
552
682
|
self.refresh_token = refresh_token
|
|
553
683
|
self.url = url
|
|
@@ -585,6 +715,11 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin, LocalCacheMixin):
|
|
|
585
715
|
|
|
586
716
|
@property
|
|
587
717
|
def api_key(self) -> str:
|
|
718
|
+
"""The current API access token.
|
|
719
|
+
|
|
720
|
+
Automatically loads from instance, environment (SWEATSTACK_API_KEY),
|
|
721
|
+
or persistent storage. Refreshes expired tokens automatically.
|
|
722
|
+
"""
|
|
588
723
|
if self._api_key is not None:
|
|
589
724
|
value = self._api_key
|
|
590
725
|
elif value := os.getenv("SWEATSTACK_API_KEY"):
|
|
@@ -604,6 +739,10 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin, LocalCacheMixin):
|
|
|
604
739
|
|
|
605
740
|
@property
|
|
606
741
|
def refresh_token(self) -> str:
|
|
742
|
+
"""The refresh token used for automatic token renewal.
|
|
743
|
+
|
|
744
|
+
Loads from instance, environment (SWEATSTACK_REFRESH_TOKEN), or persistent storage.
|
|
745
|
+
"""
|
|
607
746
|
if self._refresh_token is not None:
|
|
608
747
|
return self._refresh_token
|
|
609
748
|
elif value := os.getenv("SWEATSTACK_REFRESH_TOKEN"):
|
|
@@ -735,6 +874,38 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin, LocalCacheMixin):
|
|
|
735
874
|
else:
|
|
736
875
|
return df
|
|
737
876
|
|
|
877
|
+
def _create_empty_dataframe_from_model(self, model_class, normalize_columns: list[str] | None = None) -> pd.DataFrame:
|
|
878
|
+
"""Create an empty DataFrame with proper schema from a Pydantic model.
|
|
879
|
+
|
|
880
|
+
Args:
|
|
881
|
+
model_class: The Pydantic model class to extract schema from
|
|
882
|
+
normalize_columns: Optional list of columns to normalize (expand nested fields)
|
|
883
|
+
|
|
884
|
+
Returns:
|
|
885
|
+
pd.DataFrame: Empty DataFrame with columns matching the model schema
|
|
886
|
+
"""
|
|
887
|
+
# Create a dummy instance with all None values to get the structure
|
|
888
|
+
fields = model_class.model_fields
|
|
889
|
+
dummy_data = {}
|
|
890
|
+
for field_name, field_info in fields.items():
|
|
891
|
+
dummy_data[field_name] = None
|
|
892
|
+
|
|
893
|
+
# Create a single-row DataFrame then drop the row to preserve schema
|
|
894
|
+
df = pd.DataFrame([dummy_data])
|
|
895
|
+
|
|
896
|
+
# Normalize specified columns if requested
|
|
897
|
+
if normalize_columns:
|
|
898
|
+
for column in normalize_columns:
|
|
899
|
+
if column in df.columns:
|
|
900
|
+
# Create empty normalized columns
|
|
901
|
+
normalized = pd.DataFrame()
|
|
902
|
+
df = pd.concat([df.drop(column, axis=1), normalized], axis=1)
|
|
903
|
+
|
|
904
|
+
# Drop the dummy row to create empty DataFrame
|
|
905
|
+
df = df.iloc[0:0]
|
|
906
|
+
|
|
907
|
+
return df
|
|
908
|
+
|
|
738
909
|
def get_activities(
|
|
739
910
|
self,
|
|
740
911
|
*,
|
|
@@ -773,10 +944,17 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin, LocalCacheMixin):
|
|
|
773
944
|
offset=offset,
|
|
774
945
|
))
|
|
775
946
|
if as_dataframe:
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
947
|
+
if not activities:
|
|
948
|
+
# Return empty DataFrame with proper schema
|
|
949
|
+
df = self._create_empty_dataframe_from_model(
|
|
950
|
+
ActivitySummary,
|
|
951
|
+
normalize_columns=["summary", "laps", "traces"]
|
|
952
|
+
)
|
|
953
|
+
else:
|
|
954
|
+
df = pd.DataFrame([activity.model_dump() for activity in activities])
|
|
955
|
+
df = self._normalize_dataframe_column(df, "summary")
|
|
956
|
+
df = self._normalize_dataframe_column(df, "laps")
|
|
957
|
+
df = self._normalize_dataframe_column(df, "traces")
|
|
780
958
|
return self._postprocess_dataframe(df)
|
|
781
959
|
else:
|
|
782
960
|
return activities
|
|
@@ -904,6 +1082,41 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin, LocalCacheMixin):
|
|
|
904
1082
|
df = pd.read_parquet(BytesIO(response.content))
|
|
905
1083
|
return self._postprocess_dataframe(df)
|
|
906
1084
|
|
|
1085
|
+
def get_activity_awd(
|
|
1086
|
+
self,
|
|
1087
|
+
activity_id: str,
|
|
1088
|
+
metric: Literal[Metric.power, Metric.speed] | Literal["power", "speed"] | None = None,
|
|
1089
|
+
) -> pd.DataFrame:
|
|
1090
|
+
"""Gets the accumulated work duration (AWD) for a specific activity.
|
|
1091
|
+
|
|
1092
|
+
This method retrieves accumulated work duration metrics for a specific activity.
|
|
1093
|
+
AWD represents the total duration spent at each intensity level by sorting
|
|
1094
|
+
activity data by intensity.
|
|
1095
|
+
|
|
1096
|
+
Args:
|
|
1097
|
+
activity_id: The unique identifier of the activity.
|
|
1098
|
+
metric: Optional metric type. Defaults to power for cycling, speed for other sports.
|
|
1099
|
+
Can be either "power" or "speed".
|
|
1100
|
+
|
|
1101
|
+
Returns:
|
|
1102
|
+
pd.DataFrame: A pandas DataFrame containing the AWD data.
|
|
1103
|
+
|
|
1104
|
+
Raises:
|
|
1105
|
+
HTTPStatusError: If the API request fails.
|
|
1106
|
+
"""
|
|
1107
|
+
params = {}
|
|
1108
|
+
if metric is not None:
|
|
1109
|
+
params["metric"] = self._enums_to_strings([metric])[0]
|
|
1110
|
+
|
|
1111
|
+
with self._http_client() as client:
|
|
1112
|
+
response = client.get(
|
|
1113
|
+
url=f"/api/v1/activities/{activity_id}/accumulated-work-duration",
|
|
1114
|
+
params=params,
|
|
1115
|
+
)
|
|
1116
|
+
self._raise_for_status(response)
|
|
1117
|
+
df = pd.read_parquet(BytesIO(response.content))
|
|
1118
|
+
return self._postprocess_dataframe(df)
|
|
1119
|
+
|
|
907
1120
|
def get_latest_activity_data(
|
|
908
1121
|
self,
|
|
909
1122
|
sport: Sport | str | None = None,
|
|
@@ -1077,6 +1290,57 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin, LocalCacheMixin):
|
|
|
1077
1290
|
df = pd.read_parquet(BytesIO(response.content))
|
|
1078
1291
|
return self._postprocess_dataframe(df)
|
|
1079
1292
|
|
|
1293
|
+
def get_longitudinal_awd(
|
|
1294
|
+
self,
|
|
1295
|
+
*,
|
|
1296
|
+
sport: Sport | str,
|
|
1297
|
+
metric: Literal[Metric.power, Metric.speed] | Literal["power", "speed"],
|
|
1298
|
+
date: date | str | None = None,
|
|
1299
|
+
window_days: int | None = None,
|
|
1300
|
+
) -> pd.DataFrame:
|
|
1301
|
+
"""Gets the longitudinal accumulated work duration (AWD) for a specific sport and metric.
|
|
1302
|
+
|
|
1303
|
+
This method retrieves AWD values across four intensity levels: max (highest daily AWD),
|
|
1304
|
+
hard, medium, and easy (sustainable durations for respective workout intensities).
|
|
1305
|
+
|
|
1306
|
+
Note: This endpoint is in development and subject to change.
|
|
1307
|
+
|
|
1308
|
+
Args:
|
|
1309
|
+
sport: The sport to get AWD data for. Can be a Sport enum or string ID.
|
|
1310
|
+
metric: The metric to calculate AWD for. Must be either "power" or "speed".
|
|
1311
|
+
date: Optional reference date for the AWD calculation. If provided,
|
|
1312
|
+
the AWD will be calculated up to this date. Can be a date object
|
|
1313
|
+
or string in ISO format.
|
|
1314
|
+
window_days: Optional number of days to include in the calculation window
|
|
1315
|
+
before the reference date. If None, all available data is used.
|
|
1316
|
+
|
|
1317
|
+
Returns:
|
|
1318
|
+
pd.DataFrame: A pandas DataFrame containing the longitudinal AWD data with intensity levels.
|
|
1319
|
+
|
|
1320
|
+
Raises:
|
|
1321
|
+
HTTPStatusError: If the API request fails.
|
|
1322
|
+
"""
|
|
1323
|
+
sport = self._enums_to_strings([sport])[0]
|
|
1324
|
+
metric = self._enums_to_strings([metric])[0]
|
|
1325
|
+
|
|
1326
|
+
params = {
|
|
1327
|
+
"sport": sport,
|
|
1328
|
+
"metric": metric,
|
|
1329
|
+
}
|
|
1330
|
+
if date is not None:
|
|
1331
|
+
params["date"] = date
|
|
1332
|
+
if window_days is not None:
|
|
1333
|
+
params["window_days"] = window_days
|
|
1334
|
+
|
|
1335
|
+
with self._http_client() as client:
|
|
1336
|
+
response = client.get(
|
|
1337
|
+
url="/api/v1/activities/longitudinal-accumulated-work-duration",
|
|
1338
|
+
params=params,
|
|
1339
|
+
)
|
|
1340
|
+
self._raise_for_status(response)
|
|
1341
|
+
df = pd.read_parquet(BytesIO(response.content))
|
|
1342
|
+
return self._postprocess_dataframe(df)
|
|
1343
|
+
|
|
1080
1344
|
def _get_traces_generator(
|
|
1081
1345
|
self,
|
|
1082
1346
|
*,
|
|
@@ -1466,6 +1730,9 @@ _generate_singleton_methods(
|
|
|
1466
1730
|
[
|
|
1467
1731
|
"login",
|
|
1468
1732
|
"authenticate",
|
|
1733
|
+
"get_authorization_url",
|
|
1734
|
+
"exchange_code_for_token",
|
|
1735
|
+
"generate_pkce_params",
|
|
1469
1736
|
|
|
1470
1737
|
"get_user",
|
|
1471
1738
|
"get_users",
|
|
@@ -1480,6 +1747,7 @@ _generate_singleton_methods(
|
|
|
1480
1747
|
"get_activity",
|
|
1481
1748
|
"get_activity_data",
|
|
1482
1749
|
"get_activity_mean_max",
|
|
1750
|
+
"get_activity_awd",
|
|
1483
1751
|
|
|
1484
1752
|
"get_latest_activity",
|
|
1485
1753
|
"get_latest_activity_data",
|
|
@@ -1487,6 +1755,7 @@ _generate_singleton_methods(
|
|
|
1487
1755
|
|
|
1488
1756
|
"get_longitudinal_data",
|
|
1489
1757
|
"get_longitudinal_mean_max",
|
|
1758
|
+
"get_longitudinal_awd",
|
|
1490
1759
|
|
|
1491
1760
|
"get_traces",
|
|
1492
1761
|
"create_trace",
|
sweatstack/schemas.py
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
|
+
"""SweatStack data schemas and utilities.
|
|
2
|
+
|
|
3
|
+
This module re-exports Pydantic models from openapi_schemas and extends
|
|
4
|
+
the Sport and Metric enums with convenient utility methods.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
sport = Sport.cycling_road
|
|
8
|
+
print(sport.display_name()) # "cycling (road)"
|
|
9
|
+
print(sport.root_sport()) # Sport.cycling
|
|
10
|
+
print(sport.is_root_sport()) # False
|
|
11
|
+
print(sport.is_sub_sport_of(Sport.cycling)) # True
|
|
12
|
+
"""
|
|
1
13
|
from enum import Enum
|
|
2
14
|
from typing import List, Union
|
|
3
15
|
|
|
4
16
|
from .openapi_schemas import (
|
|
5
17
|
ActivityDetails, ActivitySummary, BackfillStatus, Metric, Scope, Sport,
|
|
6
|
-
TraceDetails, UserInfoResponse, UserSummary
|
|
18
|
+
TokenResponse, TraceDetails, UserInfoResponse, UserSummary
|
|
7
19
|
)
|
|
8
20
|
|
|
9
21
|
|
|
10
|
-
def
|
|
22
|
+
def _parent_sport(sport: Sport) -> Sport:
|
|
11
23
|
"""Returns the parent sport of a given sport.
|
|
12
24
|
|
|
13
25
|
For sports with a hierarchical structure (e.g., 'cycling.road'), returns the parent sport
|
|
@@ -25,7 +37,7 @@ def parent_sport(sport: Sport) -> Sport:
|
|
|
25
37
|
return sport.__class__(".".join(parts[:-1]))
|
|
26
38
|
|
|
27
39
|
|
|
28
|
-
def
|
|
40
|
+
def _root_sport(sport: Sport) -> Sport:
|
|
29
41
|
"""Returns the root sport of a given sport.
|
|
30
42
|
|
|
31
43
|
For sports with a hierarchical structure (e.g., 'cycling.road' or 'cycling.road.gravel'),
|
|
@@ -40,7 +52,7 @@ def root_sport(sport: Sport) -> Sport:
|
|
|
40
52
|
return sport.__class__(sport.value.split(".")[0])
|
|
41
53
|
|
|
42
54
|
|
|
43
|
-
def
|
|
55
|
+
def _is_root_sport(sport: Sport) -> bool:
|
|
44
56
|
"""Determines if a sport is a root sport.
|
|
45
57
|
|
|
46
58
|
A root sport is one that doesn't have a parent sport in the hierarchy
|
|
@@ -52,10 +64,10 @@ def is_root_sport(sport: Sport) -> bool:
|
|
|
52
64
|
Returns:
|
|
53
65
|
bool: True if the sport is a root sport, False otherwise.
|
|
54
66
|
"""
|
|
55
|
-
return sport ==
|
|
67
|
+
return sport == _root_sport(sport)
|
|
56
68
|
|
|
57
69
|
|
|
58
|
-
def
|
|
70
|
+
def _is_sub_sport_of(sport: Sport, sport_or_sports: Union[Sport, List[Sport]]) -> bool:
|
|
59
71
|
"""Determines if a sport is a sub-sport of another sport or list of sports.
|
|
60
72
|
|
|
61
73
|
For example, 'cycling.road' is a sub-sport of 'cycling', but not of 'running'.
|
|
@@ -73,12 +85,12 @@ def is_sub_sport_of(sport: Sport, sport_or_sports: Union[Sport, List[Sport]]) ->
|
|
|
73
85
|
if isinstance(sport_or_sports, Sport):
|
|
74
86
|
return sport.value.startswith(sport_or_sports.value)
|
|
75
87
|
elif isinstance(sport_or_sports, (list, tuple)):
|
|
76
|
-
return any(
|
|
88
|
+
return any(_is_sub_sport_of(sport, s) for s in sport_or_sports)
|
|
77
89
|
else:
|
|
78
90
|
raise ValueError(f"Invalid type for sport_or_sports: {type(sport_or_sports)}")
|
|
79
91
|
|
|
80
92
|
|
|
81
|
-
def
|
|
93
|
+
def _display_name(sport: Sport) -> str:
|
|
82
94
|
"""Returns a human-readable display name for a sport.
|
|
83
95
|
|
|
84
96
|
This function converts a Sport enum value into a formatted string suitable for display.
|
|
@@ -98,13 +110,23 @@ def display_name(sport: Sport) -> str:
|
|
|
98
110
|
return f"{base_sport} ({the_rest})"
|
|
99
111
|
|
|
100
112
|
|
|
101
|
-
Sport.root_sport =
|
|
102
|
-
Sport.
|
|
103
|
-
Sport.is_sub_sport_of = is_sub_sport_of
|
|
104
|
-
Sport.display_name = display_name
|
|
113
|
+
Sport.root_sport = _root_sport
|
|
114
|
+
Sport.root_sport.__doc__ = _root_sport.__doc__
|
|
105
115
|
|
|
116
|
+
Sport.parent_sport = _parent_sport
|
|
117
|
+
Sport.parent_sport.__doc__ = _parent_sport.__doc__
|
|
106
118
|
|
|
107
|
-
|
|
119
|
+
Sport.is_sub_sport_of = _is_sub_sport_of
|
|
120
|
+
Sport.is_sub_sport_of.__doc__ = _is_sub_sport_of.__doc__
|
|
121
|
+
|
|
122
|
+
Sport.is_root_sport = _is_root_sport
|
|
123
|
+
Sport.is_root_sport.__doc__ = _is_root_sport.__doc__
|
|
124
|
+
|
|
125
|
+
Sport.display_name = _display_name
|
|
126
|
+
Sport.display_name.__doc__ = _display_name.__doc__
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _metric_display_name(metric: Metric) -> str:
|
|
108
130
|
"""Returns a human-readable display name for a metric.
|
|
109
131
|
|
|
110
132
|
This function converts a Metric enum value into a formatted string suitable for display.
|
|
@@ -112,4 +134,5 @@ def metric_display_name(metric: Metric) -> str:
|
|
|
112
134
|
return metric.value.replace("_", " ")
|
|
113
135
|
|
|
114
136
|
|
|
115
|
-
Metric.display_name =
|
|
137
|
+
Metric.display_name = _metric_display_name
|
|
138
|
+
Metric.display_name.__doc__ = _metric_display_name.__doc__
|
sweatstack/streamlit.py
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
"""Streamlit integration for SweatStack authentication and UI components.
|
|
2
|
+
|
|
3
|
+
This module provides authentication and UI helper components for building
|
|
4
|
+
Streamlit applications with SweatStack. The StreamlitAuth class handles
|
|
5
|
+
OAuth2 authentication flow and provides convenient selector components.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
import streamlit as st
|
|
9
|
+
from sweatstack.streamlit import StreamlitAuth
|
|
10
|
+
|
|
11
|
+
auth = StreamlitAuth(
|
|
12
|
+
client_id="YOUR_APPLICATION_ID",
|
|
13
|
+
client_secret="YOUR_APPLICATION_SECRET",
|
|
14
|
+
redirect_uri="http://localhost:8501",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
with st.sidebar:
|
|
18
|
+
auth.authenticate()
|
|
19
|
+
|
|
20
|
+
if not auth.is_authenticated():
|
|
21
|
+
st.stop()
|
|
22
|
+
|
|
23
|
+
st.write("Welcome!")
|
|
24
|
+
latest = auth.client.get_latest_activity()
|
|
25
|
+
st.write(f"Latest: {latest.sport}")
|
|
26
|
+
"""
|
|
1
27
|
import os
|
|
2
28
|
import urllib.parse
|
|
3
29
|
from datetime import date
|
|
@@ -18,6 +44,46 @@ from .schemas import Metric, Scope, Sport
|
|
|
18
44
|
|
|
19
45
|
|
|
20
46
|
class StreamlitAuth:
|
|
47
|
+
"""Handles SweatStack authentication and provides UI components for Streamlit apps.
|
|
48
|
+
|
|
49
|
+
This class manages OAuth2 authentication flow for Streamlit applications and provides
|
|
50
|
+
convenient selector components for activities, sports, tags, and metrics. Once authenticated,
|
|
51
|
+
the client property provides access to the SweatStack API.
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
import streamlit as st
|
|
55
|
+
from sweatstack.streamlit import StreamlitAuth
|
|
56
|
+
|
|
57
|
+
# Initialize authentication
|
|
58
|
+
auth = StreamlitAuth(
|
|
59
|
+
client_id="YOUR_APPLICATION_ID",
|
|
60
|
+
client_secret="YOUR_APPLICATION_SECRET",
|
|
61
|
+
redirect_uri="http://localhost:8501",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Add authentication to sidebar
|
|
65
|
+
with st.sidebar:
|
|
66
|
+
auth.authenticate()
|
|
67
|
+
|
|
68
|
+
# Check authentication
|
|
69
|
+
if not auth.is_authenticated():
|
|
70
|
+
st.write("Please log in to continue")
|
|
71
|
+
st.stop()
|
|
72
|
+
|
|
73
|
+
# Use the authenticated client
|
|
74
|
+
st.write("Welcome to SweatStack")
|
|
75
|
+
latest_activity = auth.client.get_latest_activity()
|
|
76
|
+
st.write(f"Latest activity: {latest_activity.sport} on {latest_activity.start}")
|
|
77
|
+
|
|
78
|
+
# Switch between accessible users (admin feature)
|
|
79
|
+
with st.sidebar:
|
|
80
|
+
auth.select_user()
|
|
81
|
+
|
|
82
|
+
Attributes:
|
|
83
|
+
client: The SweatStack Client instance for API access.
|
|
84
|
+
api_key: The current API access token.
|
|
85
|
+
"""
|
|
86
|
+
|
|
21
87
|
def __init__(
|
|
22
88
|
self,
|
|
23
89
|
client_id=None,
|
|
@@ -25,12 +91,13 @@ class StreamlitAuth:
|
|
|
25
91
|
scopes: List[Union[str, Scope]]=None,
|
|
26
92
|
redirect_uri=None,
|
|
27
93
|
):
|
|
28
|
-
"""
|
|
94
|
+
"""Initialize the StreamlitAuth component.
|
|
95
|
+
|
|
29
96
|
Args:
|
|
30
|
-
client_id:
|
|
31
|
-
client_secret:
|
|
32
|
-
scopes:
|
|
33
|
-
redirect_uri:
|
|
97
|
+
client_id: OAuth2 client ID. Falls back to SWEATSTACK_CLIENT_ID env var.
|
|
98
|
+
client_secret: OAuth2 client secret. Falls back to SWEATSTACK_CLIENT_SECRET env var.
|
|
99
|
+
scopes: OAuth2 scopes. Falls back to SWEATSTACK_SCOPES env var. Defaults to data:read, profile.
|
|
100
|
+
redirect_uri: OAuth2 redirect URI. Falls back to SWEATSTACK_REDIRECT_URI env var.
|
|
34
101
|
"""
|
|
35
102
|
self.client_id = client_id or os.environ.get("SWEATSTACK_CLIENT_ID")
|
|
36
103
|
self.client_secret = client_secret or os.environ.get("SWEATSTACK_CLIENT_SECRET")
|
|
@@ -51,6 +118,11 @@ class StreamlitAuth:
|
|
|
51
118
|
self.client = Client(self.api_key, streamlit_compatible=True)
|
|
52
119
|
|
|
53
120
|
def logout_button(self):
|
|
121
|
+
"""Displays a logout button and handles user logout.
|
|
122
|
+
|
|
123
|
+
When clicked, this button clears the stored API key from session state,
|
|
124
|
+
resets the client, and triggers a Streamlit rerun to update the UI.
|
|
125
|
+
"""
|
|
54
126
|
if st.button("Logout"):
|
|
55
127
|
self.api_key = None
|
|
56
128
|
self.client = Client(streamlit_compatible=True)
|
|
@@ -58,9 +130,15 @@ class StreamlitAuth:
|
|
|
58
130
|
st.rerun()
|
|
59
131
|
|
|
60
132
|
def _running_on_streamlit_cloud(self):
|
|
133
|
+
"""Detects if the app is running on Streamlit Cloud."""
|
|
61
134
|
return os.environ.get("HOSTNAME") == "streamlit"
|
|
62
135
|
|
|
63
136
|
def _show_sweatstack_login(self, login_label: str | None = None):
|
|
137
|
+
"""Displays the SweatStack login button with appropriate styling.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
login_label: Text to display on the login button.
|
|
141
|
+
"""
|
|
64
142
|
authorization_url = self.get_authorization_url()
|
|
65
143
|
login_label = login_label or "Connect with SweatStack"
|
|
66
144
|
if not self._running_on_streamlit_cloud():
|
|
@@ -96,6 +174,14 @@ class StreamlitAuth:
|
|
|
96
174
|
st.link_button(login_label, authorization_url)
|
|
97
175
|
|
|
98
176
|
def get_authorization_url(self):
|
|
177
|
+
"""Generates the OAuth2 authorization URL for SweatStack.
|
|
178
|
+
|
|
179
|
+
This method constructs the URL users will be redirected to for OAuth2 authorization.
|
|
180
|
+
It includes the client ID, redirect URI, scopes, and other OAuth2 parameters.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
str: The complete authorization URL.
|
|
184
|
+
"""
|
|
99
185
|
params = {
|
|
100
186
|
"client_id": self.client_id,
|
|
101
187
|
"redirect_uri": self.redirect_uri,
|
|
@@ -108,11 +194,24 @@ class StreamlitAuth:
|
|
|
108
194
|
return authorization_url
|
|
109
195
|
|
|
110
196
|
def _set_api_key(self, api_key):
|
|
197
|
+
"""Sets the API key in instance and session state, then refreshes the client.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
api_key: The API access token to set.
|
|
201
|
+
"""
|
|
111
202
|
self.api_key = api_key
|
|
112
203
|
st.session_state["sweatstack_api_key"] = api_key
|
|
113
204
|
self.client = Client(self.api_key, streamlit_compatible=True)
|
|
114
205
|
|
|
115
206
|
def _exchange_token(self, code):
|
|
207
|
+
"""Exchanges an authorization code for an access token.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
code: The authorization code from the OAuth2 callback.
|
|
211
|
+
|
|
212
|
+
Raises:
|
|
213
|
+
Exception: If the token exchange fails.
|
|
214
|
+
"""
|
|
116
215
|
token_data = {
|
|
117
216
|
"grant_type": "authorization_code",
|
|
118
217
|
"client_id": self.client_id,
|
|
@@ -1,17 +1,17 @@
|
|
|
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=1JW7c_yPR4YARYBo6E_mx6iN13pU0zyl8rs6oTxdiUc,65126
|
|
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
|
|
7
7
|
sweatstack/openapi_schemas.py,sha256=VvquBdbssdB9D1KeJYQCx51hy1Df4SS0PjzGWXcUaew,46221
|
|
8
8
|
sweatstack/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
-
sweatstack/schemas.py,sha256=
|
|
10
|
-
sweatstack/streamlit.py,sha256=
|
|
9
|
+
sweatstack/schemas.py,sha256=Xh9E8DjFx5NIEBnVqS6ixFVb0E06ANZbdOlnMofCpZw,4481
|
|
10
|
+
sweatstack/streamlit.py,sha256=sJSVWRAY-CT3FuEPKA41h4S0BsaVFChs0M_oVIj0VFQ,16520
|
|
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.55.0.dist-info/METADATA,sha256=sRtgay1gBJG-kQVB0InAV0tnAPbZKFZaPl0NS5sQvpk,852
|
|
15
|
+
sweatstack-0.55.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
16
|
+
sweatstack-0.55.0.dist-info/entry_points.txt,sha256=kCzOUQI3dqbTpEYqtgYDeiKFaqaA7BMlV6D24BMzCFU,208
|
|
17
|
+
sweatstack-0.55.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|