sweatstack 0.43.0__tar.gz → 0.45.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 (28) hide show
  1. sweatstack-0.45.0/CHANGELOG.md +26 -0
  2. {sweatstack-0.43.0 → sweatstack-0.45.0}/PKG-INFO +2 -1
  3. {sweatstack-0.43.0 → sweatstack-0.45.0}/pyproject.toml +2 -1
  4. {sweatstack-0.43.0 → sweatstack-0.45.0}/src/sweatstack/client.py +119 -4
  5. {sweatstack-0.43.0 → sweatstack-0.45.0}/src/sweatstack/ipython_init.py +1 -5
  6. {sweatstack-0.43.0 → sweatstack-0.45.0}/src/sweatstack/jupyterlab_oauth2_startup.py +1 -4
  7. {sweatstack-0.43.0 → sweatstack-0.45.0}/src/sweatstack/schemas.py +12 -1
  8. {sweatstack-0.43.0 → sweatstack-0.45.0}/.gitignore +0 -0
  9. {sweatstack-0.43.0 → sweatstack-0.45.0}/.python-version +0 -0
  10. {sweatstack-0.43.0 → sweatstack-0.45.0}/DEVELOPMENT.md +0 -0
  11. {sweatstack-0.43.0 → sweatstack-0.45.0}/Makefile +0 -0
  12. {sweatstack-0.43.0 → sweatstack-0.45.0}/README.md +0 -0
  13. {sweatstack-0.43.0 → sweatstack-0.45.0}/playground/.ipynb_checkpoints/Untitled-checkpoint.ipynb +0 -0
  14. {sweatstack-0.43.0 → sweatstack-0.45.0}/playground/README.md +0 -0
  15. {sweatstack-0.43.0 → sweatstack-0.45.0}/playground/Sweat Stack examples/Getting started.ipynb +0 -0
  16. {sweatstack-0.43.0 → sweatstack-0.45.0}/playground/Untitled.ipynb +0 -0
  17. {sweatstack-0.43.0 → sweatstack-0.45.0}/playground/hello.py +0 -0
  18. {sweatstack-0.43.0 → sweatstack-0.45.0}/playground/pyproject.toml +0 -0
  19. {sweatstack-0.43.0 → sweatstack-0.45.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
  20. {sweatstack-0.43.0 → sweatstack-0.45.0}/src/sweatstack/__init__.py +0 -0
  21. {sweatstack-0.43.0 → sweatstack-0.45.0}/src/sweatstack/cli.py +0 -0
  22. {sweatstack-0.43.0 → sweatstack-0.45.0}/src/sweatstack/constants.py +0 -0
  23. {sweatstack-0.43.0 → sweatstack-0.45.0}/src/sweatstack/openapi_schemas.py +0 -0
  24. {sweatstack-0.43.0 → sweatstack-0.45.0}/src/sweatstack/py.typed +0 -0
  25. {sweatstack-0.43.0 → sweatstack-0.45.0}/src/sweatstack/streamlit.py +0 -0
  26. {sweatstack-0.43.0 → sweatstack-0.45.0}/src/sweatstack/sweatshell.py +0 -0
  27. {sweatstack-0.43.0 → sweatstack-0.45.0}/src/sweatstack/utils.py +0 -0
  28. {sweatstack-0.43.0 → sweatstack-0.45.0}/uv.lock +0 -0
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+
9
+ ## [0.45.0] - 2025-06-24
10
+
11
+ ### Added
12
+
13
+ - Added a new `ss.whoami()` method that returns the authenticated user's summary information. This method is recommended over `ss.get_userinfo()` which only exists for OpenID compatibility and requires the `profile` scope.
14
+ - Added a new `ss.Metric.display_name()` method that returns a human-readable display name for a metric. For example, `ss.Metric.heart_rate.display_name()` returns "heart rate".
15
+
16
+ ## [0.44.0] - 2025-06-18
17
+
18
+ ### Added
19
+
20
+ - Added support for persistent storage of API keys and refresh tokens.
21
+ - Added a new `ss.authenticate()` method that handles authentication comprehensively, including calling `ss.login()` when needed. This method is now the recommended way to authenticate the client.
22
+
23
+
24
+ ## Changed
25
+
26
+ - The `sweatlab` and `sweatshell` commands now use the new `ss.authenticate()` method.
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sweatstack
3
- Version: 0.43.0
3
+ Version: 0.45.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
7
7
  Requires-Dist: httpx>=0.28.1
8
8
  Requires-Dist: pandas>=2.2.3
9
+ Requires-Dist: platformdirs>=4.0.0
9
10
  Requires-Dist: pyarrow>=18.0.0
10
11
  Requires-Dist: pydantic>=2.10.5
11
12
  Provides-Extra: jupyter
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sweatstack"
3
- version = "0.43.0"
3
+ version = "0.45.0"
4
4
  description = "The official Python client for SweatStack"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -10,6 +10,7 @@ requires-python = ">=3.9"
10
10
  dependencies = [
11
11
  "httpx>=0.28.1",
12
12
  "pandas>=2.2.3",
13
+ "platformdirs>=4.0.0",
13
14
  "pyarrow>=18.0.0",
14
15
  "pydantic>=2.10.5",
15
16
  ]
@@ -1,5 +1,6 @@
1
1
  import base64
2
2
  import contextlib
3
+ import json
3
4
  import random
4
5
  import hashlib
5
6
  import logging
@@ -14,11 +15,13 @@ from functools import wraps
14
15
  from http.server import BaseHTTPRequestHandler, HTTPServer
15
16
  from importlib.metadata import version
16
17
  from io import BytesIO
18
+ from pathlib import Path
17
19
  from typing import Any, Generator, get_type_hints, List, Literal
18
20
  from urllib.parse import parse_qs, urlparse
19
21
 
20
22
  import httpx
21
23
  import pandas as pd
24
+ from platformdirs import user_data_dir
22
25
 
23
26
  from .constants import DEFAULT_URL
24
27
  from .schemas import (
@@ -45,6 +48,45 @@ AUTH_SUCCESSFUL_RESPONSE = """<!DOCTYPE html>
45
48
  OAUTH2_CLIENT_ID = "5382f68b0d254378"
46
49
 
47
50
 
51
+ class TokenStorageMixin:
52
+ """Mixin for handling persistent token storage using platformdirs."""
53
+
54
+ def _get_token_file_path(self) -> Path:
55
+ """Get the path to the token storage file."""
56
+ data_dir = user_data_dir("SweatStack", "SweatStack")
57
+ return Path(data_dir) / "tokens.json"
58
+
59
+ def _save_tokens(self, access_token: str, refresh_token: str) -> None:
60
+ """Save tokens to the user data directory."""
61
+ token_file = self._get_token_file_path()
62
+ token_file.parent.mkdir(parents=True, exist_ok=True)
63
+
64
+ token_data = {
65
+ "access_token": access_token,
66
+ "refresh_token": refresh_token
67
+ }
68
+
69
+ with open(token_file, "w") as f:
70
+ json.dump(token_data, f, indent=2)
71
+
72
+ # Set restrictive permissions (user read/write only)
73
+ token_file.chmod(0o600)
74
+
75
+ def _load_persistent_tokens(self) -> tuple[str | None, str | None]:
76
+ """Load tokens from the user data directory."""
77
+ token_file = self._get_token_file_path()
78
+
79
+ if not token_file.exists():
80
+ return None, None
81
+
82
+ try:
83
+ with open(token_file, "r") as f:
84
+ token_data = json.load(f)
85
+ return token_data.get("access_token"), token_data.get("refresh_token")
86
+ except (json.JSONDecodeError, FileNotFoundError, KeyError):
87
+ return None, None
88
+
89
+
48
90
  try:
49
91
  __version__ = version("sweatstack")
50
92
  except ImportError:
@@ -52,7 +94,7 @@ except ImportError:
52
94
 
53
95
 
54
96
  class OAuth2Mixin:
55
- def login(self):
97
+ def login(self, persist_api_key: bool = True):
56
98
  """Initiates the OAuth2 login flow for SweatStack authentication.
57
99
 
58
100
  This method starts a local HTTP server to receive the OAuth2 callback,
@@ -62,6 +104,10 @@ class OAuth2Mixin:
62
104
  The method uses PKCE (Proof Key for Code Exchange) for enhanced security
63
105
  during the OAuth2 authorization code flow.
64
106
 
107
+ Args:
108
+ persist_api_key: Whether to save the API key to persistent storage for future use.
109
+ Defaults to True.
110
+
65
111
  Returns:
66
112
  None
67
113
 
@@ -144,10 +190,45 @@ class OAuth2Mixin:
144
190
  self.jwt = token_response.get("access_token")
145
191
  self.api_key = self.jwt
146
192
  self.refresh_token = token_response.get("refresh_token")
193
+
194
+ if persist_api_key:
195
+ self._save_tokens(self.api_key, self.refresh_token)
147
196
  print(f"SweatStack Python login successful.")
148
197
  else:
149
198
  raise Exception("SweatStack Python login failed. Please try again.")
150
199
 
200
+ def authenticate(self, *, persist_api_key: bool = True, force_login: bool = False) -> None:
201
+ """Ensures the client is authenticated, either using existing tokens or by initiating login.
202
+
203
+ This method checks for authentication in the following order:
204
+ 1. Current instance tokens (if already set)
205
+ 2. Environment variables (SWEATSTACK_API_KEY, SWEATSTACK_REFRESH_TOKEN)
206
+ 3. Persistent storage tokens
207
+ 4. If none found or force_login is True, initiates OAuth2 login flow
208
+
209
+ Args:
210
+ persist_api_key: Whether to save tokens to persistent storage after login.
211
+ Defaults to True.
212
+ force_login: Whether to force a new login even if tokens are available.
213
+ Defaults to False.
214
+
215
+ Returns:
216
+ None
217
+
218
+ Raises:
219
+ Exception: If the authentication process fails.
220
+ """
221
+ if force_login:
222
+ self.login(persist_api_key=persist_api_key)
223
+ return
224
+
225
+ # Check if we already have valid tokens
226
+ if self.api_key:
227
+ return
228
+
229
+ # If no tokens available, initiate login
230
+ self.login(persist_api_key=persist_api_key)
231
+
151
232
 
152
233
  class DelegationMixin:
153
234
  def _validate_user(self, user: str | UserSummary):
@@ -352,7 +433,7 @@ class DelegationMixin:
352
433
  )
353
434
 
354
435
 
355
- class Client(OAuth2Mixin, DelegationMixin):
436
+ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin):
356
437
  def __init__(
357
438
  self,
358
439
  api_key: str | None = None,
@@ -399,8 +480,10 @@ class Client(OAuth2Mixin, DelegationMixin):
399
480
  def api_key(self) -> str:
400
481
  if self._api_key is not None:
401
482
  value = self._api_key
483
+ elif value := os.getenv("SWEATSTACK_API_KEY"):
484
+ pass
402
485
  else:
403
- value = os.getenv("SWEATSTACK_API_KEY")
486
+ value, _ = self._load_persistent_tokens()
404
487
 
405
488
  if value is None:
406
489
  # A non-authenticated client is a potentially valid use-case.
@@ -416,8 +499,12 @@ class Client(OAuth2Mixin, DelegationMixin):
416
499
  def refresh_token(self) -> str:
417
500
  if self._refresh_token is not None:
418
501
  return self._refresh_token
502
+ elif value := os.getenv("SWEATSTACK_REFRESH_TOKEN"):
503
+ pass
419
504
  else:
420
- return os.getenv("SWEATSTACK_REFRESH_TOKEN")
505
+ _, value = self._load_persistent_tokens()
506
+
507
+ return value
421
508
 
422
509
  @refresh_token.setter
423
510
  def refresh_token(self, value: str):
@@ -1117,6 +1204,32 @@ class Client(OAuth2Mixin, DelegationMixin):
1117
1204
  self._raise_for_status(response)
1118
1205
  return UserInfoResponse.model_validate(response.json())
1119
1206
 
1207
+ def whoami(self) -> UserSummary:
1208
+ """Gets the authenticated user's summary information.
1209
+
1210
+ This method retrieves basic information about the currently authenticated user
1211
+ by extracting the user ID from the JWT token and fetching the user details.
1212
+
1213
+ Returns:
1214
+ UserSummary: A UserSummary object containing the authenticated user's information.
1215
+
1216
+ Raises:
1217
+ ValueError: If no authentication token is available.
1218
+ HTTPStatusError: If the API request fails or user is not found.
1219
+ """
1220
+ if not self.api_key:
1221
+ raise ValueError("Not authenticated. Please call authenticate() or login() first.")
1222
+
1223
+ try:
1224
+ jwt_body = decode_jwt_body(self.api_key)
1225
+ user_id = jwt_body.get("sub")
1226
+ if not user_id:
1227
+ raise ValueError("Unable to extract user ID from token")
1228
+ except Exception as e:
1229
+ raise ValueError(f"Invalid authentication token: {e}")
1230
+
1231
+ return self._get_user_by_id(user_id)
1232
+
1120
1233
 
1121
1234
  _default_client = Client()
1122
1235
 
@@ -1156,10 +1269,12 @@ def _generate_singleton_methods(method_names: List[str]) -> None:
1156
1269
  _generate_singleton_methods(
1157
1270
  [
1158
1271
  "login",
1272
+ "authenticate",
1159
1273
 
1160
1274
  "get_user",
1161
1275
  "get_users",
1162
1276
  "get_userinfo",
1277
+ "whoami",
1163
1278
 
1164
1279
  "get_activities",
1165
1280
 
@@ -1,12 +1,8 @@
1
- import time
2
-
3
1
  import sweatstack as ss
4
2
 
5
3
 
6
4
  print("\n")
7
5
  print(">>>>>>>>>> Sweat Stack Initialization <<<<<<<<<")
8
6
  print("Initializing....")
9
- print("You will be redirected to your browser for authentication.\n")
10
- time.sleep(2)
11
7
 
12
- ss.login()
8
+ ss.authenticate()
@@ -5,7 +5,6 @@ from pathlib import Path
5
5
 
6
6
  import sweatstack as ss
7
7
  from jupyterlab.labapp import LabApp
8
- from sweatstack.client import _default_client
9
8
 
10
9
 
11
10
  def start_jupyterlab_with_oauth():
@@ -24,9 +23,7 @@ def start_jupyterlab_with_oauth():
24
23
  if not target_dir.exists():
25
24
  shutil.copytree(examples_dir, target_dir)
26
25
 
27
- ss.login()
28
- os.environ["SWEATSTACK_API_KEY"] = _default_client.api_key
29
- os.environ["SWEATSTACK_REFRESH_TOKEN"] = _default_client.refresh_token
26
+ ss.authenticate()
30
27
 
31
28
 
32
29
  return LabApp.launch_instance(argv=remaining_args)
@@ -101,4 +101,15 @@ def display_name(sport: Sport) -> str:
101
101
  Sport.root_sport = root_sport
102
102
  Sport.parent_sport = parent_sport
103
103
  Sport.is_sub_sport_of = is_sub_sport_of
104
- Sport.display_name = display_name
104
+ Sport.display_name = display_name
105
+
106
+
107
+ def metric_display_name(metric: Metric) -> str:
108
+ """Returns a human-readable display name for a metric.
109
+
110
+ This function converts a Metric enum value into a formatted string suitable for display.
111
+ """
112
+ return metric.value.replace("_", " ")
113
+
114
+
115
+ Metric.display_name = metric_display_name
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes