sweatstack 0.40.0__tar.gz → 0.41.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 (27) hide show
  1. {sweatstack-0.40.0 → sweatstack-0.41.0}/PKG-INFO +1 -1
  2. {sweatstack-0.40.0 → sweatstack-0.41.0}/pyproject.toml +1 -1
  3. {sweatstack-0.40.0 → sweatstack-0.41.0}/src/sweatstack/client.py +116 -8
  4. {sweatstack-0.40.0 → sweatstack-0.41.0}/src/sweatstack/streamlit.py +5 -5
  5. {sweatstack-0.40.0 → sweatstack-0.41.0}/.gitignore +0 -0
  6. {sweatstack-0.40.0 → sweatstack-0.41.0}/.python-version +0 -0
  7. {sweatstack-0.40.0 → sweatstack-0.41.0}/DEVELOPMENT.md +0 -0
  8. {sweatstack-0.40.0 → sweatstack-0.41.0}/Makefile +0 -0
  9. {sweatstack-0.40.0 → sweatstack-0.41.0}/README.md +0 -0
  10. {sweatstack-0.40.0 → sweatstack-0.41.0}/playground/.ipynb_checkpoints/Untitled-checkpoint.ipynb +0 -0
  11. {sweatstack-0.40.0 → sweatstack-0.41.0}/playground/README.md +0 -0
  12. {sweatstack-0.40.0 → sweatstack-0.41.0}/playground/Sweat Stack examples/Getting started.ipynb +0 -0
  13. {sweatstack-0.40.0 → sweatstack-0.41.0}/playground/Untitled.ipynb +0 -0
  14. {sweatstack-0.40.0 → sweatstack-0.41.0}/playground/hello.py +0 -0
  15. {sweatstack-0.40.0 → sweatstack-0.41.0}/playground/pyproject.toml +0 -0
  16. {sweatstack-0.40.0 → sweatstack-0.41.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
  17. {sweatstack-0.40.0 → sweatstack-0.41.0}/src/sweatstack/__init__.py +0 -0
  18. {sweatstack-0.40.0 → sweatstack-0.41.0}/src/sweatstack/cli.py +0 -0
  19. {sweatstack-0.40.0 → sweatstack-0.41.0}/src/sweatstack/constants.py +0 -0
  20. {sweatstack-0.40.0 → sweatstack-0.41.0}/src/sweatstack/ipython_init.py +0 -0
  21. {sweatstack-0.40.0 → sweatstack-0.41.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
  22. {sweatstack-0.40.0 → sweatstack-0.41.0}/src/sweatstack/openapi_schemas.py +0 -0
  23. {sweatstack-0.40.0 → sweatstack-0.41.0}/src/sweatstack/py.typed +0 -0
  24. {sweatstack-0.40.0 → sweatstack-0.41.0}/src/sweatstack/schemas.py +0 -0
  25. {sweatstack-0.40.0 → sweatstack-0.41.0}/src/sweatstack/sweatshell.py +0 -0
  26. {sweatstack-0.40.0 → sweatstack-0.41.0}/src/sweatstack/utils.py +0 -0
  27. {sweatstack-0.40.0 → sweatstack-0.41.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sweatstack
3
- Version: 0.40.0
3
+ Version: 0.41.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.40.0"
3
+ version = "0.41.0"
4
4
  description = "The official Python client for SweatStack"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -167,14 +167,106 @@ class DelegationMixin:
167
167
 
168
168
  return response.json()
169
169
 
170
- def switch_user(self, user: str | UserSummary):
170
+ def _is_user_id(self, user: str) -> bool:
171
+ """Check if a string is a valid user ID.
172
+
173
+ Args:
174
+ user: The string to check.
175
+
176
+ Returns:
177
+ bool: True if the string is a valid user ID format, False otherwise.
178
+ """
179
+ if not isinstance(user, str):
180
+ return False
181
+
182
+ return len(user) == 16 and user.isalnum()
183
+
184
+ def _get_user_by_name(self, name: str) -> UserSummary:
185
+ """Get a user by name.
186
+
187
+ Args:
188
+ name: The name of the user to get.
189
+
190
+ Returns:
191
+ UserSummary: The user object.
192
+
193
+ Raises:
194
+ ValueError: If the user is not found.
195
+ ValueError: If multiple users are found with the same name.
196
+ """
197
+ matches = []
198
+ for user in self.get_users():
199
+ if name in user.display_name.lower():
200
+ matches.append(user)
201
+
202
+ if len(matches) == 0:
203
+ raise ValueError(f"User with name {name} not found")
204
+ elif len(matches) > 1:
205
+ raise ValueError(f"Multiple users found with name {name}: {', '.join([user.display_name for user in matches])}")
206
+ return matches[0]
207
+
208
+ def _get_user_by_id(self, id: str) -> UserSummary:
209
+ """Get a user by ID.
210
+
211
+ Args:
212
+ id: The ID of the user to get.
213
+
214
+ Returns:
215
+ UserSummary: The user object.
216
+
217
+ Raises:
218
+ HTTPStatusError: If the user is not found.
219
+ """
220
+ # TODO: Implement this using a user detail endpoint
221
+ return next((user for user in self.get_users() if user.id == id), None)
222
+
223
+ def get_user(self, user: str, *, search_mode: Literal["auto", "id", "name"] = "auto") -> UserSummary:
224
+ """Get a user by ID or name.
225
+ This method will always authenticate as the principal user.
226
+
227
+ Args:
228
+ user: Either a UserSummary object or a string representing the user id or (part of) the user name to get.
229
+ search_mode: The mode to use when searching for the user.
230
+ - "auto": Automatically determine the search mode based on the type of user argument.
231
+ - "id": Search for the user by ID.
232
+ - "name": Search for the user by name.
233
+
234
+ Returns:
235
+ UserSummary: The user object.
236
+
237
+ Raises:
238
+ HTTPStatusError: If the user is not found.
239
+ """
240
+ client = self.principal_client()
241
+ if search_mode == "auto":
242
+ if client._is_user_id(user):
243
+ return client._get_user_by_id(user)
244
+ else:
245
+ return client._get_user_by_name(user)
246
+ elif search_mode == "id":
247
+ return client._get_user_by_id(user)
248
+ elif search_mode == "name":
249
+ return client._get_user_by_name(user)
250
+
251
+ def switch_user(
252
+ self,
253
+ user: str | UserSummary,
254
+ *,
255
+ search_mode: Literal["auto", "id", "name"] = "auto",
256
+ ):
171
257
  """Switches the client to operate on behalf of another user.
172
258
 
173
259
  This method changes the current client's authentication to act on behalf of the specified user.
174
260
  The client will use a delegated token for all subsequent API calls.
175
261
 
176
262
  Args:
177
- user: Either a UserSummary object or a string user ID representing the user to switch to.
263
+ user: Either a UserSummary object or a string representing the user id or (part of) the user name to switch to.
264
+
265
+ search_mode:
266
+ The mode to use when searching for the user.
267
+ - "auto": Automatically determine the search mode based on the type of user argument.
268
+ - "id": Search for the user by ID.
269
+ - "name": Search for the user by name.
178
270
 
179
271
  Returns:
180
272
  None
@@ -182,6 +274,11 @@ class DelegationMixin:
182
274
  Raises:
183
275
  HTTPStatusError: If the delegation request fails.
184
276
  """
277
+ self.switch_back()
278
+
279
+ if not isinstance(user, UserSummary):
280
+ user = self.get_user(user, search_mode=search_mode)
281
+
185
282
  token_response = self._get_delegated_token(user)
186
283
  self.api_key = token_response["access_token"]
187
284
  self.refresh_token = token_response["refresh_token"]
@@ -268,14 +365,14 @@ class Client(OAuth2Mixin, DelegationMixin):
268
365
  self.url = url
269
366
  self.streamlit_compatible = streamlit_compatible
270
367
 
271
- def _do_token_refresh(self, tz_offset: int) -> str:
368
+ def _do_token_refresh(self, tz: str) -> str:
272
369
  with self._http_client() as client:
273
370
  response = client.post(
274
371
  "/api/v1/oauth/token",
275
372
  json={
276
373
  "grant_type": "refresh_token",
277
374
  "refresh_token": self.refresh_token,
278
- "tz_offset": tz_offset,
375
+ "tz": tz,
279
376
  },
280
377
  )
281
378
 
@@ -289,7 +386,7 @@ class Client(OAuth2Mixin, DelegationMixin):
289
386
  TOKEN_EXPIRY_MARGIN = 5
290
387
  if body["exp"] - TOKEN_EXPIRY_MARGIN < time.time():
291
388
  # Token is (almost) expired, refresh it
292
- token = self._do_token_refresh(body["tz_offset"])
389
+ token = self._do_token_refresh(body["tz"])
293
390
  self._api_key = token
294
391
  except Exception:
295
392
  # If token can't be decoded, just return as-is
@@ -359,6 +456,14 @@ class Client(OAuth2Mixin, DelegationMixin):
359
456
  with httpx.Client(base_url=self.url, headers=headers) as client:
360
457
  yield client
361
458
 
459
+ def _print_response_and_raise(self, response: httpx.Response):
460
+ try:
461
+ response.raise_for_status()
462
+ except httpx.HTTPStatusError as exception:
463
+ additional_info = response.text
464
+ exception.add_note(additional_info)
465
+ raise exception
466
+
362
467
  def _raise_for_status(self, response: httpx.Response):
363
468
  if response.status_code == 422:
364
469
  raise ValueError(response.json())
@@ -366,7 +471,7 @@ class Client(OAuth2Mixin, DelegationMixin):
366
471
  try:
367
472
  import streamlit
368
473
  except ImportError:
369
- response.raise_for_status()
474
+ self._print_response_and_raise(response)
370
475
  else:
371
476
  try:
372
477
  response.raise_for_status()
@@ -380,7 +485,7 @@ class Client(OAuth2Mixin, DelegationMixin):
380
485
  raise
381
486
 
382
487
  else:
383
- response.raise_for_status()
488
+ self._print_response_and_raise(response)
384
489
 
385
490
  def _enums_to_strings(self, values: list[Enum | str]) -> list[str]:
386
491
  return [value.value if isinstance(value, Enum) else value for value in values]
@@ -976,6 +1081,7 @@ class Client(OAuth2Mixin, DelegationMixin):
976
1081
  This method retrieves all users that the current user has access to view.
977
1082
  For regular users, this typically returns only their own user information.
978
1083
  For admin users, this may return information about multiple users.
1084
+ This method will always authenticate as the principal user.
979
1085
 
980
1086
  Returns:
981
1087
  list[UserSummary]: A list of UserSummary objects containing basic user information.
@@ -983,7 +1089,8 @@ class Client(OAuth2Mixin, DelegationMixin):
983
1089
  Raises:
984
1090
  HTTPStatusError: If the API request fails.
985
1091
  """
986
- with self._http_client() as client:
1092
+ client = self.principal_client()
1093
+ with client._http_client() as client:
987
1094
  response = client.get(
988
1095
  url="/api/v1/users/",
989
1096
  )
@@ -1050,6 +1157,7 @@ _generate_singleton_methods(
1050
1157
  [
1051
1158
  "login",
1052
1159
 
1160
+ "get_user",
1053
1161
  "get_users",
1054
1162
  "get_userinfo",
1055
1163
 
@@ -15,7 +15,7 @@ import httpx
15
15
  from .client import Client
16
16
  from .constants import DEFAULT_URL
17
17
  from .schemas import Metric, Scope, Sport
18
- from .utils import format_sport
18
+
19
19
 
20
20
  class StreamlitAuth:
21
21
  def __init__(
@@ -253,7 +253,7 @@ class StreamlitAuth:
253
253
  selected_activity = st.selectbox(
254
254
  "Select an activity",
255
255
  activities,
256
- format_func=lambda activity: f"{activity.start.date().isoformat()} {format_sport(activity.sport)}",
256
+ format_func=lambda activity: f"{activity.start.date().isoformat()} {activity.sport.display_name()}",
257
257
  )
258
258
  return selected_activity
259
259
 
@@ -272,7 +272,7 @@ class StreamlitAuth:
272
272
  Sport or list[Sport]: The selected sport or list of sports, depending on allow_multiple.
273
273
 
274
274
  Note:
275
- Sports are displayed in a human-readable format using the format_sport function.
275
+ Sports are displayed in a human-readable format using the display_name function.
276
276
  """
277
277
  if only_available:
278
278
  sports = self.client.get_sports(only_root)
@@ -286,13 +286,13 @@ class StreamlitAuth:
286
286
  selected_sport = st.multiselect(
287
287
  "Select sports",
288
288
  sports,
289
- format_func=format_sport,
289
+ format_func=lambda sport: sport.display_name(),
290
290
  )
291
291
  else:
292
292
  selected_sport = st.selectbox(
293
293
  "Select a sport",
294
294
  sports,
295
- format_func=format_sport,
295
+ format_func=lambda sport: sport.display_name(),
296
296
  )
297
297
  return selected_sport
298
298
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes