otf-api 0.12.0__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.
Files changed (74) hide show
  1. otf_api/__init__.py +35 -3
  2. otf_api/api/__init__.py +3 -0
  3. otf_api/api/_compat.py +77 -0
  4. otf_api/api/api.py +80 -0
  5. otf_api/api/bookings/__init__.py +3 -0
  6. otf_api/api/bookings/booking_api.py +541 -0
  7. otf_api/api/bookings/booking_client.py +112 -0
  8. otf_api/api/client.py +203 -0
  9. otf_api/api/members/__init__.py +3 -0
  10. otf_api/api/members/member_api.py +187 -0
  11. otf_api/api/members/member_client.py +112 -0
  12. otf_api/api/studios/__init__.py +3 -0
  13. otf_api/api/studios/studio_api.py +173 -0
  14. otf_api/api/studios/studio_client.py +120 -0
  15. otf_api/api/utils.py +307 -0
  16. otf_api/api/workouts/__init__.py +3 -0
  17. otf_api/api/workouts/workout_api.py +333 -0
  18. otf_api/api/workouts/workout_client.py +140 -0
  19. otf_api/auth/__init__.py +1 -1
  20. otf_api/auth/auth.py +155 -89
  21. otf_api/auth/user.py +5 -17
  22. otf_api/auth/utils.py +27 -2
  23. otf_api/cache.py +132 -0
  24. otf_api/exceptions.py +18 -6
  25. otf_api/models/__init__.py +25 -21
  26. otf_api/models/bookings/__init__.py +23 -0
  27. otf_api/models/bookings/bookings.py +134 -0
  28. otf_api/models/{bookings_v2.py → bookings/bookings_v2.py} +72 -31
  29. otf_api/models/bookings/classes.py +124 -0
  30. otf_api/models/{enums.py → bookings/enums.py} +7 -81
  31. otf_api/{filters.py → models/bookings/filters.py} +39 -11
  32. otf_api/models/{ratings.py → bookings/ratings.py} +2 -6
  33. otf_api/models/members/__init__.py +5 -0
  34. otf_api/models/members/member_detail.py +149 -0
  35. otf_api/models/members/member_membership.py +26 -0
  36. otf_api/models/members/member_purchases.py +29 -0
  37. otf_api/models/members/notifications.py +17 -0
  38. otf_api/models/mixins.py +48 -1
  39. otf_api/models/studios/__init__.py +5 -0
  40. otf_api/models/studios/enums.py +11 -0
  41. otf_api/models/studios/studio_detail.py +93 -0
  42. otf_api/models/studios/studio_services.py +36 -0
  43. otf_api/models/workouts/__init__.py +31 -0
  44. otf_api/models/{body_composition_list.py → workouts/body_composition_list.py} +140 -71
  45. otf_api/models/workouts/challenge_tracker_content.py +50 -0
  46. otf_api/models/workouts/challenge_tracker_detail.py +99 -0
  47. otf_api/models/workouts/enums.py +70 -0
  48. otf_api/models/workouts/lifetime_stats.py +96 -0
  49. otf_api/models/workouts/out_of_studio_workout_history.py +32 -0
  50. otf_api/models/{performance_summary.py → workouts/performance_summary.py} +19 -5
  51. otf_api/models/workouts/telemetry.py +88 -0
  52. otf_api/models/{workout.py → workouts/workout.py} +34 -20
  53. {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/METADATA +4 -2
  54. otf_api-0.13.0.dist-info/RECORD +59 -0
  55. {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/WHEEL +1 -1
  56. otf_api/api.py +0 -1682
  57. otf_api/logging.py +0 -19
  58. otf_api/models/bookings.py +0 -109
  59. otf_api/models/challenge_tracker_content.py +0 -59
  60. otf_api/models/challenge_tracker_detail.py +0 -88
  61. otf_api/models/classes.py +0 -70
  62. otf_api/models/lifetime_stats.py +0 -78
  63. otf_api/models/member_detail.py +0 -121
  64. otf_api/models/member_membership.py +0 -26
  65. otf_api/models/member_purchases.py +0 -29
  66. otf_api/models/notifications.py +0 -17
  67. otf_api/models/out_of_studio_workout_history.py +0 -32
  68. otf_api/models/studio_detail.py +0 -71
  69. otf_api/models/studio_services.py +0 -36
  70. otf_api/models/telemetry.py +0 -84
  71. otf_api/utils.py +0 -164
  72. otf_api-0.12.0.dist-info/RECORD +0 -38
  73. {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/licenses/LICENSE +0 -0
  74. {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/top_level.txt +0 -0
otf_api/auth/auth.py CHANGED
@@ -2,12 +2,13 @@
2
2
  import platform
3
3
  import typing
4
4
  from collections.abc import Generator
5
+ from datetime import datetime
5
6
  from logging import getLogger
6
- from pathlib import Path
7
7
  from time import sleep
8
8
  from typing import Any, ClassVar
9
9
 
10
10
  import httpx
11
+ import jwt
11
12
  from boto3 import Session
12
13
  from botocore import UNSIGNED
13
14
  from botocore.auth import SigV4Auth
@@ -18,26 +19,23 @@ from botocore.exceptions import ClientError
18
19
  from pycognito import AWSSRP, Cognito
19
20
  from pycognito.aws_srp import generate_hash_device
20
21
 
21
- from otf_api.utils import CacheableData
22
+ from otf_api.cache import get_cache
22
23
 
23
24
  if typing.TYPE_CHECKING:
24
25
  from mypy_boto3_cognito_identity import CognitoIdentityClient
26
+ from mypy_boto3_cognito_identity.type_defs import CredentialsTypeDef
25
27
  from mypy_boto3_cognito_idp import CognitoIdentityProviderClient
26
28
  from mypy_boto3_cognito_idp.type_defs import InitiateAuthResponseTypeDef
27
29
 
28
30
  LOGGER = getLogger(__name__)
31
+
29
32
  CLIENT_ID = "1457d19r0pcjgmp5agooi0rb1b" # from android app
30
- USER_POOL_ID = "us-east-1_dYDxUeyL1"
31
33
  REGION = "us-east-1"
32
-
33
- ID_POOL_ID = "us-east-1:4943c880-fb02-4fd7-bc37-2f4c32ecb2a3"
34
+ USER_POOL_ID = "us-east-1_dYDxUeyL1"
35
+ ID_POOL_ID = f"{REGION}:4943c880-fb02-4fd7-bc37-2f4c32ecb2a3"
34
36
  PROVIDER_KEY = f"cognito-idp.{REGION}.amazonaws.com/{USER_POOL_ID}"
35
-
36
37
  BOTO_CONFIG = Config(region_name=REGION, signature_version=UNSIGNED)
37
- CRED_CACHE = CacheableData("creds", Path("~/.otf-api"))
38
-
39
- DEVICE_KEYS = ["device_key", "device_group_key", "device_password"]
40
- TOKEN_KEYS = ["access_token", "id_token", "refresh_token"]
38
+ CACHE = get_cache()
41
39
 
42
40
 
43
41
  class NoCredentialsError(Exception):
@@ -45,8 +43,10 @@ class NoCredentialsError(Exception):
45
43
 
46
44
 
47
45
  class OtfCognito(Cognito):
48
- """A subclass of the pycognito Cognito class that adds the device_key to the auth_params. Without this
49
- being set the renew_access_token call will always fail with NOT_AUTHORIZED."""
46
+ """A subclass of the pycognito Cognito class that adds the device_key to the auth_params.
47
+
48
+ Without this being set the renew_access_token call will always fail with NOT_AUTHORIZED.
49
+ """
50
50
 
51
51
  user_pool_id: ClassVar[str] = USER_POOL_ID
52
52
  client_id: ClassVar[str] = CLIENT_ID
@@ -60,6 +60,44 @@ class OtfCognito(Cognito):
60
60
  device_password: str
61
61
  device_name: str
62
62
 
63
+ @property
64
+ def expiration_seconds(self) -> int:
65
+ """Returns the expiration time of the access token in seconds.
66
+
67
+ This is useful for checking if the access token is still valid.
68
+
69
+ Returns:
70
+ int: The expiration time of the access token in seconds.
71
+ """
72
+ if not self.access_token:
73
+ raise AttributeError("Access Token Required to Check Token")
74
+ return self.get_decoded_access_token()["exp"] - int(datetime.now().timestamp()) # noqa: DTZ005
75
+
76
+ @property
77
+ def acces_token_expiration(self) -> int:
78
+ """Returns the expiration time of the access token in seconds."""
79
+ return datetime.fromtimestamp(self.get_decoded_access_token()["exp"]) # type: ignore # noqa: DTZ006
80
+
81
+ @property
82
+ def tokens(self) -> dict[str, str]:
83
+ """Returns the tokens as a dictionary."""
84
+ tokens = {
85
+ "access_token": self.access_token,
86
+ "id_token": self.id_token,
87
+ "refresh_token": self.refresh_token,
88
+ }
89
+ return {k: v for k, v in tokens.items() if v}
90
+
91
+ @property
92
+ def device_metadata(self) -> dict[str, str]:
93
+ """Returns the device metadata as a dictionary."""
94
+ dm = {
95
+ "device_key": self.device_key,
96
+ "device_group_key": self.device_group_key,
97
+ "device_password": self.device_password,
98
+ }
99
+ return {k: v for k, v in dm.items() if v}
100
+
63
101
  def __init__(
64
102
  self,
65
103
  username: str | None = None,
@@ -68,16 +106,6 @@ class OtfCognito(Cognito):
68
106
  access_token: str | None = None,
69
107
  refresh_token: str | None = None,
70
108
  ):
71
- """
72
-
73
- Args:
74
- username (str, optional): User Pool username
75
- password (str, optional): User Pool password
76
- id_token (str, optional): ID Token returned by authentication
77
- access_token (str, optional): Access Token returned by authentication
78
- refresh_token (str, optional): Refresh Token returned by authentication
79
- """
80
-
81
109
  self.username = username
82
110
  self.id_token = id_token # type: ignore
83
111
  self.access_token = access_token # type: ignore
@@ -91,22 +119,6 @@ class OtfCognito(Cognito):
91
119
  self.mfa_tokens: dict[str, Any] = {}
92
120
  self.pool_domain_url: str | None = None
93
121
 
94
- self.handle_login(username, password, id_token, access_token)
95
-
96
- def handle_login(
97
- self, username: str | None, password: str | None, id_token: str | None = None, access_token: str | None = None
98
- ) -> None:
99
- try:
100
- dd = CRED_CACHE.get_cached_data(DEVICE_KEYS)
101
- except Exception:
102
- LOGGER.exception("Failed to read device key cache")
103
- dd = {}
104
-
105
- self.device_name = platform.node()
106
- self.device_key = dd.get("device_key") # type: ignore
107
- self.device_group_key = dd.get("device_group_key") # type: ignore
108
- self.device_password = dd.get("device_password") # type: ignore
109
-
110
122
  self.idp_client: CognitoIdentityProviderClient = Session().client(
111
123
  "cognito-idp", config=BOTO_CONFIG, region_name=REGION
112
124
  ) # type: ignore
@@ -115,66 +127,84 @@ class OtfCognito(Cognito):
115
127
  "cognito-identity", config=BOTO_CONFIG, region_name=REGION
116
128
  ) # type: ignore
117
129
 
118
- try:
119
- token_cache = CRED_CACHE.get_cached_data(TOKEN_KEYS)
120
- except Exception:
121
- LOGGER.exception("Failed to read token cache")
122
- token_cache = {}
130
+ self.handle_login(password)
123
131
 
124
- if not (username and password) and not (id_token and access_token) and not token_cache:
132
+ def handle_login(self, password: str | None = None) -> None:
133
+ """Handles the login process for the user.
134
+
135
+ This will set the tokens and device metadata.
136
+ If the user is not logged in, it will attempt to login with the provided username and password.
137
+ If the user is already logged in, it will check the tokens and refresh them if necessary.
138
+
139
+ Args:
140
+ password (str, optional): The password to use for login. If not provided, the user will be prompted for it.
141
+ """
142
+ self.set_attributes_from_cache()
143
+
144
+ if not (self.username and password) and not (self.id_token and self.access_token):
125
145
  raise NoCredentialsError("No credentials provided and no tokens cached, cannot authenticate")
126
146
 
127
- if username and password:
128
- self.login(password)
129
- elif token_cache and not (id_token and access_token):
130
- LOGGER.debug("Using cached tokens")
131
- self.id_token = token_cache["id_token"]
132
- self.access_token = token_cache["access_token"]
133
- self.refresh_token = token_cache["refresh_token"]
147
+ if self.username and password:
148
+ self.login_with_password(password)
149
+ return
150
+
151
+ # at this point we have tokens, so let's hand it off to the proper method
152
+ self.authenticate_with_saved_tokens()
134
153
 
135
- try:
136
- self.check_token()
137
- except ClientError as e:
138
- if e.response["Error"]["Code"] == "NotAuthorizedException":
139
- LOGGER.warning("Tokens expired, attempting to login with username and password")
140
- CRED_CACHE.clear_cache()
141
- if not username or not password:
142
- raise NoCredentialsError("No credentials provided and no tokens cached, cannot authenticate")
143
- self.handle_login(username, password)
154
+ def set_attributes_from_cache(self) -> None:
155
+ """Sets the attributes from the cache.
144
156
 
145
- self.verify_tokens()
146
- CRED_CACHE.write_to_cache(self.tokens)
157
+ This is useful for initializing the Cognito instance with tokens that have been previously cached.
158
+ It will read the tokens and device metadata from the cache and set the instance attributes accordingly.
159
+ """
160
+ token_cache = CACHE.read_token_data_from_cache()
161
+ self.id_token = self.id_token or token_cache.get("id_token") # type: ignore
162
+ self.access_token = self.access_token or token_cache.get("access_token") # type: ignore
163
+ self.refresh_token = self.refresh_token or token_cache.get("refresh_token") # type: ignore
147
164
 
148
- def get_identity_credentials(self):
165
+ dd_cache = CACHE.read_device_data_from_cache()
166
+ self.device_key = dd_cache.get("device_key") or ""
167
+ self.device_group_key = dd_cache.get("device_group_key") or ""
168
+ self.device_password = dd_cache.get("device_password") or ""
169
+
170
+ def get_identity_credentials(self) -> "CredentialsTypeDef":
149
171
  """Get the AWS credentials for the user using the Cognito Identity Pool.
150
- This is used to access AWS resources using the Cognito Identity Pool."""
172
+
173
+ This is used to access AWS resources using the Cognito Identity Pool.
174
+
175
+ Returns:
176
+ CredentialsTypeDef: The AWS credentials for the user.
177
+ """
151
178
  cognito_id = self.id_client.get_id(IdentityPoolId=ID_POOL_ID, Logins={PROVIDER_KEY: self.id_token})
152
179
  creds = self.id_client.get_credentials_for_identity(
153
180
  IdentityId=cognito_id["IdentityId"], Logins={PROVIDER_KEY: self.id_token}
154
181
  )
155
182
  return creds["Credentials"]
156
183
 
157
- @property
158
- def tokens(self) -> dict[str, str]:
159
- tokens = {
160
- "access_token": self.access_token,
161
- "id_token": self.id_token,
162
- "refresh_token": self.refresh_token,
163
- }
164
- return {k: v for k, v in tokens.items() if v}
184
+ def get_decoded_access_token(self) -> dict[str, Any]:
185
+ """Decodes the access token without verifying the signature.
165
186
 
166
- @property
167
- def device_metadata(self) -> dict[str, str]:
168
- dm = {
169
- "device_key": self.device_key,
170
- "device_group_key": self.device_group_key,
171
- "device_password": self.device_password,
172
- }
173
- return {k: v for k, v in dm.items() if v}
187
+ This is useful for checking the expiration time of the access token.
174
188
 
175
- def login(self, password: str) -> None:
176
- """Called when logging in with a username and password. Will set the tokens and device metadata."""
189
+ Returns:
190
+ dict[str, Any]: The decoded access token.
191
+ """
192
+ if not self.access_token:
193
+ raise AttributeError("Access Token Required to Check Token")
194
+ dec_access_token = jwt.decode(self.access_token, options={"verify_signature": False})
195
+ return dec_access_token
196
+
197
+ def authenticate_with_saved_tokens(self) -> None:
198
+ """Authenticate using saved tokens from the cache.
199
+
200
+ This method is useful for initializing the Cognito instance with tokens that have been previously cached.
201
+ It will verify the tokens and set the device metadata if available.
202
+ """
203
+ self.check_token()
204
+ self.verify_tokens()
177
205
 
206
+ def login_with_password(self, password: str) -> None:
207
+ """Called when logging in with a username and password. Will set the tokens and device metadata."""
178
208
  LOGGER.debug("Logging in with username and password...")
179
209
 
180
210
  aws = AWSSRP(
@@ -205,6 +235,11 @@ class OtfCognito(Cognito):
205
235
  is no benefit to remembering the device. Additionally, it does not appear that the OTF app remembers devices,
206
236
  so this matches the behavior of the app.
207
237
  """
238
+ dd = CACHE.read_device_data_from_cache()
239
+ self.device_key = dd.get("device_key") or ""
240
+ self.device_group_key = dd.get("device_group_key") or ""
241
+ self.device_password = dd.get("device_password") or ""
242
+ self.device_name = platform.node()
208
243
 
209
244
  if not self.device_key:
210
245
  raise ValueError("Device key not set - device key is required by this Cognito pool")
@@ -221,12 +256,45 @@ class OtfCognito(Cognito):
221
256
  )
222
257
 
223
258
  try:
224
- CRED_CACHE.write_to_cache(self.device_metadata)
259
+ CACHE.write_device_data_to_cache(self.device_metadata)
225
260
  except Exception:
226
261
  LOGGER.exception("Failed to write device key cache")
227
262
 
228
263
  ##### OVERRIDDEN METHODS #####
229
264
 
265
+ def verify_tokens(self) -> None:
266
+ """Verifies the current id_token and access_token.
267
+
268
+ This method will also write the tokens to the cache if they are valid.
269
+ It is useful to call this method after creating a Cognito instance where you've provided
270
+ externally-remembered token values.
271
+ """
272
+ self.verify_token(self.id_token, "id_token", "id")
273
+ self.verify_token(self.access_token, "access_token", "access")
274
+ CACHE.write_token_data_to_cache(self.tokens, self.expiration_seconds)
275
+
276
+ def check_token(self, renew: bool = True) -> bool:
277
+ """Checks the exp attribute of the access_token and refreshes it if it has expired and renew is True.
278
+
279
+ Args:
280
+ renew (bool): whether to refresh on expiration
281
+
282
+ Raises:
283
+ AttributeError: If access_token is not set
284
+ NoCredentialsError: If refresh token has expired
285
+
286
+ Returns:
287
+ bool: True if the access_token has expired, False otherwise
288
+ """
289
+ try:
290
+ return super().check_token(renew=renew)
291
+ except ClientError as e:
292
+ if e.response["Error"]["Code"] == "NotAuthorizedException":
293
+ LOGGER.warning("Tokens expired, attempting to login with username and password")
294
+ CACHE.clear()
295
+ raise NoCredentialsError("Cached tokens expired, please login again") from e
296
+ raise
297
+
230
298
  def renew_access_token(self) -> None:
231
299
  """Sets a new access token on the User using the cached refresh token and device metadata.
232
300
 
@@ -265,12 +333,12 @@ class OtfCognito(Cognito):
265
333
  self.verify_token(auth_result["AccessToken"], "access_token", "access")
266
334
  self.verify_token(auth_result["IdToken"], "id_token", "id")
267
335
  self.refresh_token = auth_result.get("RefreshToken", self.refresh_token)
268
- CRED_CACHE.write_to_cache(self.tokens)
336
+ CACHE.write_token_data_to_cache(self.tokens, self.expiration_seconds)
269
337
 
270
338
  # device metadata - default to existing values if not present
271
339
  self.device_key = device_metadata.get("DeviceKey", self.device_key)
272
340
  self.device_group_key = device_metadata.get("DeviceGroupKey", self.device_group_key)
273
- CRED_CACHE.write_to_cache(self.device_metadata)
341
+ CACHE.write_device_data_to_cache(self.device_metadata)
274
342
 
275
343
 
276
344
  class HttpxCognitoAuth(httpx.Auth):
@@ -283,10 +351,10 @@ class HttpxCognitoAuth(httpx.Auth):
283
351
  Args:
284
352
  cognito (Cognito): A Cognito instance.
285
353
  """
286
-
287
354
  self.cognito = cognito
288
355
 
289
356
  def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, Any, None]:
357
+ """Add the Cognito access token to the request headers."""
290
358
  self.cognito.check_token(renew=True)
291
359
 
292
360
  token = self.cognito.id_token
@@ -305,9 +373,7 @@ class HttpxCognitoAuth(httpx.Auth):
305
373
  yield request
306
374
 
307
375
  def sign_httpx_request(self, request: httpx.Request) -> Generator[httpx.Request, Any, None]:
308
- """
309
- Sign an HTTP request using AWS SigV4 for use with httpx.
310
- """
376
+ """Sign an HTTP request using AWS SigV4 for use with httpx."""
311
377
  headers = request.headers.copy()
312
378
 
313
379
  # ensure this header is not included, it will break the signature
otf_api/auth/user.py CHANGED
@@ -2,8 +2,8 @@ from logging import getLogger
2
2
 
3
3
  import attrs
4
4
 
5
- from otf_api.auth.auth import CRED_CACHE, HttpxCognitoAuth, NoCredentialsError, OtfCognito
6
- from otf_api.auth.utils import can_provide_input, get_credentials_from_env, prompt_for_username_and_password
5
+ from otf_api.auth.auth import HttpxCognitoAuth, NoCredentialsError, OtfCognito
6
+ from otf_api.auth.utils import get_username_password
7
7
 
8
8
  LOGGER = getLogger(__name__)
9
9
 
@@ -47,27 +47,15 @@ class OtfUser:
47
47
  refresh_token=refresh_token,
48
48
  )
49
49
  except NoCredentialsError:
50
- username, password = get_credentials_from_env()
51
- if not username or not password:
52
- if not can_provide_input():
53
- LOGGER.error("Unable to prompt for credentials in a non-interactive shell")
54
- raise
55
- username, password = prompt_for_username_and_password()
56
- if not username or not password:
57
- raise NoCredentialsError("No credentials provided and no tokens cached, cannot authenticate")
50
+ LOGGER.debug("No credentials provided, attempting to get them from environment or prompt user")
51
+ username, password = get_username_password()
52
+ self.cognito = OtfCognito(username=username, password=password)
58
53
  except Exception as e:
59
54
  LOGGER.exception("Failed to authenticate with Cognito")
60
55
  raise e
61
56
 
62
- self.cognito = OtfCognito(username=username, password=password)
63
-
64
57
  self.cognito_id = self.cognito.access_claims["sub"]
65
58
  self.member_uuid = self.cognito.id_claims["cognito:username"]
66
59
  self.email_address = self.cognito.id_claims["email"]
67
60
 
68
61
  self.httpx_auth = HttpxCognitoAuth(cognito=self.cognito)
69
-
70
- @staticmethod
71
- def clear_cache():
72
- """Clear the cached credentials."""
73
- CRED_CACHE.clear_cache()
otf_api/auth/utils.py CHANGED
@@ -4,11 +4,38 @@ from functools import partial
4
4
  from getpass import getpass
5
5
  from logging import getLogger
6
6
 
7
+ from otf_api.auth.auth import NoCredentialsError
8
+
7
9
  LOGGER = getLogger(__name__)
8
10
  USERNAME_PROMPT = "Enter your Orangetheory Fitness username/email: "
9
11
  PASSWORD_PROMPT = "Enter your Orangetheory Fitness password: "
10
12
 
11
13
 
14
+ def get_username_password() -> tuple[str, str]:
15
+ """Get username and password for OTF authentication.
16
+
17
+ This function checks for credentials in the environment variables first.
18
+ If not found, it prompts the user for credentials if the environment allows it.
19
+ If neither is available, it raises a NoCredentialsError.
20
+
21
+ Returns:
22
+ tuple[str, str]: A tuple containing the username and password.
23
+
24
+ Raises:
25
+ NoCredentialsError: If no credentials are provided and cannot prompt for input.
26
+ """
27
+ username, password = get_credentials_from_env()
28
+ if not username or not password:
29
+ if not can_provide_input():
30
+ LOGGER.error("Unable to prompt for credentials in a non-interactive shell")
31
+ raise
32
+ username, password = prompt_for_username_and_password()
33
+ if not username or not password:
34
+ raise NoCredentialsError("No credentials provided and no tokens cached, cannot authenticate")
35
+
36
+ return username, password
37
+
38
+
12
39
  def _show_error_message(message: str) -> None:
13
40
  try:
14
41
  from rich import get_console # type: ignore
@@ -84,7 +111,6 @@ def get_credentials_from_env() -> tuple[str, str]:
84
111
  Returns:
85
112
  tuple[str, str]: A tuple containing the username and password.
86
113
  """
87
-
88
114
  username = os.getenv("OTF_EMAIL")
89
115
  password = os.getenv("OTF_PASSWORD")
90
116
 
@@ -101,7 +127,6 @@ def prompt_for_username_and_password() -> tuple[str, str]:
101
127
  Returns:
102
128
  tuple[str, str]: A tuple containing the username and password.
103
129
  """
104
-
105
130
  username = _prompt_for_username()
106
131
  password = _prompt_for_password()
107
132
 
otf_api/cache.py ADDED
@@ -0,0 +1,132 @@
1
+ from importlib.metadata import version
2
+ from logging import getLogger
3
+
4
+ from diskcache import Cache
5
+ from packaging.version import Version
6
+ from platformdirs import user_cache_dir
7
+
8
+ _CACHE = None
9
+ DEVICE_KEYS = ["device_key", "device_group_key", "device_password"]
10
+ TOKEN_KEYS = ["access_token", "id_token", "refresh_token"]
11
+
12
+ LOGGER = getLogger(__name__)
13
+
14
+
15
+ class OtfCache(Cache):
16
+ """Small wrapper around diskcache.Cache to handle OTF API specific caching.
17
+
18
+ This class provides methods to write and read device data and token data to/from the cache.
19
+ It also provides methods to clear the cache and specific tags.
20
+ """
21
+
22
+ def write_device_data_to_cache(self, device_data: dict[str, str]) -> None:
23
+ """Writes device data to the cache.
24
+
25
+ Args:
26
+ device_data (dict[str, str]): The device data to write to the cache.
27
+ """
28
+ try:
29
+ if not any(device_data.values()):
30
+ LOGGER.debug("No device data to write to cache")
31
+ return
32
+
33
+ for key, value in device_data.items():
34
+ self.set(key, value, tag="device")
35
+ except Exception:
36
+ LOGGER.exception("Failed to write device key cache")
37
+
38
+ def read_device_data_from_cache(self) -> dict[str, str | None]:
39
+ """Reads device data from the cache.
40
+
41
+ Returns:
42
+ dict[str, str]: The device data read from the cache.
43
+ """
44
+ try:
45
+ dd = {k: self.get(k) for k in DEVICE_KEYS}
46
+ if not any(dd.values()):
47
+ return {}
48
+ return dd # type: ignore
49
+ except Exception:
50
+ LOGGER.exception("Failed to read device key cache")
51
+ return {}
52
+
53
+ def write_token_data_to_cache(self, token_data: dict[str, str], expiration: int | None = None) -> None:
54
+ """Writes token data to the cache.
55
+
56
+ Args:
57
+ token_data (dict[str, str]): The token data to write to the cache.
58
+ expiration (int | None): The expiration time in seconds for the cache entry. Defaults to None.
59
+ """
60
+ try:
61
+ if not any(token_data.values()):
62
+ LOGGER.debug("No token data to write to cache")
63
+ return
64
+
65
+ for key, value in token_data.items():
66
+ self.set(key, value, tag="token", expire=expiration)
67
+ except Exception:
68
+ LOGGER.exception("Failed to write token cache")
69
+
70
+ def read_token_data_from_cache(self) -> dict[str, str | None]:
71
+ """Reads token data from the cache.
72
+
73
+ Returns:
74
+ dict[str, str | None]: The token data read from the cache.
75
+ """
76
+ try:
77
+ tokens = {k: self.get(k) for k in TOKEN_KEYS} # type: ignore
78
+ if not any(tokens.values()):
79
+ return {}
80
+ return tokens # type: ignore
81
+ except Exception:
82
+ LOGGER.exception("Failed to read token cache")
83
+ return {}
84
+
85
+ def clear_tokens(self) -> None:
86
+ """Clears the token cache."""
87
+ try:
88
+ self.evict(tag="token", retry=True)
89
+ except Exception:
90
+ LOGGER.exception("Failed to clear token cache")
91
+
92
+ def clear_device_data(self) -> None:
93
+ """Clears the device data cache."""
94
+ try:
95
+ self.evict(tag="device", retry=True)
96
+ except Exception:
97
+ LOGGER.exception("Failed to clear device key cache")
98
+
99
+ def clear(self) -> None:
100
+ """Clears the cache."""
101
+ try:
102
+ self.clear()
103
+ except Exception:
104
+ LOGGER.exception("Failed to clear cache")
105
+
106
+
107
+ def get_cache_dir() -> str:
108
+ """Returns the cache directory for the OTF API.
109
+
110
+ The cache directory is based on the version of the OTF API package.
111
+
112
+ Returns:
113
+ str: The cache directory path.
114
+ """
115
+ otf_version = Version(version("otf-api"))
116
+ otf_version_major = f"v{otf_version.major}"
117
+ cache_dir = user_cache_dir("otf-api", version=otf_version_major)
118
+ return cache_dir
119
+
120
+
121
+ def get_cache() -> OtfCache:
122
+ """Returns the cache instance, creating it if it does not exist.
123
+
124
+ Returns:
125
+ Cache: The cache instance.
126
+ """
127
+ global _CACHE
128
+ if _CACHE is None:
129
+ cache_dir = get_cache_dir()
130
+ LOGGER.debug("Using cache directory: %s", cache_dir)
131
+ _CACHE = OtfCache(cache_dir)
132
+ return _CACHE
otf_api/exceptions.py CHANGED
@@ -1,4 +1,7 @@
1
- from httpx import Request, Response
1
+ import typing
2
+
3
+ if typing.TYPE_CHECKING:
4
+ from httpx import Request, Response
2
5
 
3
6
 
4
7
  class OtfException(Exception):
@@ -8,25 +11,34 @@ class OtfException(Exception):
8
11
  class OtfRequestError(OtfException):
9
12
  """Raised when an error occurs while making a request to the OTF API."""
10
13
 
11
- original_exception: Exception
12
- response: Response
13
- request: Request
14
+ original_exception: Exception | None
15
+ response: "Response"
16
+ request: "Request"
14
17
 
15
- def __init__(self, message: str, original_exception: Exception | None, response: Response, request: Request):
18
+ def __init__(self, message: str, original_exception: Exception | None, response: "Response", request: "Request"):
16
19
  super().__init__(message)
17
20
  self.original_exception = original_exception
18
21
  self.response = response
19
22
  self.request = request
20
23
 
21
24
 
25
+ class RetryableOtfRequestError(OtfRequestError):
26
+ """Raised when a request to the OTF API fails but can be retried.
27
+
28
+ This is typically used for transient errors that may resolve on retry.
29
+ """
30
+
31
+
22
32
  class BookingError(OtfException):
23
33
  """Base class for booking-related errors, with an optional booking UUID attribute."""
24
34
 
25
35
  booking_uuid: str | None
36
+ booking_id: str | None
26
37
 
27
- def __init__(self, message: str, booking_uuid: str | None = None):
38
+ def __init__(self, message: str, booking_uuid: str | None = None, booking_id: str | None = None):
28
39
  super().__init__(message)
29
40
  self.booking_uuid = booking_uuid
41
+ self.booking_id = booking_id
30
42
 
31
43
 
32
44
  class AlreadyBookedError(BookingError):