otf-api 0.2.2__py3-none-any.whl → 0.4.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 (50) hide show
  1. otf_api/__init__.py +14 -69
  2. otf_api/api.py +873 -66
  3. otf_api/auth.py +314 -0
  4. otf_api/cli/__init__.py +4 -0
  5. otf_api/cli/_utilities.py +60 -0
  6. otf_api/cli/app.py +172 -0
  7. otf_api/cli/bookings.py +231 -0
  8. otf_api/cli/prompts.py +162 -0
  9. otf_api/models/__init__.py +30 -23
  10. otf_api/models/base.py +205 -2
  11. otf_api/models/responses/__init__.py +29 -29
  12. otf_api/models/responses/body_composition_list.py +304 -0
  13. otf_api/models/responses/book_class.py +405 -0
  14. otf_api/models/responses/bookings.py +211 -37
  15. otf_api/models/responses/cancel_booking.py +93 -0
  16. otf_api/models/responses/challenge_tracker_content.py +6 -6
  17. otf_api/models/responses/challenge_tracker_detail.py +6 -6
  18. otf_api/models/responses/classes.py +205 -7
  19. otf_api/models/responses/enums.py +0 -35
  20. otf_api/models/responses/favorite_studios.py +5 -5
  21. otf_api/models/responses/latest_agreement.py +2 -2
  22. otf_api/models/responses/lifetime_stats.py +92 -0
  23. otf_api/models/responses/member_detail.py +17 -12
  24. otf_api/models/responses/member_membership.py +2 -2
  25. otf_api/models/responses/member_purchases.py +9 -9
  26. otf_api/models/responses/out_of_studio_workout_history.py +4 -4
  27. otf_api/models/responses/performance_summary_detail.py +1 -1
  28. otf_api/models/responses/performance_summary_list.py +13 -13
  29. otf_api/models/responses/studio_detail.py +10 -10
  30. otf_api/models/responses/studio_services.py +8 -8
  31. otf_api/models/responses/telemetry.py +6 -6
  32. otf_api/models/responses/telemetry_hr_history.py +6 -6
  33. otf_api/models/responses/telemetry_max_hr.py +3 -3
  34. otf_api/models/responses/total_classes.py +2 -2
  35. otf_api/models/responses/workouts.py +4 -4
  36. otf_api-0.4.0.dist-info/METADATA +54 -0
  37. otf_api-0.4.0.dist-info/RECORD +42 -0
  38. otf_api-0.4.0.dist-info/entry_points.txt +3 -0
  39. otf_api/__version__.py +0 -1
  40. otf_api/classes_api.py +0 -44
  41. otf_api/member_api.py +0 -380
  42. otf_api/models/auth.py +0 -141
  43. otf_api/performance_api.py +0 -54
  44. otf_api/studios_api.py +0 -96
  45. otf_api/telemetry_api.py +0 -95
  46. otf_api-0.2.2.dist-info/METADATA +0 -284
  47. otf_api-0.2.2.dist-info/RECORD +0 -38
  48. {otf_api-0.2.2.dist-info → otf_api-0.4.0.dist-info}/AUTHORS.md +0 -0
  49. {otf_api-0.2.2.dist-info → otf_api-0.4.0.dist-info}/LICENSE +0 -0
  50. {otf_api-0.2.2.dist-info → otf_api-0.4.0.dist-info}/WHEEL +0 -0
otf_api/auth.py ADDED
@@ -0,0 +1,314 @@
1
+ import typing
2
+ from typing import Any
3
+
4
+ import jwt
5
+ import pendulum
6
+ from loguru import logger
7
+ from pycognito import AWSSRP, Cognito, MFAChallengeException
8
+ from pycognito.exceptions import TokenVerificationException
9
+ from pydantic import Field
10
+ from pydantic.config import ConfigDict
11
+
12
+ from otf_api.models.base import OtfItemBase
13
+
14
+ if typing.TYPE_CHECKING:
15
+ from boto3.session import Session
16
+ from botocore.config import Config
17
+
18
+ CLIENT_ID = "65knvqta6p37efc2l3eh26pl5o" # from otlive
19
+ USER_POOL_ID = "us-east-1_dYDxUeyL1"
20
+
21
+
22
+ class OtfCognito(Cognito):
23
+ _device_key: str | None = None
24
+
25
+ def __init__(
26
+ self,
27
+ user_pool_id: str,
28
+ client_id: str,
29
+ user_pool_region: str | None = None,
30
+ username: str | None = None,
31
+ id_token: str | None = None,
32
+ refresh_token: str | None = None,
33
+ access_token: str | None = None,
34
+ client_secret: str | None = None,
35
+ access_key: str | None = None,
36
+ secret_key: str | None = None,
37
+ session: "Session|None" = None,
38
+ botocore_config: "Config|None" = None,
39
+ boto3_client_kwargs: dict[str, Any] | None = None,
40
+ device_key: str | None = None,
41
+ ):
42
+ super().__init__(
43
+ user_pool_id,
44
+ client_id,
45
+ user_pool_region=user_pool_region,
46
+ username=username,
47
+ id_token=id_token,
48
+ refresh_token=refresh_token,
49
+ access_token=access_token,
50
+ client_secret=client_secret,
51
+ access_key=access_key,
52
+ secret_key=secret_key,
53
+ session=session,
54
+ botocore_config=botocore_config,
55
+ boto3_client_kwargs=boto3_client_kwargs,
56
+ )
57
+ self.device_key = device_key
58
+
59
+ @property
60
+ def device_key(self) -> str | None:
61
+ return self._device_key
62
+
63
+ @device_key.setter
64
+ def device_key(self, value: str | None):
65
+ if not value:
66
+ logger.info("Clearing device key")
67
+ self._device_key = value
68
+ return
69
+
70
+ redacted_value = value[:4] + "*" * (len(value) - 8) + value[-4:]
71
+ logger.info(f"Setting device key: {redacted_value}")
72
+ self._device_key = value
73
+
74
+ def _set_tokens(self, tokens: dict[str, Any]):
75
+ """Set the tokens and device metadata from the response.
76
+
77
+ Args:
78
+ tokens (dict): The response from the Cognito service.
79
+ """
80
+ super()._set_tokens(tokens)
81
+
82
+ if new_metadata := tokens["AuthenticationResult"].get("NewDeviceMetadata"):
83
+ self.device_key = new_metadata["DeviceKey"]
84
+
85
+ def authenticate(self, password: str, client_metadata: dict[str, Any] | None = None, device_key: str | None = None):
86
+ """
87
+ Authenticate the user using the SRP protocol. Overridden to add `confirm_device` call.
88
+
89
+ Args:
90
+ password (str): The user's password
91
+ client_metadata (dict, optional): Any additional client metadata to send to Cognito
92
+ """
93
+ aws = AWSSRP(
94
+ username=self.username,
95
+ password=password,
96
+ pool_id=self.user_pool_id,
97
+ client_id=self.client_id,
98
+ client=self.client,
99
+ client_secret=self.client_secret,
100
+ )
101
+ try:
102
+ tokens = aws.authenticate_user(client_metadata=client_metadata)
103
+ except MFAChallengeException as mfa_challenge:
104
+ self.mfa_tokens = mfa_challenge.get_tokens()
105
+ raise mfa_challenge
106
+
107
+ # Set the tokens and device metadata
108
+ self._set_tokens(tokens)
109
+
110
+ if not device_key:
111
+ # Confirm the device so we can use the refresh token
112
+ aws.confirm_device(tokens)
113
+ else:
114
+ self.device_key = device_key
115
+ try:
116
+ self.renew_access_token()
117
+ except TokenVerificationException:
118
+ logger.error("Failed to renew access token. Confirming device.")
119
+ self.device_key = None
120
+ aws.confirm_device(tokens)
121
+
122
+ def check_token(self, renew: bool = True) -> bool:
123
+ """
124
+ Checks the exp attribute of the access_token and either refreshes
125
+ the tokens by calling the renew_access_tokens method or does nothing
126
+ :param renew: bool indicating whether to refresh on expiration
127
+ :return: bool indicating whether access_token has expired
128
+ """
129
+ if not self.access_token:
130
+ raise AttributeError("Access Token Required to Check Token")
131
+ now = pendulum.now()
132
+ dec_access_token = jwt.decode(self.access_token, options={"verify_signature": False})
133
+
134
+ exp = pendulum.DateTime.fromtimestamp(dec_access_token["exp"])
135
+ if now > exp.subtract(minutes=15):
136
+ expired = True
137
+ if renew:
138
+ self.renew_access_token()
139
+ else:
140
+ expired = False
141
+ return expired
142
+
143
+ def renew_access_token(self):
144
+ """Sets a new access token on the User using the cached refresh token and device metadata."""
145
+ auth_params = {"REFRESH_TOKEN": self.refresh_token}
146
+ self._add_secret_hash(auth_params, "SECRET_HASH")
147
+
148
+ if self.device_key:
149
+ logger.info("Using device key for refresh token")
150
+ auth_params["DEVICE_KEY"] = self.device_key
151
+
152
+ refresh_response = self.client.initiate_auth(
153
+ ClientId=self.client_id, AuthFlow="REFRESH_TOKEN_AUTH", AuthParameters=auth_params
154
+ )
155
+ self._set_tokens(refresh_response)
156
+
157
+ @classmethod
158
+ def from_token(
159
+ cls, access_token: str, id_token: str, refresh_token: str | None = None, device_key: str | None = None
160
+ ) -> "OtfCognito":
161
+ """Create an OtfCognito instance from an id token.
162
+
163
+ Args:
164
+ access_token (str): The access token.
165
+ id_token (str): The id token.
166
+ refresh_token (str, optional): The refresh token. Defaults to None.
167
+ device_key (str, optional): The device key. Defaults
168
+
169
+ Returns:
170
+ OtfCognito: The user instance
171
+ """
172
+ cognito = OtfCognito(
173
+ USER_POOL_ID,
174
+ CLIENT_ID,
175
+ access_token=access_token,
176
+ id_token=id_token,
177
+ refresh_token=refresh_token,
178
+ device_key=device_key,
179
+ )
180
+ cognito.verify_tokens()
181
+ cognito.check_token()
182
+ return cognito
183
+
184
+ @classmethod
185
+ def login(cls, username: str, password: str) -> "OtfCognito":
186
+ """Create an OtfCognito instance from a username and password.
187
+
188
+ Args:
189
+ username (str): The username to login with.
190
+ password (str): The password to login with.
191
+
192
+ Returns:
193
+ OtfCognito: The logged in user.
194
+ """
195
+ cognito_user = OtfCognito(USER_POOL_ID, CLIENT_ID, username=username)
196
+ cognito_user.authenticate(password)
197
+ cognito_user.check_token()
198
+ return cognito_user
199
+
200
+
201
+ class IdClaimsData(OtfItemBase):
202
+ sub: str
203
+ email_verified: bool
204
+ iss: str
205
+ cognito_username: str = Field(alias="cognito:username")
206
+ given_name: str
207
+ locale: str
208
+ home_studio_id: str = Field(alias="custom:home_studio_id")
209
+ aud: str
210
+ event_id: str
211
+ token_use: str
212
+ auth_time: int
213
+ exp: int
214
+ is_migration: str = Field(alias="custom:isMigration")
215
+ iat: int
216
+ family_name: str
217
+ email: str
218
+ koji_person_id: str = Field(alias="custom:koji_person_id")
219
+
220
+ @property
221
+ def member_uuid(self) -> str:
222
+ return self.cognito_username
223
+
224
+ @property
225
+ def full_name(self) -> str:
226
+ return f"{self.given_name} {self.family_name}"
227
+
228
+
229
+ class AccessClaimsData(OtfItemBase):
230
+ sub: str
231
+ device_key: str
232
+ iss: str
233
+ client_id: str
234
+ event_id: str
235
+ token_use: str
236
+ scope: str
237
+ auth_time: int
238
+ exp: int
239
+ iat: int
240
+ jti: str
241
+ username: str
242
+
243
+ @property
244
+ def member_uuid(self) -> str:
245
+ return self.username
246
+
247
+
248
+ class OtfUser(OtfItemBase):
249
+ model_config = ConfigDict(arbitrary_types_allowed=True)
250
+ cognito: OtfCognito
251
+
252
+ def __init__(
253
+ self,
254
+ username: str | None = None,
255
+ password: str | None = None,
256
+ id_token: str | None = None,
257
+ access_token: str | None = None,
258
+ refresh_token: str | None = None,
259
+ device_key: str | None = None,
260
+ cognito: OtfCognito | None = None,
261
+ ):
262
+ """Create a User instance.
263
+
264
+ Args:
265
+ username (str, optional): The username to login with. Defaults to None.
266
+ password (str, optional): The password to login with. Defaults to None.
267
+ id_token (str, optional): The id token. Defaults to None.
268
+ access_token (str, optional): The access token. Defaults to None.
269
+ refresh_token (str, optional): The refresh token. Defaults to None.
270
+ device_key (str, optional): The device key. Defaults to None.
271
+ cognito (OtfCognito, optional): A Cognito instance. Defaults to None.
272
+
273
+ Raises:
274
+ ValueError: Must provide either username and password or id token
275
+
276
+
277
+ """
278
+ if cognito:
279
+ cognito = cognito
280
+ elif username and password:
281
+ cognito = OtfCognito.login(username, password)
282
+ elif access_token and id_token:
283
+ cognito = OtfCognito.from_token(access_token, id_token, refresh_token, device_key)
284
+ else:
285
+ raise ValueError("Must provide either username and password or id token.")
286
+
287
+ super().__init__(cognito=cognito)
288
+
289
+ @property
290
+ def member_id(self) -> str:
291
+ return self.id_claims_data.cognito_username
292
+
293
+ @property
294
+ def member_uuid(self) -> str:
295
+ return self.access_claims_data.sub
296
+
297
+ @property
298
+ def access_claims_data(self) -> AccessClaimsData:
299
+ return AccessClaimsData(**self.cognito.access_claims)
300
+
301
+ @property
302
+ def id_claims_data(self) -> IdClaimsData:
303
+ return IdClaimsData(**self.cognito.id_claims)
304
+
305
+ def get_tokens(self) -> dict[str, str]:
306
+ return {
307
+ "id_token": self.cognito.id_token,
308
+ "access_token": self.cognito.access_token,
309
+ "refresh_token": self.cognito.refresh_token,
310
+ }
311
+
312
+ @property
313
+ def device_key(self) -> str:
314
+ return self.cognito.device_key
@@ -0,0 +1,4 @@
1
+ from otf_api.cli.app import base_app
2
+ from otf_api.cli.bookings import bookings_app, classes_app
3
+
4
+ __all__ = ["base_app", "bookings_app", "classes_app"]
@@ -0,0 +1,60 @@
1
+ import functools
2
+ import inspect
3
+ import traceback
4
+ from collections.abc import Awaitable, Callable
5
+ from typing import Any, NoReturn, ParamSpec, TypeGuard, TypeVar
6
+
7
+ import typer
8
+ from click.exceptions import ClickException
9
+
10
+ T = TypeVar("T")
11
+ P = ParamSpec("P")
12
+ R = TypeVar("R")
13
+
14
+
15
+ def is_async_fn(func: Callable[P, R] | Callable[P, Awaitable[R]]) -> TypeGuard[Callable[P, Awaitable[R]]]:
16
+ """
17
+ Returns `True` if a function returns a coroutine.
18
+
19
+ See https://github.com/microsoft/pyright/issues/2142 for an example use
20
+ """
21
+ while hasattr(func, "__wrapped__"):
22
+ func = func.__wrapped__
23
+
24
+ return inspect.iscoroutinefunction(func)
25
+
26
+
27
+ def exit_with_error(message: Any, code: int = 1, **kwargs: Any) -> NoReturn:
28
+ """
29
+ Utility to print a stylized error message and exit with a non-zero code
30
+ """
31
+ from otf_api.cli.app import base_app
32
+
33
+ kwargs.setdefault("style", "red")
34
+ base_app.console.print(message, **kwargs)
35
+ raise typer.Exit(code)
36
+
37
+
38
+ def exit_with_success(message: Any, **kwargs: Any) -> NoReturn:
39
+ """
40
+ Utility to print a stylized success message and exit with a zero code
41
+ """
42
+ from otf_api.cli.app import base_app
43
+
44
+ kwargs.setdefault("style", "green")
45
+ base_app.console.print(message, **kwargs)
46
+ raise typer.Exit(0)
47
+
48
+
49
+ def with_cli_exception_handling(fn: Callable[[Any], Any]) -> Callable[[Any], Any]:
50
+ @functools.wraps(fn)
51
+ def wrapper(*args: Any, **kwargs: Any) -> Any | None:
52
+ try:
53
+ return fn(*args, **kwargs)
54
+ except (typer.Exit, typer.Abort, ClickException):
55
+ raise # Do not capture click or typer exceptions
56
+ except Exception:
57
+ traceback.print_exc()
58
+ exit_with_error("An exception occurred.")
59
+
60
+ return wrapper
otf_api/cli/app.py ADDED
@@ -0,0 +1,172 @@
1
+ import asyncio
2
+ import functools
3
+ import sys
4
+ import typing
5
+ from collections.abc import Callable
6
+ from enum import Enum
7
+
8
+ import typer
9
+ from loguru import logger
10
+ from rich.console import Console
11
+ from rich.theme import Theme
12
+
13
+ import otf_api
14
+ from otf_api.cli._utilities import is_async_fn, with_cli_exception_handling
15
+
16
+ if typing.TYPE_CHECKING:
17
+ from otf_api import Otf
18
+
19
+
20
+ class OutputType(str, Enum):
21
+ json = "json"
22
+ table = "table"
23
+ interactive = "interactive"
24
+
25
+
26
+ def version_callback(value: bool) -> None:
27
+ if value:
28
+ print(otf_api.__version__)
29
+ raise typer.Exit()
30
+
31
+
32
+ OPT_USERNAME: str = typer.Option(None, envvar=["OTF_EMAIL", "OTF_USERNAME"], help="Username for the OTF API")
33
+ OPT_PASSWORD: str = typer.Option(envvar="OTF_PASSWORD", help="Password for the OTF API", hide_input=True)
34
+ OPT_OUTPUT: OutputType = typer.Option(None, envvar="OTF_OUTPUT", show_default=False, help="Output format")
35
+ OPT_LOG_LEVEL: str = typer.Option("CRITICAL", help="Log level", envvar="OTF_LOG_LEVEL")
36
+ OPT_VERSION: bool = typer.Option(
37
+ None, "--version", callback=version_callback, help="Display the current version.", is_eager=True
38
+ )
39
+
40
+
41
+ def register_main_callback(app: "AsyncTyper") -> None:
42
+ @app.callback() # type: ignore
43
+ @with_cli_exception_handling
44
+ def main_callback(
45
+ ctx: typer.Context, # noqa
46
+ version: bool = OPT_VERSION, # noqa
47
+ username: str = OPT_USERNAME,
48
+ password: str = OPT_PASSWORD,
49
+ output: OutputType = OPT_OUTPUT,
50
+ log_level: str = OPT_LOG_LEVEL,
51
+ ) -> None:
52
+ app.setup_console()
53
+ app.set_username(username)
54
+ app.password = password
55
+ app.output = output or OutputType.table
56
+ app.set_log_level(log_level)
57
+
58
+ # When running on Windows we need to ensure that the correct event loop policy is
59
+ # in place or we will not be able to spawn subprocesses. Sometimes this policy is
60
+ # changed by other libraries, but here in our CLI we should have ownership of the
61
+ # process and be able to safely force it to be the correct policy.
62
+ # https://github.com/PrefectHQ/prefect/issues/8206
63
+ if sys.platform == "win32":
64
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
65
+
66
+
67
+ class AsyncTyper(typer.Typer):
68
+ """
69
+ Wraps commands created by `Typer` to support async functions and handle errors.
70
+ """
71
+
72
+ console: Console
73
+
74
+ def __init__(self, *args: typing.Any, **kwargs: typing.Any):
75
+ super().__init__(*args, **kwargs)
76
+
77
+ theme = Theme({"prompt.choices": "bold blue"})
78
+ self.console = Console(highlight=False, theme=theme, color_system="auto")
79
+
80
+ # TODO: clean these up later, just don't want warnings everywhere that these could be None
81
+ self.api: Otf = None # type: ignore
82
+ self.username: str = None # type: ignore
83
+ self.password: str = None # type: ignore
84
+ self.output: OutputType = None # type: ignore
85
+ self.logger = logger
86
+ self.log_level = "CRITICAL"
87
+
88
+ def set_username(self, username: str | None = None) -> None:
89
+ if username:
90
+ self.username = username
91
+ return
92
+
93
+ raise ValueError("Username not provided and not found in cache")
94
+
95
+ def set_log_level(self, level: str) -> None:
96
+ self.log_level = level
97
+ logger.remove()
98
+ logger.add(sys.stderr, level=self.log_level.upper())
99
+
100
+ def print(self, *args: typing.Any, **kwargs: typing.Any) -> None:
101
+ if self.output == "json":
102
+ self.console.print_json(*args, **kwargs)
103
+ else:
104
+ self.console.print(*args, **kwargs)
105
+
106
+ def add_typer(
107
+ self, typer_instance: "AsyncTyper", *args: typing.Any, aliases: list[str] | None = None, **kwargs: typing.Any
108
+ ) -> typing.Any:
109
+ aliases = aliases or []
110
+ for alias in aliases:
111
+ super().add_typer(typer_instance, *args, name=alias, no_args_is_help=True, hidden=True, **kwargs)
112
+
113
+ return super().add_typer(typer_instance, *args, no_args_is_help=True, **kwargs)
114
+
115
+ def command(
116
+ self, name: str | None = None, *args: typing.Any, aliases: list[str] | None = None, **kwargs: typing.Any
117
+ ) -> Callable[[typing.Any], typing.Any]:
118
+ """
119
+ Create a new command. If aliases are provided, the same command function
120
+ will be registered with multiple names.
121
+ """
122
+
123
+ aliases = aliases or []
124
+
125
+ def wrapper(fn: Callable[[typing.Any], typing.Any]) -> Callable[[typing.Any], typing.Any]:
126
+ # click doesn't support async functions, so we wrap them in
127
+ # asyncio.run(). This has the advantage of keeping the function in
128
+ # the main thread, which means signal handling works for e.g. the
129
+ # server and workers. However, it means that async CLI commands can
130
+ # not directly call other async CLI commands (because asyncio.run()
131
+ # can not be called nested). In that (rare) circumstance, refactor
132
+ # the CLI command so its business logic can be invoked separately
133
+ # from its entrypoint.
134
+ if is_async_fn(fn):
135
+ _fn = fn
136
+
137
+ @functools.wraps(fn)
138
+ def fn(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
139
+ return asyncio.run(_fn(*args, **kwargs)) # type: ignore
140
+
141
+ fn.aio = _fn # type: ignore
142
+
143
+ fn = with_cli_exception_handling(fn)
144
+
145
+ # register fn with its original name
146
+ command_decorator = super(AsyncTyper, self).command(name=name, *args, **kwargs)
147
+ original_command = command_decorator(fn)
148
+
149
+ # register fn for each alias, e.g. @marvin_app.command(aliases=["r"])
150
+ for alias in aliases:
151
+ super(AsyncTyper, self).command(
152
+ name=alias,
153
+ *args,
154
+ **{k: v for k, v in kwargs.items() if k != "aliases"},
155
+ )(fn)
156
+
157
+ return typing.cast(Callable[[typing.Any], typing.Any], original_command)
158
+
159
+ return wrapper
160
+
161
+ def setup_console(self, soft_wrap: bool = True, prompt: bool = True) -> None:
162
+ self.console = Console(
163
+ highlight=False,
164
+ color_system="auto",
165
+ theme=Theme({"prompt.choices": "bold blue"}),
166
+ soft_wrap=not soft_wrap,
167
+ force_interactive=prompt,
168
+ )
169
+
170
+
171
+ base_app = AsyncTyper(add_completion=True, no_args_is_help=True)
172
+ register_main_callback(base_app)