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.
- otf_api/__init__.py +35 -3
- otf_api/api/__init__.py +3 -0
- otf_api/api/_compat.py +77 -0
- otf_api/api/api.py +80 -0
- otf_api/api/bookings/__init__.py +3 -0
- otf_api/api/bookings/booking_api.py +541 -0
- otf_api/api/bookings/booking_client.py +112 -0
- otf_api/api/client.py +203 -0
- otf_api/api/members/__init__.py +3 -0
- otf_api/api/members/member_api.py +187 -0
- otf_api/api/members/member_client.py +112 -0
- otf_api/api/studios/__init__.py +3 -0
- otf_api/api/studios/studio_api.py +173 -0
- otf_api/api/studios/studio_client.py +120 -0
- otf_api/api/utils.py +307 -0
- otf_api/api/workouts/__init__.py +3 -0
- otf_api/api/workouts/workout_api.py +333 -0
- otf_api/api/workouts/workout_client.py +140 -0
- otf_api/auth/__init__.py +1 -1
- otf_api/auth/auth.py +155 -89
- otf_api/auth/user.py +5 -17
- otf_api/auth/utils.py +27 -2
- otf_api/cache.py +132 -0
- otf_api/exceptions.py +18 -6
- otf_api/models/__init__.py +25 -21
- otf_api/models/bookings/__init__.py +23 -0
- otf_api/models/bookings/bookings.py +134 -0
- otf_api/models/{bookings_v2.py → bookings/bookings_v2.py} +72 -31
- otf_api/models/bookings/classes.py +124 -0
- otf_api/models/{enums.py → bookings/enums.py} +7 -81
- otf_api/{filters.py → models/bookings/filters.py} +39 -11
- otf_api/models/{ratings.py → bookings/ratings.py} +2 -6
- otf_api/models/members/__init__.py +5 -0
- otf_api/models/members/member_detail.py +149 -0
- otf_api/models/members/member_membership.py +26 -0
- otf_api/models/members/member_purchases.py +29 -0
- otf_api/models/members/notifications.py +17 -0
- otf_api/models/mixins.py +48 -1
- otf_api/models/studios/__init__.py +5 -0
- otf_api/models/studios/enums.py +11 -0
- otf_api/models/studios/studio_detail.py +93 -0
- otf_api/models/studios/studio_services.py +36 -0
- otf_api/models/workouts/__init__.py +31 -0
- otf_api/models/{body_composition_list.py → workouts/body_composition_list.py} +140 -71
- otf_api/models/workouts/challenge_tracker_content.py +50 -0
- otf_api/models/workouts/challenge_tracker_detail.py +99 -0
- otf_api/models/workouts/enums.py +70 -0
- otf_api/models/workouts/lifetime_stats.py +96 -0
- otf_api/models/workouts/out_of_studio_workout_history.py +32 -0
- otf_api/models/{performance_summary.py → workouts/performance_summary.py} +19 -5
- otf_api/models/workouts/telemetry.py +88 -0
- otf_api/models/{workout.py → workouts/workout.py} +34 -20
- {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/METADATA +4 -2
- otf_api-0.13.0.dist-info/RECORD +59 -0
- {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/WHEEL +1 -1
- otf_api/api.py +0 -1682
- otf_api/logging.py +0 -19
- otf_api/models/bookings.py +0 -109
- otf_api/models/challenge_tracker_content.py +0 -59
- otf_api/models/challenge_tracker_detail.py +0 -88
- otf_api/models/classes.py +0 -70
- otf_api/models/lifetime_stats.py +0 -78
- otf_api/models/member_detail.py +0 -121
- otf_api/models/member_membership.py +0 -26
- otf_api/models/member_purchases.py +0 -29
- otf_api/models/notifications.py +0 -17
- otf_api/models/out_of_studio_workout_history.py +0 -32
- otf_api/models/studio_detail.py +0 -71
- otf_api/models/studio_services.py +0 -36
- otf_api/models/telemetry.py +0 -84
- otf_api/utils.py +0 -164
- otf_api-0.12.0.dist-info/RECORD +0 -38
- {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/licenses/LICENSE +0 -0
- {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.
|
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 = "
|
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
|
-
|
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.
|
49
|
-
|
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
|
-
|
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
|
-
|
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.
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
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
|
-
|
136
|
-
|
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
|
-
|
146
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
158
|
-
|
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
|
-
|
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
|
-
|
176
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
6
|
-
from otf_api.auth.utils import
|
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
|
-
|
51
|
-
|
52
|
-
|
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
|
-
|
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):
|