sweatstack 0.11.1__py3-none-any.whl → 0.12.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 CHANGED
@@ -1,136 +1,35 @@
1
1
  import base64
2
+ import contextlib
3
+ import random
2
4
  import hashlib
3
5
  import os
4
- import random
5
6
  import secrets
7
+ import time
6
8
  import urllib
7
9
  import webbrowser
8
- from http.server import HTTPServer, BaseHTTPRequestHandler
9
- from io import BytesIO, IOBase, StringIO
10
- from contextlib import contextmanager
11
- from datetime import date, datetime, timedelta, timezone
12
- from pathlib import Path
13
- from typing import Dict, Iterator, List,Union
14
- from urllib.parse import parse_qs, urljoin, urlparse
15
-
10
+ from datetime import date, datetime
11
+ from functools import wraps
12
+ from http.server import BaseHTTPRequestHandler, HTTPServer
13
+ from io import BytesIO
14
+ from typing import Any, Generator, get_type_hints, List, Literal
15
+ from urllib.parse import parse_qs, urlparse
16
+
17
+ import httpx
16
18
  import pandas as pd
17
- try:
18
- import pyarrow
19
- except ImportError:
20
- pyarrow = None
21
- import requests
22
-
23
-
24
- from .schemas import ActivityDetail, ActivitySummary, Metric, PermissionType, Sport, User
25
- try:
26
- from .plotting import PlottingMixin
27
- except ImportError as e:
28
- PlottingMixin = object
29
-
30
-
31
- AUTH_SUCCESSFUL_RESPONSE = """
32
- <!DOCTYPE html>
33
- <html lang="en">
34
- <head>
35
- <meta charset="UTF-8">
36
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
37
- <title>Authorization Successful</title>
38
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tachyons/4.11.1/tachyons.min.css">
39
- </head>
40
- <body class="bg-light-gray vh-100 flex items-center justify-center">
41
- <article class="mw6 center bg-white br3 pa3 pa4-ns mv3 ba b--black-10">
42
- <div class="tc">
43
- <div class="flex justify-center items-center">
44
- <img src="https://sweatstack.no/images/favicon-white-bg-small.png" alt="Sweat Stack Logo" class="h4 w4 dib pa2 ml2">
45
- <div class="f1 b black ph3">❤️</div>
46
- <img src="https://s3.dualstack.us-east-2.amazonaws.com/pythondotorg-assets/media/community/logos/python-logo-only.png" alt="Python Logo" class="h4 w4 dib pa2 ml2">
47
- </div>
48
- <h1 class="f2 mb2">Sweat Stack Python login successful</h1>
49
- </div>
50
- <p class="lh-copy measure center f4 black-70">
51
- You can now close this window and return to your Python code.
52
- </p>
53
- </article>
54
- <script>
55
- setTimeout(function() {
56
- window.close();
57
- }, 5000);
58
- </script>
59
- </body>
60
- </html>
61
- """
62
-
63
-
64
- SWEAT_STACK_CLIENT_ID = "j0mX9SQQAJXHmf6jUsbX"
65
-
66
-
67
- if "SWEAT_STACK_URL" not in os.environ:
68
- os.environ["SWEAT_STACK_URL"] = "https://app.sweatstack.no"
69
-
70
-
71
- class ProgressBar:
72
- def __init__(self, description, total, bar_length=20):
73
- self.description = description
74
- self.total = total
75
- self.bar_length = bar_length
76
- self.current = 0
77
-
78
- def __enter__(self):
79
- return self
80
-
81
- def __exit__(self, exc_type, exc_val, exc_tb):
82
- self.show_progress(self.total)
83
- print() # Print a newline at the end
84
-
85
- def show_progress(self, current):
86
- self.current = current
87
- progress = self.current / self.total
88
- filled_length = int(self.bar_length * progress)
89
- bar = '#' * filled_length + "-" * (self.bar_length - filled_length)
90
- print(f"\r{self.description}: [{bar}] {progress:.0%} ({current}/{self.total})", end="", flush=True)
91
-
92
-
93
- class Session(requests.Session):
94
- def __init__(self, *args, **kwargs):
95
- super().__init__(*args, **kwargs)
96
-
97
- def _get_url(self):
98
- """
99
- This method enables easy switching between Sweat Stack instances after module import.
100
- """
101
- return os.environ.get("SWEAT_STACK_URL")
102
19
 
103
- def request(self, method, url, *args, **kwargs):
104
- url = urljoin(self._get_url(), url)
105
- return super().request(method, url, *args, **kwargs)
20
+ from .schemas import ActivityDetails, ActivitySummary, Sport, TraceDetails
21
+ from .utils import decode_jwt_body
106
22
 
23
+ AUTH_SUCCESSFUL_RESPONSE = "<!DOCTYPE html><html><body><h1>Authentication successful. You can now close this window.</h1></body></html>"
24
+ OAUTH2_CLIENT_ID = "5382f68b0d254378"
25
+ DEFAULT_URL = "https://app.sweatstack.no"
107
26
 
108
- class SweatStack(PlottingMixin):
109
- def __init__(self, jwt: str = None):
110
- self.jwt = jwt
111
- self.root_jwt = self.jwt
112
27
 
113
- @property
114
- def jwt(self):
115
- if self._jwt is not None:
116
- return self._jwt
117
- else:
118
- return os.environ.get("SWEAT_STACK_API_KEY")
119
-
120
- @jwt.setter
121
- def jwt(self, value):
122
- self._jwt = value
123
-
124
- def _get_url(self):
125
- """
126
- This method enables easy switching between Sweat Stack instances after module import.
127
- """
128
- return os.environ.get("SWEAT_STACK_URL")
129
-
28
+ class OAuth2Mixin:
130
29
  def login(self):
131
30
  class AuthHandler(BaseHTTPRequestHandler):
132
31
  def log_message(self, format, *args):
133
- # Override to disable logging
32
+ # This override disables logging.
134
33
  pass
135
34
 
136
35
  def do_GET(self):
@@ -158,11 +57,12 @@ class SweatStack(PlottingMixin):
158
57
 
159
58
  redirect_uri = f"http://localhost:{port}"
160
59
  params = {
161
- "client_id": SWEAT_STACK_CLIENT_ID,
60
+ "client_id": OAUTH2_CLIENT_ID,
162
61
  "redirect_uri": redirect_uri,
163
62
  "code_challenge": code_challenge,
63
+ "scope": "data:read",
164
64
  }
165
- base_url = self._get_url()
65
+ base_url = self.url
166
66
  path = "/oauth/authorize"
167
67
  authorization_url = urllib.parse.urljoin(base_url, path + "?" + urllib.parse.urlencode(params))
168
68
  webbrowser.open(authorization_url)
@@ -175,403 +75,523 @@ class SweatStack(PlottingMixin):
175
75
  try:
176
76
  server.handle_request()
177
77
  except TimeoutError:
178
- raise Exception("Sweat Stack Python login timed out after 30 seconds. Please try again.")
78
+ raise Exception("SweatStack Python login timed out after 30 seconds. Please try again.")
179
79
 
180
80
  if hasattr(server, "code"):
181
81
  token_data = {
182
82
  "grant_type": "authorization_code",
183
- "client_id": SWEAT_STACK_CLIENT_ID,
83
+ "client_id": OAUTH2_CLIENT_ID,
184
84
  "code": server.code,
185
85
  "code_verifier": code_verifier
186
86
  }
187
- response = requests.post(
188
- f"{self._get_url()}/oauth/token",
87
+ response = httpx.post(
88
+ f"{self.url}/oauth/token",
189
89
  data=token_data,
190
90
  )
191
- response.raise_for_status()
91
+ try:
92
+ response.raise_for_status()
93
+ except httpx.HTTPStatusError as e:
94
+ raise Exception(f"SweatStack Python login failed. Please try again.") from e
192
95
  token_response = response.json()
193
96
 
194
97
  self.jwt = token_response.get("access_token")
195
- os.environ["SWEAT_STACK_API_KEY"] = self.jwt # This env variable is for example used by the JupyterLab extension.
196
- print(f"Sweat Stack Python login successful.")
98
+ self.api_key = self.jwt
99
+ self.refresh_token = token_response.get("refresh_token")
100
+ print(f"SweatStack Python login successful.")
197
101
  else:
198
- raise Exception("Sweat Stack Python login failed. Please try again.")
199
-
200
- @contextmanager
201
- def _http_client(self):
202
- headers = {
203
- "authorization": f"Bearer {self.jwt}"
204
- }
205
- with Session() as session:
206
- session.headers.update(headers)
207
- session.base_url = self._get_url()
208
- yield session
209
-
210
- def list_users(self, permission_type: Union[PermissionType, str] = None) -> List[User]:
211
- if permission_type is not None:
212
- params = {"type": permission_type.value if isinstance(permission_type, PermissionType) else permission_type}
213
- else:
214
- params = {}
102
+ raise Exception("SweatStack Python login failed. Please try again.")
215
103
 
216
- with self._http_client() as client:
217
- response = client.get("/api/users/", params=params)
218
- response.raise_for_status()
219
- users = response.json()
220
104
 
221
- return [User.model_validate(user) for user in users]
222
-
223
- def list_accessible_users(self) -> List[User]:
224
- return self.list_users(permission_type=PermissionType.received)
225
-
226
- def whoami(self) -> User:
105
+ class Client(OAuth2Mixin):
106
+ def __init__(
107
+ self,
108
+ api_key: str | None = None,
109
+ refresh_token: str | None = None,
110
+ url: str | None = None,
111
+ ):
112
+ self.api_key = api_key
113
+ self.refresh_token = refresh_token
114
+ self.url = url
115
+
116
+ def _do_token_refresh(self, tz_offset: int) -> str:
227
117
  with self._http_client() as client:
228
- response = client.get("/api/users/me")
118
+ response = client.post(
119
+ "/api/v1/oauth/token",
120
+ json={
121
+ "grant_type": "refresh_token",
122
+ "refresh_token": self.refresh_token,
123
+ "tz_offset": tz_offset,
124
+ },
125
+ )
126
+
229
127
  response.raise_for_status()
230
- return User.model_validate(response.json())
128
+ return response.json()["access_token"]
129
+
130
+ def _check_token_expiry(self, token: str) -> str:
131
+ try:
132
+ body = decode_jwt_body(token)
133
+ # Margin in seconds to account for time to token validation of the next request
134
+ TOKEN_EXPIRY_MARGIN = 5
135
+ if body["exp"] - TOKEN_EXPIRY_MARGIN < time.time():
136
+ # Token is (almost) expired, refresh it
137
+ token = self._do_token_refresh(body["tz_offset"])
138
+ self._api_key = token
139
+ except Exception:
140
+ # If token can't be decoded, just return as-is
141
+ # @TODO: This probably should be handled differently
142
+ pass
143
+
144
+ return token
145
+
146
+ @property
147
+ def api_key(self) -> str:
148
+ if self._api_key is not None:
149
+ value = self._api_key
150
+ else:
151
+ value = os.getenv("SWEATSTACK_API_KEY")
152
+
153
+ if value is None:
154
+ # A non-authenticated client is a potentially valid use-case.
155
+ return None
156
+
157
+ return self._check_token_expiry(value)
158
+
159
+ @api_key.setter
160
+ def api_key(self, value: str):
161
+ self._api_key = value
231
162
 
232
- def get_delegated_token(self, user: Union[User, str]):
233
- if isinstance(user, str):
234
- user_id = user
163
+ @property
164
+ def refresh_token(self) -> str:
165
+ if self._refresh_token is not None:
166
+ return self._refresh_token
235
167
  else:
236
- user_id = user.id
168
+ return os.getenv("SWEATSTACK_REFRESH_TOKEN")
237
169
 
238
- with self._http_client() as client:
239
- response = client.get(
240
- f"/api/users/{user_id}/delegated-token",
241
- )
242
- response.raise_for_status()
243
- return response.json()["jwt"]
170
+ @refresh_token.setter
171
+ def refresh_token(self, value: str):
172
+ self._refresh_token = value
173
+
174
+ @property
175
+ def url(self) -> str:
176
+ """
177
+ This determines which SweatStack URL to use, allowing the use of a non-default instance.
178
+ This is useful for example during local development.
179
+ Please note that changing the url probably requires changing the `OAUTH2_CLIENT_ID` as well.
180
+ """
181
+ if self._url is not None:
182
+ return self._url
183
+
184
+ if env_url := os.getenv("SWEATSTACK_URL"):
185
+ return env_url
186
+
187
+ return DEFAULT_URL
244
188
 
245
- def switch_user(self, user: Union[User, str]):
246
- self.root_jwt = self.jwt
247
- self.jwt = self.get_delegated_token(user)
189
+ @url.setter
190
+ def url(self, value: str):
191
+ self._url = value
248
192
 
249
- def switch_to_root_user(self):
193
+ @contextlib.contextmanager
194
+ def _http_client(self):
250
195
  """
251
- Switch back to the root user by setting the JWT to the root JWT.
196
+ Creates an httpx client with the base URL and authentication headers pre-configured.
252
197
  """
253
- self.jwt = self.root_jwt
254
-
255
- def _check_timezone_aware(self, date_obj: Union[date, datetime]):
256
- if not isinstance(date_obj, date) and date_obj.tzinfo is None and date_obj.tzinfo.utcoffset(date_obj) is None:
257
- return date_obj.replace(tzinfo=timezone.utc)
258
- else:
259
- return date_obj
260
-
261
- def _fetch_activities(
262
- self,
263
- sport: Union[Sport, str] = None,
264
- start: Union[date, datetime] = None,
265
- end: Union[date, datetime] = None,
266
- limit: int = None,
267
- as_pydantic: bool = False,
268
- ) -> Iterator[Union[Dict, ActivitySummary]]:
269
- if limit is None:
270
- limit = 1000
271
- activities_count = 0
272
-
273
- params = {}
274
- if sport is not None:
275
- if isinstance(sport, Sport):
276
- sport = sport.value
277
- params["sport"] = sport
198
+ headers = {}
199
+ if self.api_key:
200
+ headers["Authorization"] = f"Bearer {self.api_key}"
201
+
202
+ with httpx.Client(base_url=self.url, headers=headers) as client:
203
+ yield client
278
204
 
205
+ def _get_activities_generator(
206
+ self,
207
+ *,
208
+ start: date | None = None,
209
+ end: date | None = None,
210
+ sports: list[Sport | str] | None = None,
211
+ tags: list[str] | None = None,
212
+ limit: int = 100,
213
+ ) -> Generator[ActivitySummary, None, None]:
214
+ num_returned = 0
215
+ default_limit = 100
216
+ params = {
217
+ "limit": default_limit,
218
+ "offset": 0,
219
+ }
279
220
  if start is not None:
280
- params["start"] = self._check_timezone_aware(start).isoformat()
281
-
221
+ params["start"] = start.isoformat()
282
222
  if end is not None:
283
- params["end"] = self._check_timezone_aware(end).isoformat()
223
+ params["end"] = end.isoformat()
224
+ if sports is not None:
225
+ params["sports"] = sports
226
+ if tags is not None:
227
+ params["tags"] = tags
284
228
 
285
229
  with self._http_client() as client:
286
- step_size = limit
287
- offset = 0
288
-
289
230
  while True:
290
- params["limit"] = step_size
291
- params["offset"] = offset
292
- response = client.get("/api/activities/", params=params)
231
+ response = client.get(
232
+ url="/api/v1/activities/",
233
+ params=params,
234
+ )
293
235
  response.raise_for_status()
294
236
  activities = response.json()
295
-
296
237
  for activity in activities:
297
- activities_count += 1
298
- if limit is not None and activities_count > limit:
299
- break
300
- yield ActivitySummary.model_validate(activity) if as_pydantic else activity
238
+ yield ActivitySummary.model_validate(activity)
301
239
 
302
- if limit is not None and activities_count > limit or len(activities) < step_size:
303
- break
240
+ num_returned += 1
241
+ if num_returned >= limit:
242
+ return
243
+ if len(activities) < default_limit:
244
+ return
304
245
 
305
- offset += step_size
246
+ params["limit"] = min(default_limit, limit - num_returned)
247
+ params["offset"] += default_limit
306
248
 
307
-
308
- def list_activities(self, sport: Union[Sport, str] = None, start: Union[date, datetime] = None, end: Union[date, datetime] = None, limit: int = None, as_dataframe: bool = True) -> Union[Iterator[Dict], pd.DataFrame]:
249
+ def get_activities(
250
+ self,
251
+ *,
252
+ start: date | None = None,
253
+ end: date | None = None,
254
+ sports: list[Sport | str] | None = None,
255
+ tags: list[str] | None = None,
256
+ limit: int = 100,
257
+ as_dataframe: bool = False,
258
+ ) -> Generator[ActivitySummary, None, None] | pd.DataFrame:
259
+ generator = self._get_activities_generator(
260
+ start=start,
261
+ end=end,
262
+ sports=sports,
263
+ tags=tags,
264
+ limit=limit,
265
+ )
309
266
  if as_dataframe:
310
- return pd.DataFrame(
311
- self._fetch_activities(
312
- sport=sport,
313
- start=start,
314
- end=end,
315
- limit=limit,
316
- as_pydantic=False,
317
- )
318
- )
319
- else:
320
- return self._fetch_activities(
321
- sport=sport,
322
- start=start,
323
- end=end,
324
- limit=limit,
325
- as_pydantic=True,
326
- )
327
-
328
- def get_longitudinal_data(
329
- self,
330
- sport: Union[Sport, str],
331
- metrics: List[Union[Metric, str]],
332
- start: Union[date, datetime] = None,
333
- end: Union[date, datetime] = None,
334
- ) -> pd.DataFrame:
335
-
336
- params = {}
337
- if sport is not None:
338
- if isinstance(sport, Sport):
339
- sport = sport.value
340
- params["sport"] = sport
341
-
342
- if metrics is not None:
343
- new_metrics = []
344
- for metric in metrics:
345
- if isinstance(metric, Metric):
346
- new_metrics.append(metric.value)
347
- else:
348
- new_metrics.append(metric)
349
- params["metrics"] = new_metrics
350
-
351
- if start is not None:
352
- params["start"] = self._check_timezone_aware(start).isoformat()
353
- else:
354
- params["start"] = (date.today() - timedelta(days=30)).isoformat()
355
-
356
- if end is not None:
357
- params["end"] = self._check_timezone_aware(end).isoformat()
358
-
359
- try:
360
- import pyarrow
361
- except ImportError:
362
- params["response_format"] = "csv"
363
- else:
364
- params["response_format"] = "parquet"
365
-
366
- with self._http_client() as client:
367
- response = client.get(f"/api/activities/data", params=params)
368
- response.raise_for_status()
369
- buffer = BytesIO(response.content)
370
-
371
- if params["response_format"] == "parquet":
372
- data = pd.read_parquet(buffer, engine="pyarrow")
267
+ return pd.DataFrame([activity.model_dump() for activity in generator])
373
268
  else:
374
- data = pd.read_csv(buffer)
375
- data.index = pd.to_datetime(data.index)
376
- data["duration"] = pd.to_timedelta(data["duration"])
269
+ return generator
377
270
 
378
- return data
379
-
380
- def get_activity(self, activity_id: str) -> ActivityDetail:
271
+ def get_latest_activity(
272
+ self,
273
+ *,
274
+ start: date | None = None,
275
+ end: date | None = None,
276
+ sport: Sport | None = None,
277
+ tag: str | None = None,
278
+ ) -> ActivityDetails:
279
+ return next(self.get_activities(
280
+ start=start,
281
+ end=end,
282
+ sports=[sport] if sport is not None else None,
283
+ tags=[tag] if tag is not None else None,
284
+ limit=1,
285
+ ))
286
+
287
+ def get_activity(self, activity_id: str) -> ActivityDetails:
381
288
  with self._http_client() as client:
382
- response = client.get(f"/api/activities/{activity_id}")
289
+ response = client.get(url=f"/api/v1/activities/{activity_id}")
383
290
  response.raise_for_status()
384
- return ActivityDetail(**response.json())
291
+ return ActivityDetails.model_validate(response.json())
385
292
 
386
- def get_latest_activity(self) -> ActivityDetail:
387
- activity = next(self._fetch_activities(limit=1, as_pydantic=True))
388
- return self.get_activity(activity.id)
389
-
390
- def get_activity_data(self, activity_id: str, fusion: bool = None) -> pd.DataFrame:
293
+ def get_activity_data(
294
+ self,
295
+ activity_id: str,
296
+ adaptive_sampling_on: Literal["power", "speed"] | None = None,
297
+ ) -> pd.DataFrame:
391
298
  params = {}
392
- if fusion is not None:
393
- params["fusion"] = fusion
299
+ if adaptive_sampling_on is not None:
300
+ params["adaptive_sampling_on"] = adaptive_sampling_on
394
301
 
395
302
  with self._http_client() as client:
396
303
  response = client.get(
397
- f"/api/activities/{activity_id}/data",
304
+ url=f"/api/v1/activities/{activity_id}/data",
398
305
  params=params,
399
306
  )
400
307
 
401
- response.raise_for_status()
308
+ response.raise_for_status()
402
309
 
403
- response = response.json()
404
- data = pd.read_json(StringIO(response["data"]), orient="split")
405
- data.index = pd.to_datetime(data.index)
406
- data["duration"] = pd.to_timedelta(data["duration"], unit="ms")
310
+ return pd.read_parquet(BytesIO(response.content))
407
311
 
408
- data.attrs["activity"] = None
409
- data.attrs["column_mapping"] = None
410
- data.attrs["activity"] = ActivityDetail.model_validate(response["activity"])
411
- data.attrs["column_mapping"] = response["column_mapping"]
312
+ def get_activity_mean_max(
313
+ self,
314
+ activity_id: str,
315
+ metric: str,
316
+ adaptive_sampling: bool = False,
317
+ ) -> pd.DataFrame:
318
+ with self._http_client() as client:
319
+ response = client.get(
320
+ url=f"/api/v1/activities/{activity_id}/mean-max",
321
+ params={
322
+ "metric": metric,
323
+ "adaptive_sampling": adaptive_sampling,
324
+ },
325
+ )
326
+ response.raise_for_status()
327
+ return pd.read_parquet(BytesIO(response.content))
412
328
 
413
- return data
414
-
415
- def get_latest_activity_data(self, fusion: bool = None) -> pd.DataFrame:
416
- activity = self.get_latest_activity()
417
- return self.get_activity_data(activity.id, fusion=fusion)
329
+ def get_latest_activity_data(
330
+ self,
331
+ sport: Sport | None = None,
332
+ adaptive_sampling_on: Literal["power", "speed"] | None = None,
333
+ ) -> pd.DataFrame:
334
+ activity = self.get_latest_activity(sport=sport)
335
+ return self.get_activity_data(activity.id, adaptive_sampling_on)
418
336
 
419
- def get_accumulated_work_duration(self, start: date, sport: Union[Sport, str], metric: Union[Metric, str], end: date=None) -> pd.DataFrame:
420
- if not isinstance(start, date):
421
- start = date.fromisoformat(start)
337
+ def get_latest_activity_mean_max(
338
+ self,
339
+ metric: str,
340
+ sport: Sport | None = None,
341
+ adaptive_sampling: bool = False,
342
+ ) -> pd.DataFrame:
343
+ activity = self.get_latest_activity(sport=sport)
344
+ return self.get_activity_mean_max(activity.id, metric, adaptive_sampling)
422
345
 
423
- if end is None:
424
- end = date.today()
425
- if not isinstance(end, date):
426
- end = date.fromisoformat(end)
346
+ def get_longitudinal_data(
347
+ self,
348
+ *,
349
+ sport: Sport | None = None,
350
+ sports: list[Sport | str] | None = None,
351
+ start: date | str,
352
+ end: date | str | None = None,
353
+ metrics: list[str] | None = None,
354
+ adaptive_sampling_on: Literal["power", "speed"] | None = None,
355
+ ) -> pd.DataFrame:
356
+ if sport and sports:
357
+ raise ValueError("Cannot specify both sport and sports")
358
+ if sport is not None:
359
+ sports = [sport]
360
+ elif sports is None:
361
+ sports = []
427
362
 
428
- if not isinstance(metric, Metric):
429
- metric = Metric(metric)
430
- if not isinstance(sport, Sport):
431
- sport = Sport(sport)
363
+ params = {
364
+ "sports": sports,
365
+ "start": start,
366
+ }
367
+ if end is not None:
368
+ params["end"] = end
369
+ if metrics is not None:
370
+ params["metrics"] = metrics
371
+ if adaptive_sampling_on is not None:
372
+ params["adaptive_sampling_on"] = adaptive_sampling_on
432
373
 
433
374
  with self._http_client() as client:
434
375
  response = client.get(
435
- "/api/activities/accumulated-work-duration",
436
- params={
437
- "start": start.strftime("%Y-%m-%dT%H:%M:%SZ"),
438
- "end": end.strftime("%Y-%m-%dT%H:%M:%SZ"),
439
- "sport": sport.value,
440
- "metric": metric.value,
441
- }
376
+ url="/api/v1/activities/longitudinal-data",
377
+ params=params,
442
378
  )
443
379
  response.raise_for_status()
444
380
 
445
- awd = pd.read_json(
446
- StringIO(response.json()),
447
- orient="split",
448
- date_unit="s",
449
- typ="series",
450
- )
451
- awd = pd.to_timedelta(awd, unit="seconds")
452
- awd.name = "duration"
453
- awd.index.name = metric.value
454
- return awd
381
+ return pd.read_parquet(BytesIO(response.content))
455
382
 
456
- def get_mean_max(
383
+ def get_longitudinal_mean_max(
457
384
  self,
458
385
  *,
459
- sport: Union[Sport, str],
460
- metric: Union[Metric, str],
461
- start: Union[date, str] = None,
462
- end: Union[date, str] = None,
386
+ sport: Sport | str,
387
+ metric: str,
388
+ date: date | str | None = None,
389
+ window_days: int | None = None,
463
390
  ) -> pd.DataFrame:
464
- if start is None:
465
- start = date.today() - timedelta(days=30)
466
- elif not isinstance(start, date):
467
- start = date.fromisoformat(start)
468
-
469
- if end is None:
470
- end = date.today()
471
- elif not isinstance(end, date):
472
- end = date.fromisoformat(end)
473
-
474
- if not isinstance(metric, Metric):
475
- metric = Metric(metric)
476
- if not isinstance(sport, Sport):
477
- sport = Sport(sport)
391
+ params = {
392
+ "sport": sport,
393
+ "metric": metric,
394
+ }
395
+ if date is not None:
396
+ params["date"] = date
397
+ if window_days is not None:
398
+ params["window_days"] = window_days
478
399
 
479
400
  with self._http_client() as client:
480
401
  response = client.get(
481
- "/api/activities/mean-max",
482
- params={
483
- "start": start.strftime("%Y-%m-%dT%H:%M:%SZ"),
484
- "end": end.strftime("%Y-%m-%dT%H:%M:%SZ"),
485
- "sport": sport.value,
486
- "metric": metric.value,
487
- }
402
+ url="/api/v1/activities/longitudinal-mean-max",
403
+ params=params,
488
404
  )
489
405
  response.raise_for_status()
490
406
 
491
- mean_max = pd.read_json(
492
- StringIO(response.json()),
493
- orient="split",
494
- date_unit="s",
495
- typ="series",
496
- )
497
- mean_max = pd.to_timedelta(mean_max, unit="seconds")
498
- mean_max.name = "duration"
499
- mean_max.index.name = metric.value
500
- return mean_max
407
+ return pd.read_parquet(BytesIO(response.content))
408
+
409
+ def _get_traces_generator(
410
+ self,
411
+ *,
412
+ start: date | None = None,
413
+ end: date | None = None,
414
+ sports: list[Sport | str] | None = None,
415
+ tags: list[str] | None = None,
416
+ limit: int = 100,
417
+ ) -> Generator[TraceDetails, None, None]:
418
+ num_returned = 0
419
+ default_limit = 100
420
+ params = {
421
+ "limit": default_limit,
422
+ "offset": 0,
423
+ }
424
+ if start is not None:
425
+ params["start"] = start.isoformat()
426
+ if end is not None:
427
+ params["end"] = end.isoformat()
428
+ if sports is not None:
429
+ params["sports"] = sports
430
+ if tags is not None:
431
+ params["tags"] = tags
432
+
433
+ with self._http_client() as client:
434
+ while True:
435
+ response = client.get(
436
+ url="/api/v1/traces/",
437
+ params=params,
438
+ )
439
+ response.raise_for_status()
440
+ traces = response.json()
441
+ for trace in traces:
442
+ yield TraceDetails.model_validate(trace)
443
+
444
+ num_returned += 1
445
+ if num_returned >= limit:
446
+ return
447
+ if len(traces) < default_limit:
448
+ return
449
+
450
+ params["limit"] = min(default_limit, limit - num_returned)
451
+ params["offset"] += default_limit
452
+
453
+ def _normalize_dataframe_column(self, df: pd.DataFrame, column: str) -> pd.DataFrame:
454
+ normalized = pd.json_normalize(
455
+ df[column],
456
+ )
457
+ normalized = normalized.add_prefix(f"{column}.")
458
+ if column == "activity":
459
+ normalized = normalized.drop(["activity.traces", "activity.laps"], axis=1, errors="ignore")
460
+ return pd.concat([df.drop(column, axis=1), normalized], axis=1)
461
+
462
+ def get_traces(
463
+ self,
464
+ *,
465
+ start: date | None = None,
466
+ end: date | None = None,
467
+ sports: list[Sport | str] | None = None,
468
+ tags: list[str] | None = None,
469
+ limit: int = 100,
470
+ as_dataframe: bool = False,
471
+ ) -> Generator[TraceDetails, None, None] | pd.DataFrame:
472
+ generator = self._get_traces_generator(
473
+ start=start,
474
+ end=end,
475
+ sports=sports,
476
+ tags=tags,
477
+ limit=limit,
478
+ )
479
+ if not as_dataframe:
480
+ return generator
481
+
482
+ data = pd.DataFrame([trace.model_dump() for trace in generator])
483
+
484
+ if "activity" in data.columns:
485
+ data = self._normalize_dataframe_column(data, "activity")
486
+
487
+ if "lap" in data.columns:
488
+ data = self._normalize_dataframe_column(data, "lap")
501
489
 
502
- def _upload_activity(self, files):
490
+ return data
491
+
492
+ def create_trace(
493
+ self,
494
+ *,
495
+ timestamp: datetime,
496
+ lactate: float | None = None,
497
+ rpe: int | None = None,
498
+ notes: str | None = None,
499
+ power: int | None = None,
500
+ speed: float | None = None,
501
+ heart_rate: int | None = None,
502
+ tags: list[str] | None = None,
503
+ ) -> TraceDetails:
503
504
  with self._http_client() as client:
504
505
  response = client.post(
505
- "/api/activities/upload",
506
- files=[("files", f) for f in files],
506
+ url="/api/v1/traces/",
507
+ json={
508
+ "timestamp": timestamp.isoformat(),
509
+ "lactate": lactate,
510
+ "rpe": rpe,
511
+ "notes": notes,
512
+ "power": power,
513
+ "speed": speed,
514
+ "heart_rate": heart_rate,
515
+ "tags": tags,
516
+ },
507
517
  )
508
518
  response.raise_for_status()
519
+ return TraceDetails.model_validate(response.json())
509
520
 
521
+ def get_sports(self, only_root: bool = False) -> list[Sport]:
522
+ with self._http_client() as client:
523
+ response = client.get(
524
+ url="/api/v1/profile/sports/",
525
+ params={"only_root": only_root},
526
+ )
527
+ response.raise_for_status()
528
+ return [Sport(sport) for sport in response.json()]
529
+
530
+ def get_tags(self) -> list[str]:
531
+ with self._http_client() as client:
532
+ response = client.get(
533
+ url="/api/v1/profile/tags/",
534
+ )
535
+ response.raise_for_status()
510
536
  return response.json()
511
537
 
512
- def _preprocess_file(self, file):
513
- if isinstance(file, (str, Path)):
514
- file = open(file, "rb")
515
- elif isinstance(file, IOBase):
516
- file = file
517
- else:
518
- raise ValueError("File must be a path (string or pathlib.Path) or a file-like object")
519
- return file
520
-
521
- def upload_activity(self, file):
522
- file = self._preprocess_file(file)
523
- self._upload_activity([file])
524
-
525
- def batch_upload_activities(self, *, files: List[Union[str, Path, IOBase]] = None, directory: Union[str, Path] = None):
526
- if files is not None and directory is not None:
527
- raise ValueError("Only one of files or directory can be provided")
528
- elif files is None and directory is None:
529
- raise ValueError("One of files or directory must be provided")
538
+
539
+ _default_client = Client()
540
+
541
+
542
+ def _generate_singleton_methods(method_names: List[str]) -> None:
543
+ """
544
+ Automatically generates singleton methods for the Client class.
545
+
546
+ Args:
547
+ method_names: List of method names to expose in the singleton interface
548
+ """
549
+
550
+ def create_singleton_method(method_name: str):
551
+ bound_method = getattr(_default_client, method_name)
552
+
553
+ @wraps(bound_method)
554
+ def singleton_method(*args: Any, **kwargs: Any) -> Any:
555
+ return bound_method(*args, **kwargs)
556
+
557
+ class_method = getattr(Client, method_name)
558
+ singleton_method.__annotations__ = get_type_hints(class_method)
559
+
560
+ return singleton_method
561
+
562
+ for method_name in method_names:
563
+ if not hasattr(Client, method_name):
564
+ raise ValueError(f"Method '{method_name}' not found in class {Client.__name__}")
565
+
566
+ class_method = getattr(Client, method_name)
530
567
 
531
- if directory is not None:
532
- path = Path(directory)
533
- if not path.exists() or not path.is_dir():
534
- raise ValueError("Directory does not exist")
535
- files = [self._preprocess_file(file) for file in path.glob("*.fit")]
536
- else:
537
- files = [self._preprocess_file(file) for file in files]
538
-
539
- with ProgressBar("Uploading activity files", len(files)) as progress_bar:
540
- progress_bar.show_progress(0)
541
- for i in range(0, len(files), 10):
542
- chunk = files[i:i+10]
543
- self._upload_activity(chunk)
544
- progress_bar.show_progress(i)
545
-
546
-
547
- _instance = SweatStack()
548
-
549
-
550
- login = _instance.login
551
-
552
- list_users = _instance.list_users
553
- list_accessible_users = _instance.list_accessible_users
554
- switch_user = _instance.switch_user
555
- switch_to_root_user = _instance.switch_to_root_user
556
- whoami = _instance.whoami
557
-
558
- list_activities = _instance.list_activities
559
- get_activity = _instance.get_activity
560
- get_latest_activity = _instance.get_latest_activity
561
- get_activity_data = _instance.get_activity_data
562
- get_latest_activity_data = _instance.get_latest_activity_data
563
-
564
- upload_activity = _instance.upload_activity
565
- batch_upload_activities = _instance.batch_upload_activities
566
-
567
- get_accumulated_work_duration = _instance.get_accumulated_work_duration
568
- get_mean_max = _instance.get_mean_max
569
- get_longitudinal_data = _instance.get_longitudinal_data
570
- try:
571
- plot_activity_data = _instance.plot_activity_data
572
- plot_latest_activity_data = _instance.plot_latest_activity_data
573
- plot_scatter = _instance.plot_scatter
574
- plot_mean_max = _instance.plot_mean_max
575
- except AttributeError:
576
- # This is the case when the user has not installed the plotting dependencies
577
- pass
568
+ if not callable(class_method):
569
+ continue
570
+
571
+ globals()[method_name] = create_singleton_method(method_name)
572
+
573
+
574
+ _generate_singleton_methods(
575
+ [
576
+ "login",
577
+
578
+ "get_activities",
579
+
580
+ "get_activity",
581
+ "get_activity_data",
582
+ "get_activity_mean_max",
583
+
584
+ "get_latest_activity",
585
+ "get_latest_activity_data",
586
+ "get_latest_activity_mean_max",
587
+
588
+ "get_longitudinal_data",
589
+ "get_longitudinal_mean_max",
590
+
591
+ "get_traces",
592
+ "create_trace",
593
+
594
+ "get_sports",
595
+ "get_tags",
596
+ ]
597
+ )