otf-api 0.4.0__py3-none-any.whl → 0.6.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 CHANGED
@@ -6,7 +6,7 @@ from loguru import logger
6
6
  from .api import Otf
7
7
  from .auth import OtfUser
8
8
 
9
- __version__ = "0.4.0"
9
+ __version__ = "0.6.0"
10
10
 
11
11
 
12
12
  __all__ = ["Otf", "OtfUser"]
otf_api/api.py CHANGED
@@ -3,7 +3,6 @@ import contextlib
3
3
  import json
4
4
  import typing
5
5
  from datetime import date, datetime
6
- from math import ceil
7
6
  from typing import Any
8
7
 
9
8
  import aiohttp
@@ -43,7 +42,6 @@ from otf_api.models import (
43
42
  TelemetryHrHistory,
44
43
  TelemetryMaxHr,
45
44
  TotalClasses,
46
- WorkoutList,
47
45
  )
48
46
 
49
47
 
@@ -226,32 +224,15 @@ class Otf:
226
224
  """Perform an API request to the performance summary API."""
227
225
  return await self._do(method, API_IO_BASE_URL, url, params, headers)
228
226
 
229
- async def get_workouts(self) -> WorkoutList:
230
- """Get the list of workouts from OT Live.
227
+ async def get_body_composition_list(self) -> BodyCompositionList:
228
+ """Get the member's body composition list.
231
229
 
232
230
  Returns:
233
- WorkoutList: The list of workouts.
234
-
235
- Info:
236
- ---
237
- This returns data from the same api the [OT Live website](https://otlive.orangetheory.com/) uses.
238
- It is quite a bit of data, and all workouts going back to ~2019. The data includes the class history
239
- UUID, which can be used to get telemetry data for a specific workout.
231
+ Any: The member's body composition list.
240
232
  """
233
+ data = await self._default_request("GET", f"/member/members/{self._member_uuid}/body-composition")
241
234
 
242
- res = await self._default_request("GET", "/virtual-class/in-studio-workouts")
243
-
244
- return WorkoutList(workouts=res["data"])
245
-
246
- async def get_total_classes(self) -> TotalClasses:
247
- """Get the member's total classes. This is a simple object reflecting the total number of classes attended,
248
- both in-studio and OT Live.
249
-
250
- Returns:
251
- TotalClasses: The member's total classes.
252
- """
253
- data = await self._default_request("GET", "/mobile/v1/members/classes/summary")
254
- return TotalClasses(**data["data"])
235
+ return BodyCompositionList(data=data["data"])
255
236
 
256
237
  async def get_classes(
257
238
  self,
@@ -346,6 +327,16 @@ class Otf:
346
327
 
347
328
  return classes_list
348
329
 
330
+ async def get_total_classes(self) -> TotalClasses:
331
+ """Get the member's total classes. This is a simple object reflecting the total number of classes attended,
332
+ both in-studio and OT Live.
333
+
334
+ Returns:
335
+ TotalClasses: The member's total classes.
336
+ """
337
+ data = await self._default_request("GET", "/mobile/v1/members/classes/summary")
338
+ return TotalClasses(**data["data"])
339
+
349
340
  async def book_class(self, class_uuid: str) -> BookClass | typing.Any:
350
341
  """Book a class by class_uuid.
351
342
 
@@ -860,16 +851,15 @@ class Otf:
860
851
  res = await self._telemetry_request("GET", path, params=params)
861
852
  return TelemetryMaxHr(**res)
862
853
 
863
- async def get_telemetry(self, class_history_uuid: str, max_data_points: int = 0) -> Telemetry:
864
- """Get the telemetry for a class history.
854
+ async def get_telemetry(self, performance_summary_id: str, max_data_points: int = 120) -> Telemetry:
855
+ """Get the telemetry for a performance summary.
865
856
 
866
857
  This returns an object that contains the max heartrate, start/end bpm for each zone,
867
858
  and a list of telemetry items that contain the heartrate, splat points, calories, and timestamp.
868
859
 
869
860
  Args:
870
- class_history_uuid (str): The class history UUID.
871
- max_data_points (int): The max data points to use for the telemetry. Default is 0, which will attempt to\
872
- get the max data points from the workout. If the workout is not found, it will default to 120 data points.
861
+ performance_summary_id (str): The performance summary id.
862
+ max_data_points (int): The max data points to use for the telemetry. Default is 120.
873
863
 
874
864
  Returns:
875
865
  TelemetryItem: The telemetry for the class history.
@@ -877,30 +867,10 @@ class Otf:
877
867
  """
878
868
  path = "/v1/performance/summary"
879
869
 
880
- max_data_points = max_data_points or await self._get_max_data_points(class_history_uuid)
881
-
882
- params = {"classHistoryUuid": class_history_uuid, "maxDataPoints": max_data_points}
870
+ params = {"classHistoryUuid": performance_summary_id, "maxDataPoints": max_data_points}
883
871
  res = await self._telemetry_request("GET", path, params=params)
884
872
  return Telemetry(**res)
885
873
 
886
- async def _get_max_data_points(self, class_history_uuid: str) -> int:
887
- """Get the max data points to use for the telemetry.
888
-
889
- Attempts to get the amount of active time for the workout from the OT Live API. If the workout is not found,
890
- it will default to 120 data points. If it is found, it will calculate the amount of data points needed based on
891
- the active time. This should amount to a data point per 30 seconds, roughly.
892
-
893
- Args:
894
- class_history_uuid (str): The class history UUID.
895
-
896
- Returns:
897
- int: The max data points to use.
898
- """
899
- workouts = await self.get_workouts()
900
- workout = workouts.by_class_history_uuid.get(class_history_uuid)
901
- max_data_points = 120 if workout is None else ceil(active_time_to_data_points(workout.active_time))
902
- return max_data_points
903
-
904
874
  # the below do not return any data for me, so I can't test them
905
875
 
906
876
  async def _get_member_services(self, active_only: bool = True) -> typing.Any:
@@ -934,17 +904,3 @@ class Otf:
934
904
 
935
905
  data = self._default_request("GET", f"/member/wearables/{self._member_id}/wearable-daily", params=params)
936
906
  return data
937
-
938
- async def get_body_composition_list(self) -> BodyCompositionList:
939
- """Get the member's body composition list.
940
-
941
- Returns:
942
- Any: The member's body composition list.
943
- """
944
- data = await self._default_request("GET", f"/member/members/{self._member_uuid}/body-composition")
945
-
946
- return BodyCompositionList(data=data["data"])
947
-
948
-
949
- def active_time_to_data_points(active_time: int) -> float:
950
- return active_time / 60 * 2
otf_api/auth.py CHANGED
@@ -63,7 +63,8 @@ class OtfCognito(Cognito):
63
63
  @device_key.setter
64
64
  def device_key(self, value: str | None):
65
65
  if not value:
66
- logger.info("Clearing device key")
66
+ if self._device_key:
67
+ logger.info("Clearing device key")
67
68
  self._device_key = value
68
69
  return
69
70
 
@@ -30,7 +30,6 @@ from .responses import (
30
30
  TelemetryHrHistory,
31
31
  TelemetryMaxHr,
32
32
  TotalClasses,
33
- WorkoutList,
34
33
  )
35
34
 
36
35
  __all__ = [
@@ -66,5 +65,4 @@ __all__ = [
66
65
  "TelemetryHrHistory",
67
66
  "TelemetryMaxHr",
68
67
  "TotalClasses",
69
- "WorkoutList",
70
68
  ]
otf_api/models/base.py CHANGED
@@ -1,14 +1,9 @@
1
1
  import inspect
2
2
  import typing
3
- from enum import Enum
4
- from typing import Any, ClassVar, TypeVar
3
+ from typing import ClassVar, TypeVar
5
4
 
6
5
  from box import Box
7
- from inflection import humanize
8
6
  from pydantic import BaseModel, ConfigDict
9
- from rich.style import Style
10
- from rich.styled import Styled
11
- from rich.table import Table
12
7
 
13
8
  if typing.TYPE_CHECKING:
14
9
  from pydantic.main import IncEx
@@ -138,50 +133,6 @@ class BetterDumperMixin:
138
133
  class OtfItemBase(BetterDumperMixin, BaseModel):
139
134
  model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
140
135
 
141
- def convert_row_value_types(self, row: list[Any]) -> list[str]:
142
- for i, val in enumerate(row):
143
- if isinstance(val, bool):
144
- row[i] = str(val)
145
- continue
146
-
147
- if isinstance(val, Enum):
148
- row[i] = val.name
149
- continue
150
-
151
- if val is None:
152
- row[i] = ""
153
- continue
154
-
155
- row[i] = str(val)
156
-
157
- return row
158
-
159
- def get_style(self, is_selected: bool = False) -> Style:
160
- return Style(color="blue", bold=True) if is_selected else Style(color="white")
161
-
162
- def to_row(self, attributes: list[str], is_selected: bool = False) -> list[Styled]:
163
- style = self.get_style(is_selected)
164
-
165
- boxed_self = Box(self.model_dump(), box_dots=True)
166
- row = [boxed_self.get(attr, "") for attr in attributes]
167
- row = self.convert_row_value_types(row)
168
- styled = [Styled(cell, style=style) for cell in row]
169
-
170
- prefix = "> " if is_selected else " "
171
- styled.insert(0, Styled(prefix, style=style))
172
-
173
- return styled
174
-
175
- @property
176
- def sidebar_data(self) -> Table | None:
177
- return None
178
-
179
- @classmethod
180
- def attr_to_column_header(cls, attr: str) -> str:
181
- attr_map = {k: humanize(k) for k in cls.model_fields}
182
-
183
- return attr_map.get(attr, attr)
184
-
185
136
 
186
137
  class OtfListBase(BaseModel):
187
138
  model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
@@ -191,18 +142,6 @@ class OtfListBase(BaseModel):
191
142
  def collection(self) -> list[OtfItemBase]:
192
143
  return getattr(self, self.collection_field)
193
144
 
194
- def to_table(self, columns: list[str]) -> Table:
195
- table = Table(expand=True, show_header=True, show_footer=False)
196
-
197
- table.add_column()
198
- for column in columns:
199
- table.add_column(OtfItemBase.attr_to_column_header(column))
200
-
201
- for item in self.collection:
202
- table.add_row(*item.to_row(columns))
203
-
204
- return table
205
-
206
145
  def to_json(self, **kwargs) -> str:
207
146
  kwargs.setdefault("indent", 4)
208
147
  kwargs.setdefault("exclude_none", True)
@@ -21,7 +21,6 @@ from .telemetry import Telemetry
21
21
  from .telemetry_hr_history import TelemetryHrHistory
22
22
  from .telemetry_max_hr import TelemetryMaxHr
23
23
  from .total_classes import TotalClasses
24
- from .workouts import WorkoutList
25
24
 
26
25
  __all__ = [
27
26
  "BodyCompositionList",
@@ -56,5 +55,4 @@ __all__ = [
56
55
  "TelemetryHrHistory",
57
56
  "TelemetryMaxHr",
58
57
  "TotalClasses",
59
- "WorkoutList",
60
58
  ]
@@ -4,9 +4,6 @@ from typing import ClassVar
4
4
 
5
5
  from inflection import humanize
6
6
  from pydantic import Field
7
- from rich.style import Style
8
- from rich.styled import Styled
9
- from rich.table import Table
10
7
 
11
8
  from otf_api.models.base import OtfItemBase, OtfListBase
12
9
  from otf_api.models.responses.classes import OtfClassTimeMixin
@@ -223,82 +220,7 @@ class Booking(OtfItemBase):
223
220
  def id_val(self) -> str:
224
221
  return self.class_booking_id
225
222
 
226
- @property
227
- def sidebar_data(self) -> Table:
228
- data = {
229
- "date": self.otf_class.date,
230
- "class_name": self.otf_class.name,
231
- "description": self.otf_class.description,
232
- "class_id": self.id_val,
233
- "studio_address": self.otf_class.studio.studio_location.physical_address,
234
- "coach_name": self.otf_class.coach.name,
235
- }
236
-
237
- table = Table(expand=True, show_header=False, show_footer=False)
238
- table.add_column("Key", style="cyan", ratio=1)
239
- table.add_column("Value", style="magenta", ratio=2)
240
-
241
- for key, value in data.items():
242
- if value is False:
243
- table.add_row(key, Styled(str(value), style="red"))
244
- else:
245
- table.add_row(key, str(value))
246
-
247
- return table
248
-
249
- def get_style(self, is_selected: bool = False) -> Style:
250
- style = super().get_style(is_selected)
251
- if self.status == BookingStatus.Cancelled:
252
- style = Style(color="red")
253
- elif self.status == BookingStatus.Waitlisted:
254
- style = Style(color="yellow")
255
- elif self.status == BookingStatus.CheckedIn and is_selected:
256
- style = Style(color="blue", strike=True)
257
- elif self.status == BookingStatus.CheckedIn:
258
- style = Style(color="grey58")
259
-
260
- return style
261
-
262
- @classmethod
263
- def attr_to_column_header(cls, attr: str) -> str:
264
- if attr.startswith("otf_class"):
265
- return OtfClass.attr_to_column_header(attr.split(".")[-1])
266
-
267
- attr_map = {k: humanize(k) for k in cls.model_fields}
268
- overrides = {
269
- "day_of_week": "Class DoW",
270
- "date": "Class Date",
271
- "time": "Class Time",
272
- "duration": "Class Duration",
273
- "name": "Class Name",
274
- "is_home_studio": "Home Studio",
275
- "is_booked": "Booked",
276
- }
277
-
278
- attr_map.update(overrides)
279
-
280
- return attr_map.get(attr, attr)
281
-
282
223
 
283
224
  class BookingList(OtfListBase):
284
225
  collection_field: ClassVar[str] = "bookings"
285
226
  bookings: list[Booking]
286
-
287
- @staticmethod
288
- def show_bookings_columns() -> list[str]:
289
- return [
290
- "otf_class.day_of_week",
291
- "otf_class.date",
292
- "otf_class.time",
293
- "otf_class.duration",
294
- "otf_class.name",
295
- "status",
296
- "otf_class.studio.studio_name",
297
- "is_home_studio",
298
- ]
299
-
300
- def to_table(self, columns: list[str] | None = None) -> Table:
301
- if not columns:
302
- columns = self.show_bookings_columns()
303
-
304
- return super().to_table(columns)
@@ -3,11 +3,7 @@ from enum import Enum
3
3
  from typing import ClassVar
4
4
 
5
5
  from humanize import precisedelta
6
- from inflection import humanize
7
6
  from pydantic import Field
8
- from rich.style import Style
9
- from rich.styled import Styled
10
- from rich.table import Table
11
7
 
12
8
  from otf_api.models.base import OtfItemBase, OtfListBase
13
9
 
@@ -176,80 +172,7 @@ class OtfClass(OtfItemBase, OtfClassTimeMixin):
176
172
  dow = self.starts_at_local.strftime("%A")
177
173
  return DoW.get_case_insensitive(dow)
178
174
 
179
- @property
180
- def sidebar_data(self) -> Table:
181
- data = {
182
- "class_date": self.date,
183
- "class_time": self.time.strip(),
184
- "class_name": self.name,
185
- "class_id": self.id_val,
186
- "available": self.has_availability,
187
- "waitlist_available": self.waitlist_available,
188
- "studio_address": self.studio.address.line1,
189
- "coach_name": self.coach.first_name,
190
- "waitlist_size": self.waitlist_size,
191
- "max_capacity": self.max_capacity,
192
- }
193
-
194
- if not self.full:
195
- del data["waitlist_available"]
196
- del data["waitlist_size"]
197
-
198
- table = Table(expand=True, show_header=False, show_footer=False)
199
- table.add_column("Key", style="cyan", ratio=1)
200
- table.add_column("Value", style="magenta", ratio=2)
201
-
202
- for key, value in data.items():
203
- if value is False:
204
- table.add_row(key, Styled(str(value), style="red"))
205
- else:
206
- table.add_row(key, str(value))
207
-
208
- return table
209
-
210
- def get_style(self, is_selected: bool = False) -> Style:
211
- style = super().get_style(is_selected)
212
- if self.is_booked:
213
- style = Style(color="grey58")
214
- return style
215
-
216
- @classmethod
217
- def attr_to_column_header(cls, attr: str) -> str:
218
- attr_map = {k: humanize(k) for k in cls.model_fields}
219
- overrides = {
220
- "day_of_week": "Class DoW",
221
- "date": "Class Date",
222
- "time": "Class Time",
223
- "duration": "Class Duration",
224
- "name": "Class Name",
225
- "is_home_studio": "Home Studio",
226
- "is_booked": "Booked",
227
- }
228
-
229
- attr_map.update(overrides)
230
-
231
- return attr_map.get(attr, attr)
232
-
233
175
 
234
176
  class OtfClassList(OtfListBase):
235
177
  collection_field: ClassVar[str] = "classes"
236
178
  classes: list[OtfClass]
237
-
238
- @staticmethod
239
- def book_class_columns() -> list[str]:
240
- return [
241
- "day_of_week",
242
- "date",
243
- "time",
244
- "duration",
245
- "name",
246
- "studio.name",
247
- "is_home_studio",
248
- "is_booked",
249
- ]
250
-
251
- def to_table(self, columns: list[str] | None = None) -> Table:
252
- if not columns:
253
- columns = self.book_class_columns()
254
-
255
- return super().to_table(columns)
@@ -33,7 +33,7 @@ class Studio(OtfItemBase):
33
33
  class Class(OtfItemBase):
34
34
  ot_base_class_uuid: str | None = None
35
35
  starts_at_local: str
36
- name: str
36
+ name: str | None = None
37
37
  coach: Coach
38
38
  studio: Studio
39
39
 
@@ -56,7 +56,7 @@ class Ratings(OtfItemBase):
56
56
 
57
57
 
58
58
  class PerformanceSummaryEntry(OtfItemBase):
59
- performance_summary_id: str = Field(..., alias="id")
59
+ id: str = Field(..., alias="id")
60
60
  details: Details
61
61
  ratable: bool
62
62
  otf_class: Class = Field(..., alias="class")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: otf-api
3
- Version: 0.4.0
3
+ Version: 0.6.0
4
4
  Summary: Python OrangeTheory Fitness API Client
5
5
  License: MIT
6
6
  Author: Jessica Smith
@@ -18,7 +18,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
18
18
  Classifier: Topic :: Software Development :: Libraries
19
19
  Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
20
20
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
- Requires-Dist: aiohttp (==3.9.5)
21
+ Requires-Dist: aiohttp (==3.8.*)
22
22
  Requires-Dist: humanize (>=4.9.0,<5.0.0)
23
23
  Requires-Dist: inflection (==0.5.*)
24
24
  Requires-Dist: loguru (==0.7.2)
@@ -27,15 +27,13 @@ Requires-Dist: pint (==0.24.*)
27
27
  Requires-Dist: pycognito (==2024.5.1)
28
28
  Requires-Dist: pydantic (==2.7.3)
29
29
  Requires-Dist: python-box (>=7.2.0,<8.0.0)
30
- Requires-Dist: readchar (>=4.1.0,<5.0.0)
31
- Requires-Dist: typer (>=0.12.3,<0.13.0)
32
30
  Project-URL: Documentation, https://otf-api.readthedocs.io/en/stable/
33
31
  Description-Content-Type: text/markdown
34
32
 
35
33
  Simple API client for interacting with the OrangeTheory Fitness APIs.
36
34
 
37
35
 
38
- This library allows access to the OrangeTheory API to retrieve workouts and performance data, class schedules, studio information, and bookings. It is a work in progress, currently only allowing access to GET calls, but my goal is to expand it to include POST, PUT, and DELETE calls as well.
36
+ This library allows access to the OrangeTheory API to retrieve workouts and performance data, class schedules, studio information, and bookings.
39
37
 
40
38
  ## Installation
41
39
  ```bash
@@ -1,21 +1,16 @@
1
- otf_api/__init__.py,sha256=r_Pbfy17O3PShjKEtmGbzMz77vKm6QIWf7nj1euogiw,237
2
- otf_api/api.py,sha256=4yiYQb_ShBduk0E9JLRs_PzLFEBDIw7c59FFE34BuKo,36749
3
- otf_api/auth.py,sha256=eapyHm768j402iqwgKOr2hpqzztXS8DBp021TSK0Txk,10194
4
- otf_api/cli/__init__.py,sha256=WI-882LPH7Tj_ygDHqE5ehsas_u7m3ulsplS9vXKByk,151
5
- otf_api/cli/_utilities.py,sha256=epjEO9S6ag4HgJLXlTpCQXfdVQkqGWyNavp7DjwPL78,1753
6
- otf_api/cli/app.py,sha256=88TuMwq3foRr1Cui0V3h0mxNkoANqd6QQifI9CIgLvI,6469
7
- otf_api/cli/bookings.py,sha256=wSmZA-03etcL6Tvb1vDSvHZW8EA9CZUgKX6W1pps3Yw,8161
8
- otf_api/cli/prompts.py,sha256=iyodQXVa5v9VsrMxw0ob1okGRBDbWCSxhrNEylsOTEQ,5358
9
- otf_api/models/__init__.py,sha256=2Zvf7u1Z3qguDd4PsWeoP_Lma3bk-A7RmYQ4LbPJ9Kg,1464
10
- otf_api/models/base.py,sha256=oTDxyliK64GyTNx1bGTd-b9dfVn0r3YPpSycs2qEuIw,7285
11
- otf_api/models/responses/__init__.py,sha256=UdJhkzREux-5DnHE5VSYN0KNKxyDkUkYMPWQpa9Y9qs,2000
1
+ otf_api/__init__.py,sha256=eRrcOgzcFV7KIhf6KwQ5AnvOaGfAam3AKbP01hAFOys,237
2
+ otf_api/api.py,sha256=QlRHXDwzbB97aCfacb_9FVox-5AuPLMweRQAZkz-EE0,34885
3
+ otf_api/auth.py,sha256=XzwLSi5M3DyG7bE7DmWAzXF2y6fkJyAZxHUA9lpW25M,10231
4
+ otf_api/models/__init__.py,sha256=3GHBOirQA4yu06cgD9pYmCU8u8_F9nxNHeSXDuFpe5A,1428
5
+ otf_api/models/base.py,sha256=FrYzMVA4-EWhaKwzem8bEAzRu_jK-RTCkDj5ihTSkZM,5442
6
+ otf_api/models/responses/__init__.py,sha256=xxwz-JwRd0upmI0VNdvInbAm2FOQvPo3pS0SEhWfkI4,1947
12
7
  otf_api/models/responses/body_composition_list.py,sha256=RTC5bQpmMDUKqFl0nGFExdDxfnbOAGoBLWunjpOym80,12193
13
8
  otf_api/models/responses/book_class.py,sha256=bWURKEjLZWPzwu3HNP2zUmHWo7q7h6_z43a9KTST0Ec,15413
14
- otf_api/models/responses/bookings.py,sha256=0oQxdKTK-k30GVDKiVxTh0vvPTbrw78sqpQpYL7JnJU,11058
9
+ otf_api/models/responses/bookings.py,sha256=XwW6blHpw9imtXRnQJYahUgfrAeOcGZqfYE-oJZ8510,8467
15
10
  otf_api/models/responses/cancel_booking.py,sha256=dmC5OP97Dy4qYT0l1KHYIitqSCo6M6Yqa0QztjgG_xQ,3859
16
11
  otf_api/models/responses/challenge_tracker_content.py,sha256=KKpSWyyg3viN0vf1Sg2zTMlMZExLe3I6wowmUPWvRCA,1423
17
12
  otf_api/models/responses/challenge_tracker_detail.py,sha256=o0y_ETfHmR1QhoOmvd83P6lfMZUPIwPlnS1V_po0dkE,3048
18
- otf_api/models/responses/classes.py,sha256=wmFMcFT-VLqOFB65pApffNPH6VFS150CEKHMq2MblI4,7090
13
+ otf_api/models/responses/classes.py,sha256=VxesbFyfRhohwkjiclqTtMPl8bNc5PJajveTHtDBQ2A,4731
19
14
  otf_api/models/responses/enums.py,sha256=Au8XhD-4T8ljiueUykFDc6Qz7kOoTlJ_kiDEx7nLVLM,1191
20
15
  otf_api/models/responses/favorite_studios.py,sha256=C5JSyiNijm6HQEBVrV9vPfZexSWQ1IlN0E3Ag0GeP_0,4982
21
16
  otf_api/models/responses/latest_agreement.py,sha256=aE8hbWE4Pgguw4Itah7a1SqwOLpJ6t9oODFwLQ8Wzo0,774
@@ -25,18 +20,16 @@ otf_api/models/responses/member_membership.py,sha256=_z301T9DrdQW9vIgnx_LeZmkRhv
25
20
  otf_api/models/responses/member_purchases.py,sha256=JoTk3hYjsq4rXogVivZxeFaM-j3gIChmIAGVldOU7rE,6085
26
21
  otf_api/models/responses/out_of_studio_workout_history.py,sha256=FwdnmTgFrMtQ8PngsmCv3UroWj3kDnQg6KfGLievoaU,1709
27
22
  otf_api/models/responses/performance_summary_detail.py,sha256=H5yWxGShR4uiXvY2OaniENburTGM7DKQjN7gvF3MG6g,1585
28
- otf_api/models/responses/performance_summary_list.py,sha256=1cbRX4bnLWwy6iYT6NmJXaT2AdZKGJvQMezkvcGLO58,1240
23
+ otf_api/models/responses/performance_summary_list.py,sha256=R__tsXGz5tVX5gfoRoVUNK4UP2pXRoK5jdSyHABsDXs,1234
29
24
  otf_api/models/responses/studio_detail.py,sha256=CJBCsi4SMs_W5nrWE4hfCs1ugJ5t7GrH80hTv7Ie3eg,5007
30
25
  otf_api/models/responses/studio_services.py,sha256=mFDClPtU0HCk5fb19gjGKpt2F8n8kto7sj1pE_l4RdQ,1836
31
26
  otf_api/models/responses/telemetry.py,sha256=8dl8FKLeyb6jtqsZT7XD4JzXBMLlami448-Jt0tFbSY,1663
32
27
  otf_api/models/responses/telemetry_hr_history.py,sha256=vDcLb4wTHVBw8O0mGblUujHfJegkflOCWW-bnTXNCI0,763
33
28
  otf_api/models/responses/telemetry_max_hr.py,sha256=xKxH0fIlOqFyZv8UW98XsxF-GMoIs9gnCTAbu88ZQtg,266
34
29
  otf_api/models/responses/total_classes.py,sha256=WrKkWbq0eK8J0RC4qhZ5kmXnv_ZTDbyzsoRm7XKGlss,288
35
- otf_api/models/responses/workouts.py,sha256=4r6wQVY-yUsI83JYBpSCYhd7I5u-5OLvy1Vd1_gra88,3177
36
30
  otf_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
37
- otf_api-0.4.0.dist-info/AUTHORS.md,sha256=FcNWMxpe8KDuTq4Qau0SUXsabQwGs9TGnMp1WkXRnj8,123
38
- otf_api-0.4.0.dist-info/LICENSE,sha256=UaPT9ynYigC3nX8n22_rC37n-qmTRKLFaHrtUwF9ktE,1071
39
- otf_api-0.4.0.dist-info/METADATA,sha256=0VCBjMyO3Wrka4Jz0ZwB38zm5KxVOlb-H1ZL6_M64WU,2259
40
- otf_api-0.4.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
41
- otf_api-0.4.0.dist-info/entry_points.txt,sha256=V2jhhfsUo3DeF0CA9HmKrMnvSoOldn9ShIzbApbeHTY,44
42
- otf_api-0.4.0.dist-info/RECORD,,
31
+ otf_api-0.6.0.dist-info/AUTHORS.md,sha256=FcNWMxpe8KDuTq4Qau0SUXsabQwGs9TGnMp1WkXRnj8,123
32
+ otf_api-0.6.0.dist-info/LICENSE,sha256=UaPT9ynYigC3nX8n22_rC37n-qmTRKLFaHrtUwF9ktE,1071
33
+ otf_api-0.6.0.dist-info/METADATA,sha256=lzG11-AQbcrEr5whF8QiBWSeJljHCG3V2OL_I1hDl6M,2031
34
+ otf_api-0.6.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
35
+ otf_api-0.6.0.dist-info/RECORD,,
otf_api/cli/__init__.py DELETED
@@ -1,4 +0,0 @@
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"]
otf_api/cli/_utilities.py DELETED
@@ -1,60 +0,0 @@
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 DELETED
@@ -1,172 +0,0 @@
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)
otf_api/cli/bookings.py DELETED
@@ -1,231 +0,0 @@
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.Otf.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.Otf.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.Otf.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.Otf.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.Otf.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.Otf.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 DELETED
@@ -1,162 +0,0 @@
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,78 +0,0 @@
1
- from ast import literal_eval
2
- from datetime import datetime
3
- from typing import Any
4
-
5
- from pydantic import Field, PrivateAttr
6
-
7
- from otf_api.models.base import OtfItemBase
8
-
9
-
10
- class WorkoutType(OtfItemBase):
11
- id: int
12
- display_name: str = Field(..., alias="displayName")
13
- icon: str
14
-
15
-
16
- class Workout(OtfItemBase):
17
- studio_number: str = Field(..., alias="studioNumber")
18
- studio_name: str = Field(..., alias="studioName")
19
- class_type: str = Field(..., alias="classType")
20
- active_time: int = Field(..., alias="activeTime")
21
- coach: str
22
- member_uuid: str = Field(..., alias="memberUuId")
23
- class_date: datetime = Field(..., alias="classDate")
24
- total_calories: int = Field(..., alias="totalCalories")
25
- avg_hr: int = Field(..., alias="avgHr")
26
- max_hr: int = Field(..., alias="maxHr")
27
- avg_percent_hr: int = Field(..., alias="avgPercentHr")
28
- max_percent_hr: int = Field(..., alias="maxPercentHr")
29
- total_splat_points: int = Field(..., alias="totalSplatPoints")
30
- red_zone_time_second: int = Field(..., alias="redZoneTimeSecond")
31
- orange_zone_time_second: int = Field(..., alias="orangeZoneTimeSecond")
32
- green_zone_time_second: int = Field(..., alias="greenZoneTimeSecond")
33
- blue_zone_time_second: int = Field(..., alias="blueZoneTimeSecond")
34
- black_zone_time_second: int = Field(..., alias="blackZoneTimeSecond")
35
- step_count: int = Field(..., alias="stepCount")
36
- class_history_uuid: str = Field(..., alias="classHistoryUuId")
37
- class_id: str = Field(..., alias="classId")
38
- date_created: datetime = Field(..., alias="dateCreated")
39
- date_updated: datetime = Field(..., alias="dateUpdated")
40
- is_intro: bool = Field(..., alias="isIntro")
41
- is_leader: bool = Field(..., alias="isLeader")
42
- member_email: None = Field(..., alias="memberEmail")
43
- member_name: str = Field(..., alias="memberName")
44
- member_performance_id: str = Field(..., alias="memberPerformanceId")
45
- minute_by_minute_hr: list[int] = Field(
46
- ...,
47
- alias="minuteByMinuteHr",
48
- description="HR data for each minute of the workout. It is returned as a string literal, so it needs to be "
49
- "evaluated to a list. If can't be parsed, it will return an empty list.",
50
- )
51
- source: str
52
- studio_account_uuid: str = Field(..., alias="studioAccountUuId")
53
- version: str
54
- workout_type: WorkoutType = Field(..., alias="workoutType")
55
- _minute_by_minute_raw: str | None = PrivateAttr(None)
56
-
57
- @property
58
- def active_time_minutes(self) -> int:
59
- """Get the active time in minutes."""
60
- return self.active_time // 60
61
-
62
- def __init__(self, **data: Any):
63
- if "minuteByMinuteHr" in data:
64
- try:
65
- data["minuteByMinuteHr"] = literal_eval(data["minuteByMinuteHr"])
66
- except (ValueError, SyntaxError):
67
- data["minuteByMinuteHr"] = []
68
-
69
- super().__init__(**data)
70
- self._minute_by_minute_raw = data.get("minuteByMinuteHr")
71
-
72
-
73
- class WorkoutList(OtfItemBase):
74
- workouts: list[Workout]
75
-
76
- @property
77
- def by_class_history_uuid(self) -> dict[str, Workout]:
78
- return {workout.class_history_uuid: workout for workout in self.workouts}
@@ -1,3 +0,0 @@
1
- [console_scripts]
2
- otf=otf_api.cli:base_app
3
-