sweatstack 0.11.1__py3-none-any.whl → 0.13.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/cli.py +2 -2
- sweatstack/client.py +460 -440
- sweatstack/constants.py +1 -0
- sweatstack/jupyterlab_oauth2_startup.py +5 -0
- sweatstack/openapi_schemas.py +368 -0
- sweatstack/py.typed +0 -0
- sweatstack/schemas.py +3 -229
- sweatstack/streamlit.py +116 -0
- sweatstack/utils.py +13 -0
- sweatstack-0.13.0.dist-info/METADATA +117 -0
- sweatstack-0.13.0.dist-info/RECORD +16 -0
- {sweatstack-0.11.1.dist-info → sweatstack-0.13.0.dist-info}/WHEEL +1 -1
- sweatstack/Sweat Stack examples/Getting started.ipynb +0 -28784
- sweatstack/plotting.py +0 -251
- sweatstack-0.11.1.dist-info/METADATA +0 -359
- sweatstack-0.11.1.dist-info/RECORD +0 -13
- {sweatstack-0.11.1.dist-info → sweatstack-0.13.0.dist-info}/entry_points.txt +0 -0
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
|
|
9
|
-
from
|
|
10
|
-
from
|
|
11
|
-
from
|
|
12
|
-
from
|
|
13
|
-
from
|
|
14
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
|
|
107
23
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
self.jwt = jwt
|
|
111
|
-
self.root_jwt = self.jwt
|
|
24
|
+
AUTH_SUCCESSFUL_RESPONSE = "<!DOCTYPE html><html><body><h1>Authentication successful. You can now close this window.</h1></body></html>"
|
|
25
|
+
OAUTH2_CLIENT_ID = "5382f68b0d254378"
|
|
112
26
|
|
|
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
27
|
|
|
120
|
-
|
|
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
|
-
#
|
|
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":
|
|
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.
|
|
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("
|
|
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":
|
|
83
|
+
"client_id": OAUTH2_CLIENT_ID,
|
|
184
84
|
"code": server.code,
|
|
185
85
|
"code_verifier": code_verifier
|
|
186
86
|
}
|
|
187
|
-
response =
|
|
188
|
-
f"{self.
|
|
87
|
+
response = httpx.post(
|
|
88
|
+
f"{self.url}/oauth/token",
|
|
189
89
|
data=token_data,
|
|
190
90
|
)
|
|
191
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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("
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
168
|
+
return os.getenv("SWEATSTACK_REFRESH_TOKEN")
|
|
237
169
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
self.
|
|
189
|
+
@url.setter
|
|
190
|
+
def url(self, value: str):
|
|
191
|
+
self._url = value
|
|
248
192
|
|
|
249
|
-
|
|
193
|
+
@contextlib.contextmanager
|
|
194
|
+
def _http_client(self):
|
|
250
195
|
"""
|
|
251
|
-
|
|
196
|
+
Creates an httpx client with the base URL and authentication headers pre-configured.
|
|
252
197
|
"""
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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"] =
|
|
281
|
-
|
|
221
|
+
params["start"] = start.isoformat()
|
|
282
222
|
if end is not None:
|
|
283
|
-
params["end"] =
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
298
|
-
if limit is not None and activities_count > limit:
|
|
299
|
-
break
|
|
300
|
-
yield ActivitySummary.model_validate(activity) if as_pydantic else activity
|
|
301
|
-
|
|
302
|
-
if limit is not None and activities_count > limit or len(activities) < step_size:
|
|
303
|
-
break
|
|
238
|
+
yield ActivitySummary.model_validate(activity)
|
|
304
239
|
|
|
305
|
-
|
|
240
|
+
num_returned += 1
|
|
241
|
+
if num_returned >= limit:
|
|
242
|
+
return
|
|
243
|
+
if len(activities) < default_limit:
|
|
244
|
+
return
|
|
306
245
|
|
|
246
|
+
params["limit"] = min(default_limit, limit - num_returned)
|
|
247
|
+
params["offset"] += default_limit
|
|
307
248
|
|
|
308
|
-
def
|
|
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
|
-
)
|
|
267
|
+
return pd.DataFrame([activity.model_dump() for activity in generator])
|
|
319
268
|
else:
|
|
320
|
-
return
|
|
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
|
|
269
|
+
return generator
|
|
341
270
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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")
|
|
373
|
-
else:
|
|
374
|
-
data = pd.read_csv(buffer)
|
|
375
|
-
data.index = pd.to_datetime(data.index)
|
|
376
|
-
data["duration"] = pd.to_timedelta(data["duration"])
|
|
377
|
-
|
|
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
|
|
291
|
+
return ActivityDetails.model_validate(response.json())
|
|
385
292
|
|
|
386
|
-
def
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
|
393
|
-
params["
|
|
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
|
-
|
|
308
|
+
response.raise_for_status()
|
|
402
309
|
|
|
403
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
|
383
|
+
def get_longitudinal_mean_max(
|
|
457
384
|
self,
|
|
458
385
|
*,
|
|
459
|
-
sport:
|
|
460
|
-
metric:
|
|
461
|
-
|
|
462
|
-
|
|
386
|
+
sport: Sport | str,
|
|
387
|
+
metric: str,
|
|
388
|
+
date: date | str | None = None,
|
|
389
|
+
window_days: int | None = None,
|
|
463
390
|
) -> pd.DataFrame:
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
|
501
432
|
|
|
502
|
-
|
|
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")
|
|
489
|
+
|
|
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/
|
|
506
|
-
|
|
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
|
+
},
|
|
517
|
+
)
|
|
518
|
+
response.raise_for_status()
|
|
519
|
+
return TraceDetails.model_validate(response.json())
|
|
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},
|
|
507
526
|
)
|
|
508
527
|
response.raise_for_status()
|
|
528
|
+
return [Sport(sport) for sport in response.json()]
|
|
509
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
+
)
|