sweatstack 0.40.1__py3-none-any.whl → 0.41.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
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sweatstack
3
- Version: 0.40.1
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
  sweatstack/__init__.py,sha256=tiVfgKlswRPaDMEy0gA7u8rveqEYZTA_kyB9lJ3J6Sc,21
2
2
  sweatstack/cli.py,sha256=N1NWOgEZR2yaJvIXxo9qvp_jFlypZYb0nujpbVNYQ6A,720
3
- sweatstack/client.py,sha256=SPacpBpXtPKy6TatnTXe_9jCSo_JY340U_k9FwfnHV8,39048
3
+ sweatstack/client.py,sha256=yEDFLnzp5mK1cM9Q2Ejx7kxnYTVTm9FmYKQwO47GS3g,42785
4
4
  sweatstack/constants.py,sha256=fGO6ksOv5HeISv9lHRoYm4besW1GTveXS8YD3K0ljg0,41
5
5
  sweatstack/ipython_init.py,sha256=zBGUlMFkdpLvsNpOpwrNaKRUpUZhzaICvH8ODJgMPcI,229
6
6
  sweatstack/jupyterlab_oauth2_startup.py,sha256=eZ6xi0Sa4hO4vUanimq0SqjduHtiywCURSDNWk_I-7s,1200
@@ -11,7 +11,7 @@ sweatstack/streamlit.py,sha256=C5uAKFwLAUKIyQ7SRIyxMtQEdmRTY-jZsfGQzkYxg9c,13022
11
11
  sweatstack/sweatshell.py,sha256=MYLNcWbOdceqKJ3S0Pe8dwHXEeYsGJNjQoYUXpMTftA,333
12
12
  sweatstack/utils.py,sha256=AwHRdC1ziOZ5o9RBIB21Uxm-DoClVRAJSVvgsmSmvps,1801
13
13
  sweatstack/Sweat Stack examples/Getting started.ipynb,sha256=k2hiSffWecoQ0VxjdpDcgFzBXDQiYEebhnAYlu8cgX8,6335204
14
- sweatstack-0.40.1.dist-info/METADATA,sha256=Q2GhOToeHNP3ibiP5vxpKpDrB5o83nfulvzr5l9itV4,779
15
- sweatstack-0.40.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
16
- sweatstack-0.40.1.dist-info/entry_points.txt,sha256=kCzOUQI3dqbTpEYqtgYDeiKFaqaA7BMlV6D24BMzCFU,208
17
- sweatstack-0.40.1.dist-info/RECORD,,
14
+ sweatstack-0.41.0.dist-info/METADATA,sha256=_KW2JqiYqFTJiWC7sJ0LZEFX0vHVHGYlpT2TezX4Djg,779
15
+ sweatstack-0.41.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
16
+ sweatstack-0.41.0.dist-info/entry_points.txt,sha256=kCzOUQI3dqbTpEYqtgYDeiKFaqaA7BMlV6D24BMzCFU,208
17
+ sweatstack-0.41.0.dist-info/RECORD,,