otf-api 0.2.1__py3-none-any.whl → 0.3.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 (49) hide show
  1. otf_api/__init__.py +12 -67
  2. otf_api/api.py +794 -36
  3. otf_api/cli/__init__.py +4 -0
  4. otf_api/cli/_utilities.py +60 -0
  5. otf_api/cli/app.py +177 -0
  6. otf_api/cli/bookings.py +231 -0
  7. otf_api/cli/prompts.py +162 -0
  8. otf_api/models/__init__.py +4 -8
  9. otf_api/models/auth.py +18 -12
  10. otf_api/models/base.py +205 -2
  11. otf_api/models/responses/__init__.py +6 -14
  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.3.0.dist-info/METADATA +55 -0
  37. otf_api-0.3.0.dist-info/RECORD +42 -0
  38. otf_api-0.3.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/performance_api.py +0 -54
  43. otf_api/studios_api.py +0 -96
  44. otf_api/telemetry_api.py +0 -95
  45. otf_api-0.2.1.dist-info/METADATA +0 -284
  46. otf_api-0.2.1.dist-info/RECORD +0 -38
  47. {otf_api-0.2.1.dist-info → otf_api-0.3.0.dist-info}/AUTHORS.md +0 -0
  48. {otf_api-0.2.1.dist-info → otf_api-0.3.0.dist-info}/LICENSE +0 -0
  49. {otf_api-0.2.1.dist-info → otf_api-0.3.0.dist-info}/WHEEL +0 -0
@@ -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,177 @@
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
+ from otf_api.models.auth import User
16
+
17
+ if typing.TYPE_CHECKING:
18
+ from otf_api.api import Api
19
+
20
+
21
+ class OutputType(str, Enum):
22
+ json = "json"
23
+ table = "table"
24
+ interactive = "interactive"
25
+
26
+
27
+ def version_callback(value: bool) -> None:
28
+ if value:
29
+ print(otf_api.__version__)
30
+ raise typer.Exit()
31
+
32
+
33
+ OPT_USERNAME: str = typer.Option(None, envvar=["OTF_EMAIL", "OTF_USERNAME"], help="Username for the OTF API")
34
+ OPT_PASSWORD: str = typer.Option(envvar="OTF_PASSWORD", help="Password for the OTF API", hide_input=True)
35
+ OPT_OUTPUT: OutputType = typer.Option(None, envvar="OTF_OUTPUT", show_default=False, help="Output format")
36
+ OPT_LOG_LEVEL: str = typer.Option("CRITICAL", help="Log level", envvar="OTF_LOG_LEVEL")
37
+ OPT_VERSION: bool = typer.Option(
38
+ None, "--version", callback=version_callback, help="Display the current version.", is_eager=True
39
+ )
40
+
41
+
42
+ def register_main_callback(app: "AsyncTyper") -> None:
43
+ @app.callback() # type: ignore
44
+ @with_cli_exception_handling
45
+ def main_callback(
46
+ ctx: typer.Context, # noqa
47
+ version: bool = OPT_VERSION, # noqa
48
+ username: str = OPT_USERNAME,
49
+ password: str = OPT_PASSWORD,
50
+ output: OutputType = OPT_OUTPUT,
51
+ log_level: str = OPT_LOG_LEVEL,
52
+ ) -> None:
53
+ app.setup_console()
54
+ app.set_username(username)
55
+ app.password = password
56
+ app.output = output or OutputType.table
57
+ app.set_log_level(log_level)
58
+
59
+ # When running on Windows we need to ensure that the correct event loop policy is
60
+ # in place or we will not be able to spawn subprocesses. Sometimes this policy is
61
+ # changed by other libraries, but here in our CLI we should have ownership of the
62
+ # process and be able to safely force it to be the correct policy.
63
+ # https://github.com/PrefectHQ/prefect/issues/8206
64
+ if sys.platform == "win32":
65
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
66
+
67
+
68
+ class AsyncTyper(typer.Typer):
69
+ """
70
+ Wraps commands created by `Typer` to support async functions and handle errors.
71
+ """
72
+
73
+ console: Console
74
+
75
+ def __init__(self, *args: typing.Any, **kwargs: typing.Any):
76
+ super().__init__(*args, **kwargs)
77
+
78
+ theme = Theme({"prompt.choices": "bold blue"})
79
+ self.console = Console(highlight=False, theme=theme, color_system="auto")
80
+
81
+ # TODO: clean these up later, just don't want warnings everywhere that these could be None
82
+ self.api: Api = None # type: ignore
83
+ self.username: str = None # type: ignore
84
+ self.password: str = None # type: ignore
85
+ self.output: OutputType = None # type: ignore
86
+ self.logger = logger
87
+ self.log_level = "CRITICAL"
88
+
89
+ def set_username(self, username: str | None = None) -> None:
90
+ if username:
91
+ self.username = username
92
+ return
93
+
94
+ if User.cache_file_exists():
95
+ self.username = User.username_from_disk()
96
+ return
97
+
98
+ raise ValueError("Username not provided and not found in cache")
99
+
100
+ def set_log_level(self, level: str) -> None:
101
+ self.log_level = level
102
+ logger.remove()
103
+ logger.add(sys.stderr, level=self.log_level.upper())
104
+
105
+ def print(self, *args: typing.Any, **kwargs: typing.Any) -> None:
106
+ if self.output == "json":
107
+ self.console.print_json(*args, **kwargs)
108
+ else:
109
+ self.console.print(*args, **kwargs)
110
+
111
+ def add_typer(
112
+ self, typer_instance: "AsyncTyper", *args: typing.Any, aliases: list[str] | None = None, **kwargs: typing.Any
113
+ ) -> typing.Any:
114
+ aliases = aliases or []
115
+ for alias in aliases:
116
+ super().add_typer(typer_instance, *args, name=alias, no_args_is_help=True, hidden=True, **kwargs)
117
+
118
+ return super().add_typer(typer_instance, *args, no_args_is_help=True, **kwargs)
119
+
120
+ def command(
121
+ self, name: str | None = None, *args: typing.Any, aliases: list[str] | None = None, **kwargs: typing.Any
122
+ ) -> Callable[[typing.Any], typing.Any]:
123
+ """
124
+ Create a new command. If aliases are provided, the same command function
125
+ will be registered with multiple names.
126
+ """
127
+
128
+ aliases = aliases or []
129
+
130
+ def wrapper(fn: Callable[[typing.Any], typing.Any]) -> Callable[[typing.Any], typing.Any]:
131
+ # click doesn't support async functions, so we wrap them in
132
+ # asyncio.run(). This has the advantage of keeping the function in
133
+ # the main thread, which means signal handling works for e.g. the
134
+ # server and workers. However, it means that async CLI commands can
135
+ # not directly call other async CLI commands (because asyncio.run()
136
+ # can not be called nested). In that (rare) circumstance, refactor
137
+ # the CLI command so its business logic can be invoked separately
138
+ # from its entrypoint.
139
+ if is_async_fn(fn):
140
+ _fn = fn
141
+
142
+ @functools.wraps(fn)
143
+ def fn(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
144
+ return asyncio.run(_fn(*args, **kwargs)) # type: ignore
145
+
146
+ fn.aio = _fn # type: ignore
147
+
148
+ fn = with_cli_exception_handling(fn)
149
+
150
+ # register fn with its original name
151
+ command_decorator = super(AsyncTyper, self).command(name=name, *args, **kwargs)
152
+ original_command = command_decorator(fn)
153
+
154
+ # register fn for each alias, e.g. @marvin_app.command(aliases=["r"])
155
+ for alias in aliases:
156
+ super(AsyncTyper, self).command(
157
+ name=alias,
158
+ *args,
159
+ **{k: v for k, v in kwargs.items() if k != "aliases"},
160
+ )(fn)
161
+
162
+ return typing.cast(Callable[[typing.Any], typing.Any], original_command)
163
+
164
+ return wrapper
165
+
166
+ def setup_console(self, soft_wrap: bool = True, prompt: bool = True) -> None:
167
+ self.console = Console(
168
+ highlight=False,
169
+ color_system="auto",
170
+ theme=Theme({"prompt.choices": "bold blue"}),
171
+ soft_wrap=not soft_wrap,
172
+ force_interactive=prompt,
173
+ )
174
+
175
+
176
+ base_app = AsyncTyper(add_completion=True, no_args_is_help=True)
177
+ register_main_callback(base_app)
@@ -0,0 +1,231 @@
1
+ from enum import Enum
2
+
3
+ import pendulum
4
+ import typer
5
+ from loguru import logger
6
+
7
+ import otf_api
8
+ from otf_api.cli.app import OPT_OUTPUT, AsyncTyper, OutputType, base_app
9
+ from otf_api.cli.prompts import prompt_select_from_table
10
+ from otf_api.models.responses.bookings import BookingStatus
11
+ from otf_api.models.responses.classes import ClassType, ClassTypeCli, DoW
12
+
13
+ flipped_status = {item.value: item.name for item in BookingStatus}
14
+ FlippedEnum = Enum("CliBookingStatus", flipped_status) # type: ignore
15
+
16
+
17
+ bookings_app = AsyncTyper(name="bookings", help="Get bookings data")
18
+ classes_app = AsyncTyper(name="classes", help="Get classes data")
19
+ base_app.add_typer(bookings_app, aliases=["booking"])
20
+ base_app.add_typer(classes_app, aliases=["class"])
21
+
22
+
23
+ def today() -> str:
24
+ val: str = pendulum.yesterday().date().to_date_string()
25
+ return val
26
+
27
+
28
+ def next_month() -> str:
29
+ val: str = pendulum.now().add(months=1).date().to_date_string()
30
+ return val
31
+
32
+
33
+ @bookings_app.command(name="list")
34
+ async def list_bookings(
35
+ start_date: str = typer.Option(default_factory=today, help="Start date for bookings"),
36
+ end_date: str = typer.Option(default_factory=next_month, help="End date for bookings"),
37
+ status: FlippedEnum = typer.Option(None, case_sensitive=False, help="Booking status"),
38
+ limit: int = typer.Option(None, help="Limit the number of bookings returned"),
39
+ exclude_cancelled: bool = typer.Option(
40
+ True, "--exclude-cancelled/--include-cancelled", help="Exclude cancelled bookings", show_default=True
41
+ ),
42
+ exclude_none: bool = typer.Option(
43
+ True, "--exclude-none/--allow-none", help="Exclude fields with a value of None", show_default=True
44
+ ),
45
+ output: OutputType = OPT_OUTPUT,
46
+ ) -> None:
47
+ """
48
+ List bookings data
49
+ """
50
+
51
+ logger.info("Listing bookings data")
52
+
53
+ if output:
54
+ base_app.output = output
55
+
56
+ bk_status = BookingStatus.get_from_key_insensitive(status.value) if status else None
57
+
58
+ if not base_app.api:
59
+ base_app.api = await otf_api.Api.create(base_app.username, base_app.password)
60
+ bookings = await base_app.api.get_bookings(start_date, end_date, bk_status, limit, exclude_cancelled)
61
+
62
+ if base_app.output == "json":
63
+ base_app.print(bookings.to_json(exclude_none=exclude_none))
64
+ elif base_app.output == "table":
65
+ base_app.print(bookings.to_table())
66
+ elif base_app.output == "interactive":
67
+ result = prompt_select_from_table(
68
+ console=base_app.console,
69
+ prompt="Select a booking",
70
+ columns=bookings.show_bookings_columns(),
71
+ data=bookings.bookings,
72
+ )
73
+ print(result)
74
+
75
+
76
+ @bookings_app.command()
77
+ async def book(class_uuid: str = typer.Option(help="Class UUID to cancel")) -> None:
78
+ """
79
+ Book a class
80
+ """
81
+
82
+ logger.info(f"Booking class {class_uuid}")
83
+
84
+ if not base_app.api:
85
+ base_app.api = await otf_api.Api.create(base_app.username, base_app.password)
86
+ booking = await base_app.api.book_class(class_uuid)
87
+
88
+ base_app.console.print(booking)
89
+
90
+
91
+ @bookings_app.command()
92
+ async def book_interactive(
93
+ studio_uuids: list[str] = typer.Option(None, help="Studio UUIDs to get classes for"),
94
+ include_home_studio: bool = typer.Option(True, help="Include the home studio in the classes"),
95
+ start_date: str = typer.Option(default_factory=today, help="Start date for classes"),
96
+ end_date: str = typer.Option(None, help="End date for classes"),
97
+ limit: int = typer.Option(None, help="Limit the number of classes returned"),
98
+ class_type: list[ClassTypeCli] = typer.Option(None, help="Class type to filter by"),
99
+ day_of_week: list[DoW] = typer.Option(None, help="Days of the week to filter by"),
100
+ exclude_cancelled: bool = typer.Option(
101
+ True, "--exclude-cancelled/--allow-cancelled", help="Exclude cancelled classes", show_default=True
102
+ ),
103
+ start_time: list[str] = typer.Option(None, help="Start time for classes"),
104
+ ) -> None:
105
+ """
106
+ Book a class interactively
107
+ """
108
+
109
+ logger.info("Booking class interactively")
110
+
111
+ with base_app.console.status("Getting classes...", spinner="arc"):
112
+ if class_type:
113
+ class_type_enums = [ClassType.get_from_key_insensitive(class_type.value) for class_type in class_type]
114
+ else:
115
+ class_type_enums = None
116
+
117
+ if not base_app.api:
118
+ base_app.api = await otf_api.Api.create(base_app.username, base_app.password)
119
+
120
+ classes = await base_app.api.get_classes(
121
+ studio_uuids,
122
+ include_home_studio,
123
+ start_date,
124
+ end_date,
125
+ limit,
126
+ class_type_enums,
127
+ exclude_cancelled,
128
+ day_of_week,
129
+ start_time,
130
+ )
131
+
132
+ result = prompt_select_from_table(
133
+ console=base_app.console,
134
+ prompt="Book a class, any class",
135
+ columns=classes.book_class_columns(),
136
+ data=classes.classes,
137
+ )
138
+
139
+ print(result["ot_class_uuid"])
140
+ booking = await base_app.api.book_class(result["ot_class_uuid"])
141
+
142
+ base_app.console.print(booking)
143
+
144
+
145
+ @bookings_app.command()
146
+ async def cancel_interactive() -> None:
147
+ """
148
+ Cancel a booking interactively
149
+ """
150
+
151
+ logger.info("Cancelling booking interactively")
152
+
153
+ with base_app.console.status("Getting bookings...", spinner="arc"):
154
+ if not base_app.api:
155
+ base_app.api = await otf_api.Api.create(base_app.username, base_app.password)
156
+ bookings = await base_app.api.get_bookings()
157
+
158
+ result = prompt_select_from_table(
159
+ console=base_app.console,
160
+ prompt="Cancel a booking, any booking",
161
+ columns=bookings.show_bookings_columns(),
162
+ data=bookings.bookings,
163
+ )
164
+
165
+ print(result["class_booking_uuid"])
166
+ booking = await base_app.api.cancel_booking(result["class_booking_uuid"])
167
+
168
+ base_app.console.print(booking)
169
+
170
+
171
+ @bookings_app.command()
172
+ async def cancel(booking_uuid: str = typer.Option(help="Booking UUID to cancel")) -> None:
173
+ """
174
+ Cancel a booking
175
+ """
176
+
177
+ logger.info(f"Cancelling booking {booking_uuid}")
178
+
179
+ if not base_app.api:
180
+ base_app.api = await otf_api.Api.create(base_app.username, base_app.password)
181
+ booking = await base_app.api.cancel_booking(booking_uuid)
182
+
183
+ base_app.console.print(booking)
184
+
185
+
186
+ @classes_app.command(name="list")
187
+ async def list_classes(
188
+ studio_uuids: list[str] = typer.Option(None, help="Studio UUIDs to get classes for"),
189
+ include_home_studio: bool = typer.Option(True, help="Include the home studio in the classes"),
190
+ start_date: str = typer.Option(default_factory=today, help="Start date for classes"),
191
+ end_date: str = typer.Option(default_factory=next_month, help="End date for classes"),
192
+ limit: int = typer.Option(None, help="Limit the number of classes returned"),
193
+ class_type: ClassTypeCli = typer.Option(None, help="Class type to filter by"),
194
+ exclude_cancelled: bool = typer.Option(
195
+ True, "--exclude-cancelled/--allow-cancelled", help="Exclude cancelled classes", show_default=True
196
+ ),
197
+ exclude_none: bool = typer.Option(
198
+ True, "--exclude-none/--allow-none", help="Exclude fields with a value of None", show_default=True
199
+ ),
200
+ output: OutputType = OPT_OUTPUT,
201
+ ) -> None:
202
+ """
203
+ List classes data
204
+ """
205
+
206
+ logger.info("Listing classes")
207
+
208
+ if output:
209
+ base_app.output = output
210
+
211
+ class_type_enum = ClassType.get_from_key_insensitive(class_type.value) if class_type else None
212
+
213
+ if not base_app.api:
214
+ base_app.api = await otf_api.Api.create(base_app.username, base_app.password)
215
+ classes = await base_app.api.get_classes(
216
+ studio_uuids, include_home_studio, start_date, end_date, limit, class_type_enum, exclude_cancelled
217
+ )
218
+
219
+ if base_app.output == "json":
220
+ base_app.print(classes.to_json(exclude_none=exclude_none))
221
+ elif base_app.output == "table":
222
+ base_app.print(classes.to_table())
223
+ else:
224
+ result = prompt_select_from_table(
225
+ console=base_app.console,
226
+ prompt="Book a class, any class",
227
+ columns=classes.book_class_columns(),
228
+ data=classes.classes,
229
+ )
230
+ print(type(result))
231
+ print(result)
otf_api/cli/prompts.py ADDED
@@ -0,0 +1,162 @@
1
+ import os
2
+ import typing
3
+
4
+ import readchar
5
+ from rich.layout import Layout
6
+ from rich.live import Live
7
+ from rich.panel import Panel
8
+ from rich.prompt import Confirm, Prompt
9
+ from rich.table import Table
10
+
11
+ from otf_api.cli._utilities import exit_with_error
12
+ from otf_api.models.base import T
13
+
14
+ if typing.TYPE_CHECKING:
15
+ from rich.console import Console
16
+
17
+
18
+ def prompt(message, **kwargs):
19
+ """Utility to prompt the user for input with consistent styling"""
20
+ return Prompt.ask(f"[bold][green]?[/] {message}[/]", **kwargs)
21
+
22
+
23
+ def confirm(message, **kwargs):
24
+ """Utility to prompt the user for confirmation with consistent styling"""
25
+ return Confirm.ask(f"[bold][green]?[/] {message}[/]", **kwargs)
26
+
27
+
28
+ def prompt_select_from_table(
29
+ console: "Console",
30
+ prompt: str,
31
+ columns: list[str],
32
+ data: list[T],
33
+ table_kwargs: dict | None = None,
34
+ ) -> dict:
35
+ """
36
+ Given a list of columns and some data, display options to user in a table
37
+ and prompt them to select one.
38
+
39
+ Args:
40
+ prompt: A prompt to display to the user before the table.
41
+ columns: A list of strings that represent the attributes of the data to display.
42
+ data: A list of dicts with keys corresponding to the `key` values in
43
+ the `columns` argument.
44
+ table_kwargs: Additional kwargs to pass to the `rich.Table` constructor.
45
+ Returns:
46
+ dict: Data representation of the selected row
47
+ """
48
+ current_idx = 0
49
+ selected_row = None
50
+ table_kwargs = table_kwargs or {}
51
+ layout = Layout()
52
+
53
+ if not data:
54
+ exit_with_error("No data to display")
55
+
56
+ MODEL_TYPE = type(data[0])
57
+
58
+ TABLE_PANEL = Layout(name="left")
59
+ DATA_PANEL = Layout(name="right")
60
+
61
+ layout.split_row(TABLE_PANEL, DATA_PANEL)
62
+
63
+ TABLE_PANEL.ratio = 7
64
+ DATA_PANEL.ratio = 3
65
+ DATA_PANEL.minimum_size = 50
66
+
67
+ n_rows = os.get_terminal_size()[1] - 5
68
+
69
+ def build_table() -> Layout:
70
+ """
71
+ Generate a table of options. The `current_idx` will be highlighted.
72
+ """
73
+
74
+ table = initialize_table()
75
+ rows = data.copy()
76
+ rows, offset = paginate_rows(rows)
77
+ selected_item = add_rows_to_table(table, rows, offset)
78
+
79
+ finalize_table(table, prompt, selected_item)
80
+
81
+ return layout
82
+
83
+ def initialize_table() -> Table:
84
+ table_kwargs.setdefault("expand", True)
85
+ table = Table(**table_kwargs)
86
+ table.add_column()
87
+ for column in columns:
88
+ table.add_column(MODEL_TYPE.attr_to_column_header(column))
89
+ return table
90
+
91
+ def paginate_rows(rows: list[T]) -> tuple[list[T], int]:
92
+ if len(rows) > n_rows:
93
+ start = max(0, current_idx - n_rows + 1)
94
+ end = min(len(rows), start + n_rows)
95
+ rows = rows[start:end]
96
+ offset = start
97
+ else:
98
+ offset = 0
99
+ return rows, offset
100
+
101
+ def add_rows_to_table(table: Table, rows: list[T], offset: int) -> T:
102
+ selected_item: T = None
103
+ for i, item in enumerate(rows):
104
+ idx_with_offset = i + offset
105
+ is_selected_row = idx_with_offset == current_idx
106
+ if is_selected_row:
107
+ selected_item = item
108
+ table.add_row(*item.to_row(columns, is_selected_row))
109
+ return selected_item
110
+
111
+ def finalize_table(table: Table, prompt: str, selected_item: T) -> None:
112
+ if table.row_count < n_rows:
113
+ for _ in range(n_rows - table.row_count):
114
+ table.add_row()
115
+
116
+ TABLE_PANEL.update(Panel(table, title=prompt))
117
+ DATA_PANEL.update(Panel("", title="Selected Data"))
118
+ if not selected_item:
119
+ DATA_PANEL.visible = False
120
+ elif selected_item.sidebar_data is not None:
121
+ sidebar_data = selected_item.sidebar_data
122
+ DATA_PANEL.update(sidebar_data)
123
+ DATA_PANEL.visible = True
124
+
125
+ with Live(build_table(), console=console, transient=True) as live:
126
+ instructions_message = f"[bold][green]?[/] {prompt} [bright_blue][Use arrows to move; enter to select]"
127
+ live.console.print(instructions_message)
128
+ while selected_row is None:
129
+ key = readchar.readkey()
130
+
131
+ start_val = 0
132
+ offset = 0
133
+
134
+ match key:
135
+ case readchar.key.UP:
136
+ offset = -1
137
+ start_val = len(data) - 1 if current_idx < 0 else current_idx
138
+ case readchar.key.PAGE_UP:
139
+ offset = -5
140
+ start_val = len(data) - 1 if current_idx < 0 else current_idx
141
+ case readchar.key.DOWN:
142
+ offset = 1
143
+ start_val = 0 if current_idx >= len(data) else current_idx
144
+ case readchar.key.PAGE_DOWN:
145
+ offset = 5
146
+ start_val = 0 if current_idx >= len(data) else current_idx
147
+ case readchar.key.CTRL_C:
148
+ # gracefully exit with no message
149
+ exit_with_error("")
150
+ case readchar.key.ENTER | readchar.key.CR:
151
+ selected_row = data[current_idx]
152
+
153
+ current_idx = start_val + offset
154
+
155
+ if current_idx < 0:
156
+ current_idx = len(data) - 1
157
+ elif current_idx >= len(data):
158
+ current_idx = 0
159
+
160
+ live.update(build_table(), refresh=True)
161
+
162
+ return selected_row
@@ -1,10 +1,9 @@
1
1
  from .auth import User
2
2
  from .responses import (
3
- ALL_CLASS_STATUS,
4
- ALL_HISTORY_CLASS_STATUS,
5
- ALL_STUDIO_STATUS,
3
+ BookClass,
6
4
  BookingList,
7
5
  BookingStatus,
6
+ CancelBooking,
8
7
  ChallengeTrackerContent,
9
8
  ChallengeTrackerDetailList,
10
9
  ChallengeType,
@@ -22,7 +21,6 @@ from .responses import (
22
21
  StudioDetail,
23
22
  StudioDetailList,
24
23
  StudioServiceList,
25
- StudioStatus,
26
24
  Telemetry,
27
25
  TelemetryHrHistory,
28
26
  TelemetryMaxHr,
@@ -36,7 +34,6 @@ __all__ = [
36
34
  "BookingStatus",
37
35
  "EquipmentType",
38
36
  "HistoryClassStatus",
39
- "StudioStatus",
40
37
  "BookingList",
41
38
  "ChallengeTrackerContent",
42
39
  "ChallengeTrackerDetailList",
@@ -55,9 +52,8 @@ __all__ = [
55
52
  "TelemetryMaxHr",
56
53
  "StudioDetail",
57
54
  "StudioDetailList",
58
- "ALL_CLASS_STATUS",
59
- "ALL_HISTORY_CLASS_STATUS",
60
- "ALL_STUDIO_STATUS",
61
55
  "PerformanceSummaryDetail",
62
56
  "PerformanceSummaryList",
57
+ "BookClass",
58
+ "CancelBooking",
63
59
  ]