sweatstack 0.53.0__tar.gz → 0.54.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.53.0 → sweatstack-0.54.0}/CHANGELOG.md +11 -0
  2. {sweatstack-0.53.0 → sweatstack-0.54.0}/PKG-INFO +1 -1
  3. {sweatstack-0.53.0 → sweatstack-0.54.0}/pyproject.toml +1 -1
  4. {sweatstack-0.53.0 → sweatstack-0.54.0}/src/sweatstack/client.py +181 -40
  5. {sweatstack-0.53.0 → sweatstack-0.54.0}/src/sweatstack/schemas.py +1 -1
  6. {sweatstack-0.53.0 → sweatstack-0.54.0}/.claude/settings.local.json +0 -0
  7. {sweatstack-0.53.0 → sweatstack-0.54.0}/.gitignore +0 -0
  8. {sweatstack-0.53.0 → sweatstack-0.54.0}/.python-version +0 -0
  9. {sweatstack-0.53.0 → sweatstack-0.54.0}/DEVELOPMENT.md +0 -0
  10. {sweatstack-0.53.0 → sweatstack-0.54.0}/Makefile +0 -0
  11. {sweatstack-0.53.0 → sweatstack-0.54.0}/README.md +0 -0
  12. {sweatstack-0.53.0 → sweatstack-0.54.0}/playground/.ipynb_checkpoints/Untitled-checkpoint.ipynb +0 -0
  13. {sweatstack-0.53.0 → sweatstack-0.54.0}/playground/README.md +0 -0
  14. {sweatstack-0.53.0 → sweatstack-0.54.0}/playground/Sweat Stack examples/Getting started.ipynb +0 -0
  15. {sweatstack-0.53.0 → sweatstack-0.54.0}/playground/Untitled.ipynb +0 -0
  16. {sweatstack-0.53.0 → sweatstack-0.54.0}/playground/hello.py +0 -0
  17. {sweatstack-0.53.0 → sweatstack-0.54.0}/playground/pyproject.toml +0 -0
  18. {sweatstack-0.53.0 → sweatstack-0.54.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
  19. {sweatstack-0.53.0 → sweatstack-0.54.0}/src/sweatstack/__init__.py +0 -0
  20. {sweatstack-0.53.0 → sweatstack-0.54.0}/src/sweatstack/cli.py +0 -0
  21. {sweatstack-0.53.0 → sweatstack-0.54.0}/src/sweatstack/constants.py +0 -0
  22. {sweatstack-0.53.0 → sweatstack-0.54.0}/src/sweatstack/ipython_init.py +0 -0
  23. {sweatstack-0.53.0 → sweatstack-0.54.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
  24. {sweatstack-0.53.0 → sweatstack-0.54.0}/src/sweatstack/openapi_schemas.py +0 -0
  25. {sweatstack-0.53.0 → sweatstack-0.54.0}/src/sweatstack/py.typed +0 -0
  26. {sweatstack-0.53.0 → sweatstack-0.54.0}/src/sweatstack/streamlit.py +0 -0
  27. {sweatstack-0.53.0 → sweatstack-0.54.0}/src/sweatstack/sweatshell.py +0 -0
  28. {sweatstack-0.53.0 → sweatstack-0.54.0}/src/sweatstack/utils.py +0 -0
  29. {sweatstack-0.53.0 → sweatstack-0.54.0}/uv.lock +0 -0
@@ -6,6 +6,17 @@ 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.54.0] - 2025-09-11
10
+
11
+ ### Added
12
+
13
+ - Added new methods `get_authorization_url()`, `exchange_code_for_token()` and `get_pkce_params()` to the `ss.Client` class that allow for getting the authorization URL and exchanging a code for tokens. This should make it easier for clients to implement the SweatStack OAuth2 flow.
14
+
15
+ ### Fixed
16
+
17
+ - Fixed an issue where the `ss.get_activities()` with `as_dataframe=True` method would raise an error if no activities were found.
18
+
19
+
9
20
  ## [0.53.0] - 2025-09-11
10
21
 
11
22
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sweatstack
3
- Version: 0.53.0
3
+ Version: 0.54.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.53.0"
3
+ version = "0.54.0"
4
4
  description = "The official Python client for SweatStack"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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
 
@@ -201,6 +201,120 @@ except ImportError:
201
201
 
202
202
 
203
203
  class OAuth2Mixin:
204
+ def generate_pkce_params(self) -> tuple[str, str]:
205
+ """Generate PKCE parameters for OAuth2 authorization.
206
+
207
+ This method generates a code verifier and its corresponding code challenge
208
+ for use in the PKCE (Proof Key for Code Exchange) OAuth2 flow.
209
+
210
+ Returns:
211
+ tuple[str, str]: A tuple of (code_verifier, code_challenge)
212
+ """
213
+ code_verifier = secrets.token_urlsafe(32)
214
+ code_challenge = hashlib.sha256(code_verifier.encode("ascii")).digest()
215
+ code_challenge = base64.urlsafe_b64encode(code_challenge).rstrip(b"=").decode("ascii")
216
+ return code_verifier, code_challenge
217
+
218
+ def get_authorization_url(
219
+ self,
220
+ client_id: str,
221
+ redirect_uri: str,
222
+ code_challenge: str | None = None,
223
+ scope: str = "data:read data:write profile",
224
+ prompt: str = "none",
225
+ state: str | None = None,
226
+ ) -> str:
227
+ """Generate OAuth2 authorization URL.
228
+
229
+ Args:
230
+ client_id: OAuth2 client ID
231
+ redirect_uri: Redirect URI for OAuth callback
232
+ code_challenge: Optional PKCE code challenge for enhanced security
233
+ scope: OAuth2 scopes (default: "data:read data:write profile")
234
+ prompt: OAuth2 prompt parameter (default: "none")
235
+ state: Optional state parameter for CSRF protection
236
+
237
+ Returns:
238
+ str: The authorization URL to redirect the user to
239
+ """
240
+ params = {
241
+ "client_id": client_id,
242
+ "redirect_uri": redirect_uri,
243
+ "scope": scope,
244
+ "prompt": prompt,
245
+ }
246
+ if code_challenge:
247
+ params["code_challenge"] = code_challenge
248
+ params["code_challenge_method"] = "S256"
249
+ if state:
250
+ params["state"] = state
251
+
252
+ base_url = self.url
253
+ path = "/oauth/authorize"
254
+ return urllib.parse.urljoin(base_url, path + "?" + urllib.parse.urlencode(params))
255
+
256
+ def exchange_code_for_token(
257
+ self,
258
+ code: str,
259
+ client_id: str,
260
+ code_verifier: str | None = None,
261
+ client_secret: str | None = None,
262
+ redirect_uri: str | None = None,
263
+ persist: bool = True,
264
+ ) -> TokenResponse:
265
+ """Exchange authorization code for access and refresh tokens.
266
+
267
+ This method exchanges an authorization code for tokens and automatically
268
+ sets them on the client instance.
269
+
270
+ Args:
271
+ code: The authorization code received from the OAuth callback
272
+ client_id: OAuth2 client ID
273
+ code_verifier: PKCE code verifier (required if PKCE was used in authorization)
274
+ client_secret: Client secret for standard OAuth2 flow
275
+ redirect_uri: Redirect URI if required by the server
276
+ persist: Whether to persist tokens to storage (default: True)
277
+
278
+ Returns:
279
+ TokenResponse: The token response containing access_token, refresh_token, etc.
280
+
281
+ Raises:
282
+ HTTPStatusError: If the token exchange fails
283
+ """
284
+ token_data = {
285
+ "grant_type": "authorization_code",
286
+ "client_id": client_id,
287
+ "code": code,
288
+ }
289
+
290
+ if code_verifier:
291
+ token_data["code_verifier"] = code_verifier
292
+ if client_secret:
293
+ token_data["client_secret"] = client_secret
294
+ if redirect_uri:
295
+ token_data["redirect_uri"] = redirect_uri
296
+
297
+ response = httpx.post(
298
+ f"{self.url}/api/v1/oauth/token",
299
+ data=token_data,
300
+ )
301
+
302
+ try:
303
+ self._raise_for_status(response)
304
+ except httpx.HTTPStatusError as e:
305
+ raise Exception(f"Token exchange failed: {e}") from e
306
+
307
+ token_response = TokenResponse.model_validate(response.json())
308
+
309
+ self.api_key = token_response.access_token
310
+ self.jwt = token_response.access_token # For backward compatibility
311
+ self.refresh_token = token_response.refresh_token
312
+
313
+ if persist:
314
+ self._save_tokens(token_response.access_token, token_response.refresh_token)
315
+
316
+ return token_response
317
+
204
318
  def login(self, persist_api_key: bool = True):
205
319
  """Initiates the OAuth2 login flow for SweatStack authentication.
206
320
 
@@ -242,9 +356,7 @@ class OAuth2Mixin:
242
356
  self.wfile.write(AUTH_SUCCESSFUL_RESPONSE.encode())
243
357
  self.server.server_close()
244
358
 
245
- code_verifier = secrets.token_urlsafe(32)
246
- code_challenge = hashlib.sha256(code_verifier.encode("ascii")).digest()
247
- code_challenge = base64.urlsafe_b64encode(code_challenge).rstrip(b"=").decode("ascii")
359
+ code_verifier, code_challenge = self.generate_pkce_params()
248
360
 
249
361
  while True:
250
362
  port = random.randint(8000, 9000)
@@ -255,16 +367,15 @@ class OAuth2Mixin:
255
367
  continue
256
368
 
257
369
  redirect_uri = f"http://localhost:{port}"
258
- params = {
259
- "client_id": OAUTH2_CLIENT_ID,
260
- "redirect_uri": redirect_uri,
261
- "code_challenge": code_challenge,
262
- "scope": "data:read data:write profile",
263
- "prompt": "none",
264
- }
265
- base_url = self.url
266
- path = "/oauth/authorize"
267
- authorization_url = urllib.parse.urljoin(base_url, path + "?" + urllib.parse.urlencode(params))
370
+
371
+ authorization_url = self.get_authorization_url(
372
+ client_id=OAUTH2_CLIENT_ID,
373
+ redirect_uri=redirect_uri,
374
+ code_challenge=code_challenge,
375
+ scope="data:read data:write profile",
376
+ prompt="none",
377
+ )
378
+
268
379
  webbrowser.open(authorization_url)
269
380
 
270
381
  print(f"Waiting for authorization... (listening on port {port})")
@@ -278,29 +389,17 @@ class OAuth2Mixin:
278
389
  raise Exception("SweatStack Python login timed out after 30 seconds. Please try again.")
279
390
 
280
391
  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
392
  try:
292
- self._raise_for_status(response)
293
- except httpx.HTTPStatusError as e:
294
- raise Exception(f"SweatStack Python login failed. Please try again.") from e
295
- token_response = response.json()
296
-
297
- self.jwt = token_response.get("access_token")
298
- self.api_key = self.jwt
299
- self.refresh_token = token_response.get("refresh_token")
300
-
301
- if persist_api_key:
302
- self._save_tokens(self.api_key, self.refresh_token)
303
- print(f"SweatStack Python login successful.")
393
+ token_response = self.exchange_code_for_token(
394
+ code=server.code,
395
+ client_id=OAUTH2_CLIENT_ID,
396
+ code_verifier=code_verifier,
397
+ persist=persist_api_key,
398
+ )
399
+ self.jwt = token_response.access_token
400
+ print("SweatStack Python login successful.")
401
+ except Exception as e:
402
+ raise Exception("SweatStack Python login failed. Please try again.") from e
304
403
  else:
305
404
  raise Exception("SweatStack Python login failed. Please try again.")
306
405
 
@@ -735,6 +834,38 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin, LocalCacheMixin):
735
834
  else:
736
835
  return df
737
836
 
837
+ def _create_empty_dataframe_from_model(self, model_class, normalize_columns: list[str] | None = None) -> pd.DataFrame:
838
+ """Create an empty DataFrame with proper schema from a Pydantic model.
839
+
840
+ Args:
841
+ model_class: The Pydantic model class to extract schema from
842
+ normalize_columns: Optional list of columns to normalize (expand nested fields)
843
+
844
+ Returns:
845
+ pd.DataFrame: Empty DataFrame with columns matching the model schema
846
+ """
847
+ # Create a dummy instance with all None values to get the structure
848
+ fields = model_class.model_fields
849
+ dummy_data = {}
850
+ for field_name, field_info in fields.items():
851
+ dummy_data[field_name] = None
852
+
853
+ # Create a single-row DataFrame then drop the row to preserve schema
854
+ df = pd.DataFrame([dummy_data])
855
+
856
+ # Normalize specified columns if requested
857
+ if normalize_columns:
858
+ for column in normalize_columns:
859
+ if column in df.columns:
860
+ # Create empty normalized columns
861
+ normalized = pd.DataFrame()
862
+ df = pd.concat([df.drop(column, axis=1), normalized], axis=1)
863
+
864
+ # Drop the dummy row to create empty DataFrame
865
+ df = df.iloc[0:0]
866
+
867
+ return df
868
+
738
869
  def get_activities(
739
870
  self,
740
871
  *,
@@ -773,10 +904,17 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin, LocalCacheMixin):
773
904
  offset=offset,
774
905
  ))
775
906
  if as_dataframe:
776
- df = pd.DataFrame([activity.model_dump() for activity in activities])
777
- df = self._normalize_dataframe_column(df, "summary")
778
- df = self._normalize_dataframe_column(df, "laps")
779
- df = self._normalize_dataframe_column(df, "traces")
907
+ if not activities:
908
+ # Return empty DataFrame with proper schema
909
+ df = self._create_empty_dataframe_from_model(
910
+ ActivitySummary,
911
+ normalize_columns=["summary", "laps", "traces"]
912
+ )
913
+ else:
914
+ df = pd.DataFrame([activity.model_dump() for activity in activities])
915
+ df = self._normalize_dataframe_column(df, "summary")
916
+ df = self._normalize_dataframe_column(df, "laps")
917
+ df = self._normalize_dataframe_column(df, "traces")
780
918
  return self._postprocess_dataframe(df)
781
919
  else:
782
920
  return activities
@@ -1466,6 +1604,9 @@ _generate_singleton_methods(
1466
1604
  [
1467
1605
  "login",
1468
1606
  "authenticate",
1607
+ "get_authorization_url",
1608
+ "exchange_code_for_token",
1609
+ "generate_pkce_params",
1469
1610
 
1470
1611
  "get_user",
1471
1612
  "get_users",
@@ -3,7 +3,7 @@ from typing import List, Union
3
3
 
4
4
  from .openapi_schemas import (
5
5
  ActivityDetails, ActivitySummary, BackfillStatus, Metric, Scope, Sport,
6
- TraceDetails, UserInfoResponse, UserSummary
6
+ TokenResponse, TraceDetails, UserInfoResponse, UserSummary
7
7
  )
8
8
 
9
9
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes