sweatstack 0.52.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.
- {sweatstack-0.52.0 → sweatstack-0.54.0}/CHANGELOG.md +18 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/PKG-INFO +1 -1
- {sweatstack-0.52.0 → sweatstack-0.54.0}/pyproject.toml +1 -1
- {sweatstack-0.52.0 → sweatstack-0.54.0}/src/sweatstack/client.py +185 -40
- {sweatstack-0.52.0 → sweatstack-0.54.0}/src/sweatstack/schemas.py +1 -1
- {sweatstack-0.52.0 → sweatstack-0.54.0}/.claude/settings.local.json +0 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/.gitignore +0 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/.python-version +0 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/DEVELOPMENT.md +0 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/Makefile +0 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/README.md +0 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/playground/.ipynb_checkpoints/Untitled-checkpoint.ipynb +0 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/playground/README.md +0 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/playground/Sweat Stack examples/Getting started.ipynb +0 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/playground/Untitled.ipynb +0 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/playground/hello.py +0 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/playground/pyproject.toml +0 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/src/sweatstack/__init__.py +0 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/src/sweatstack/cli.py +0 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/src/sweatstack/constants.py +0 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/src/sweatstack/ipython_init.py +0 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/src/sweatstack/openapi_schemas.py +0 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/src/sweatstack/py.typed +0 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/src/sweatstack/streamlit.py +0 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/src/sweatstack/sweatshell.py +0 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/src/sweatstack/utils.py +0 -0
- {sweatstack-0.52.0 → sweatstack-0.54.0}/uv.lock +0 -0
|
@@ -6,6 +6,24 @@ 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
|
+
|
|
20
|
+
## [0.53.0] - 2025-09-11
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- Added a new `sport` parameter to the `ss.create_trace()` method that allows for associating a trace with a specific sport.
|
|
25
|
+
|
|
26
|
+
|
|
9
27
|
## [0.52.0] - 2025-09-10
|
|
10
28
|
|
|
11
29
|
### 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
|
|
|
@@ -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 =
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
"
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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.
|
|
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.")
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
|
@@ -1212,6 +1350,7 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin, LocalCacheMixin):
|
|
|
1212
1350
|
speed: float | None = None,
|
|
1213
1351
|
heart_rate: int | None = None,
|
|
1214
1352
|
tags: list[str] | None = None,
|
|
1353
|
+
sport: Sport | str | None = None,
|
|
1215
1354
|
) -> TraceDetails:
|
|
1216
1355
|
"""Creates a new trace with the specified parameters.
|
|
1217
1356
|
|
|
@@ -1227,6 +1366,7 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin, LocalCacheMixin):
|
|
|
1227
1366
|
speed: Optional speed measurement in meters per second.
|
|
1228
1367
|
heart_rate: Optional heart rate measurement in beats per minute.
|
|
1229
1368
|
tags: Optional list of tags to associate with this trace.
|
|
1369
|
+
sport: Optional sport to associate with this trace.
|
|
1230
1370
|
|
|
1231
1371
|
Returns:
|
|
1232
1372
|
TraceDetails: The created trace object with all details.
|
|
@@ -1234,6 +1374,7 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin, LocalCacheMixin):
|
|
|
1234
1374
|
Raises:
|
|
1235
1375
|
HTTPStatusError: If the API request fails.
|
|
1236
1376
|
"""
|
|
1377
|
+
sport = self._enums_to_strings([sport])[0] if sport else None
|
|
1237
1378
|
with self._http_client() as client:
|
|
1238
1379
|
response = client.post(
|
|
1239
1380
|
url="/api/v1/traces/",
|
|
@@ -1246,6 +1387,7 @@ class Client(OAuth2Mixin, DelegationMixin, TokenStorageMixin, LocalCacheMixin):
|
|
|
1246
1387
|
"speed": speed,
|
|
1247
1388
|
"heart_rate": heart_rate,
|
|
1248
1389
|
"tags": tags,
|
|
1390
|
+
"sport": sport,
|
|
1249
1391
|
},
|
|
1250
1392
|
)
|
|
1251
1393
|
self._raise_for_status(response)
|
|
@@ -1462,6 +1604,9 @@ _generate_singleton_methods(
|
|
|
1462
1604
|
[
|
|
1463
1605
|
"login",
|
|
1464
1606
|
"authenticate",
|
|
1607
|
+
"get_authorization_url",
|
|
1608
|
+
"exchange_code_for_token",
|
|
1609
|
+
"generate_pkce_params",
|
|
1465
1610
|
|
|
1466
1611
|
"get_user",
|
|
1467
1612
|
"get_users",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sweatstack-0.52.0 → sweatstack-0.54.0}/playground/.ipynb_checkpoints/Untitled-checkpoint.ipynb
RENAMED
|
File without changes
|
|
File without changes
|
{sweatstack-0.52.0 → sweatstack-0.54.0}/playground/Sweat Stack examples/Getting started.ipynb
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sweatstack-0.52.0 → sweatstack-0.54.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|