otf-api 0.3.0__py3-none-any.whl → 0.5.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
@@ -3,13 +3,13 @@ import sys
3
3
 
4
4
  from loguru import logger
5
5
 
6
- from .api import Api
7
- from .models.auth import User
6
+ from .api import Otf
7
+ from .auth import OtfUser
8
8
 
9
- __version__ = "0.3.0"
9
+ __version__ = "0.5.0"
10
10
 
11
11
 
12
- __all__ = ["Api", "User"]
12
+ __all__ = ["Otf", "OtfUser"]
13
13
 
14
14
  logger.remove()
15
15
  logger.add(sink=sys.stdout, level=os.getenv("OTF_LOG_LEVEL", "INFO"))
otf_api/api.py CHANGED
@@ -3,42 +3,45 @@ 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
9
+ import requests
10
10
  from loguru import logger
11
11
  from yarl import URL
12
12
 
13
- from otf_api.models.auth import User
14
- from otf_api.models.responses.body_composition_list import BodyCompositionList
15
- from otf_api.models.responses.book_class import BookClass
16
- from otf_api.models.responses.cancel_booking import CancelBooking
17
- from otf_api.models.responses.classes import ClassType, DoW, OtfClassList
18
- from otf_api.models.responses.favorite_studios import FavoriteStudioList
19
- from otf_api.models.responses.lifetime_stats import StatsResponse, StatsTime
20
- from otf_api.models.responses.performance_summary_detail import PerformanceSummaryDetail
21
- from otf_api.models.responses.performance_summary_list import PerformanceSummaryList
22
- from otf_api.models.responses.studio_detail import Pagination, StudioDetail, StudioDetailList
23
- from otf_api.models.responses.telemetry import Telemetry
24
- from otf_api.models.responses.telemetry_hr_history import TelemetryHrHistory
25
- from otf_api.models.responses.telemetry_max_hr import TelemetryMaxHr
26
-
27
- from .models import (
13
+ from otf_api.auth import OtfUser
14
+ from otf_api.models import (
15
+ BodyCompositionList,
16
+ BookClass,
28
17
  BookingList,
29
18
  BookingStatus,
19
+ CancelBooking,
30
20
  ChallengeTrackerContent,
31
21
  ChallengeTrackerDetailList,
32
22
  ChallengeType,
23
+ ClassType,
24
+ DoW,
33
25
  EquipmentType,
26
+ FavoriteStudioList,
34
27
  LatestAgreement,
35
28
  MemberDetail,
36
29
  MemberMembership,
37
30
  MemberPurchaseList,
31
+ OtfClassList,
38
32
  OutOfStudioWorkoutHistoryList,
33
+ Pagination,
34
+ PerformanceSummaryDetail,
35
+ PerformanceSummaryList,
36
+ StatsResponse,
37
+ StatsTime,
38
+ StudioDetail,
39
+ StudioDetailList,
39
40
  StudioServiceList,
41
+ Telemetry,
42
+ TelemetryHrHistory,
43
+ TelemetryMaxHr,
40
44
  TotalClasses,
41
- WorkoutList,
42
45
  )
43
46
 
44
47
 
@@ -56,40 +59,55 @@ API_TELEMETRY_BASE_URL = "api.yuzu.orangetheory.com"
56
59
  REQUEST_HEADERS = {"Authorization": None, "Content-Type": "application/json", "Accept": "application/json"}
57
60
 
58
61
 
59
- class Api:
60
- """The main class of the otf-api library. Create an instance using the async method `create`.
62
+ class Otf:
63
+ logger: "Logger" = logger
64
+ user: OtfUser
65
+ _session: aiohttp.ClientSession
61
66
 
62
- Example:
67
+ def __init__(
68
+ self,
69
+ username: str | None = None,
70
+ password: str | None = None,
71
+ access_token: str | None = None,
72
+ id_token: str | None = None,
73
+ refresh_token: str | None = None,
74
+ device_key: str | None = None,
75
+ user: OtfUser | None = None,
76
+ ):
77
+ """Create a new Otf instance.
78
+
79
+ Authentication methods:
63
80
  ---
64
- ```python
65
- import asyncio
66
- from otf_api import Api
67
-
68
- async def main():
69
- otf = await Api.create("username", "password")
70
- print(otf.member)
81
+ - Provide a username and password.
82
+ - Provide an access token and id token.
83
+ - Provide a user object.
71
84
 
72
- if __name__ == "__main__":
73
- asyncio.run(main())
74
- ```
75
- """
76
-
77
- logger: "Logger" = logger
78
- user: User
79
- session: aiohttp.ClientSession
85
+ Args:
86
+ username (str, optional): The username of the user. Default is None.
87
+ password (str, optional): The password of the user. Default is None.
88
+ access_token (str, optional): The access token. Default is None.
89
+ id_token (str, optional): The id token. Default is None.
90
+ refresh_token (str, optional): The refresh token. Default is None.
91
+ device_key (str, optional): The device key. Default is None.
92
+ user (OtfUser, optional): A user object. Default is None.
93
+ """
80
94
 
81
- def __init__(self, username: str, password: str):
82
95
  self.member: MemberDetail
83
- self.home_studio: StudioDetail
84
-
85
- self.user = User.login(username, password)
86
-
87
- headers = {
88
- "Authorization": f"Bearer {self.user.cognito.id_token}",
89
- "Content-Type": "application/json",
90
- "Accept": "application/json",
91
- }
92
- self.session = aiohttp.ClientSession(headers=headers)
96
+ self.home_studio_uuid: str
97
+
98
+ if user:
99
+ self.user = user
100
+ elif username and password or (access_token and id_token):
101
+ self.user = OtfUser(
102
+ username=username,
103
+ password=password,
104
+ access_token=access_token,
105
+ id_token=id_token,
106
+ refresh_token=refresh_token,
107
+ device_key=device_key,
108
+ )
109
+ else:
110
+ raise ValueError("No valid authentication method provided")
93
111
 
94
112
  # simplify access to member_id and member_uuid
95
113
  self._member_id = self.user.member_id
@@ -98,26 +116,48 @@ class Api:
98
116
  "koji-member-id": self._member_id,
99
117
  "koji-member-email": self.user.id_claims_data.email,
100
118
  }
119
+ self.member = self._get_member_details_sync()
120
+ self.home_studio_uuid = self.member.home_studio.studio_uuid
101
121
 
102
- @classmethod
103
- async def create(cls, username: str, password: str) -> "Api":
104
- """Create a new API instance. The username and password are required arguments because even though
105
- we cache the token, they expire so quickly that we usually end up needing to re-authenticate.
122
+ def _get_member_details_sync(self) -> MemberDetail:
123
+ """Get the member details synchronously.
106
124
 
107
- Args:
108
- username (str): The username of the user.
109
- password (str): The password of the user.
125
+ This is used to get the member details when the API is first initialized, to let use initialize
126
+ without needing to await the member details.
127
+
128
+ Returns:
129
+ MemberDetail: The member details.
110
130
  """
111
- self = cls(username, password)
112
- self.member = await self.get_member_detail()
113
- self.home_studio = await self.get_studio_detail(self.member.home_studio.studio_uuid)
114
- return self
131
+ url = f"https://{API_BASE_URL}/member/members/{self._member_id}"
132
+ resp = requests.get(url, headers=self.headers)
133
+ return MemberDetail(**resp.json()["data"])
134
+
135
+ @property
136
+ def headers(self) -> dict[str, str]:
137
+ """Get the headers for the API request."""
138
+
139
+ # check the token before making a request in case it has expired
140
+
141
+ self.user.cognito.check_token()
142
+ return {
143
+ "Authorization": f"Bearer {self.user.cognito.id_token}",
144
+ "Content-Type": "application/json",
145
+ "Accept": "application/json",
146
+ }
147
+
148
+ @property
149
+ def session(self) -> aiohttp.ClientSession:
150
+ """Get the aiohttp session."""
151
+ if not getattr(self, "_session", None):
152
+ self._session = aiohttp.ClientSession(headers=self.headers)
153
+
154
+ return self._session
115
155
 
116
156
  def __del__(self) -> None:
117
157
  if not hasattr(self, "session"):
118
158
  return
159
+
119
160
  try:
120
- loop = asyncio.get_event_loop()
121
161
  asyncio.create_task(self._close_session()) # noqa
122
162
  except RuntimeError:
123
163
  loop = asyncio.new_event_loop()
@@ -145,6 +185,12 @@ class Api:
145
185
 
146
186
  logger.debug(f"Making {method!r} request to {full_url}, params: {params}")
147
187
 
188
+ # ensure we have headers that contain the most up-to-date token
189
+ if not headers:
190
+ headers = self.headers
191
+ else:
192
+ headers.update(self.headers)
193
+
148
194
  text = None
149
195
  async with self.session.request(method, full_url, headers=headers, params=params, **kwargs) as response:
150
196
  with contextlib.suppress(Exception):
@@ -155,10 +201,8 @@ class Api:
155
201
  except aiohttp.ClientResponseError as e:
156
202
  logger.exception(f"Error making request: {e}")
157
203
  logger.exception(f"Response: {text}")
158
- # raise
159
204
  except Exception as e:
160
205
  logger.exception(f"Error making request: {e}")
161
- # raise
162
206
 
163
207
  return await response.json()
164
208
 
@@ -180,32 +224,15 @@ class Api:
180
224
  """Perform an API request to the performance summary API."""
181
225
  return await self._do(method, API_IO_BASE_URL, url, params, headers)
182
226
 
183
- async def get_workouts(self) -> WorkoutList:
184
- """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.
185
229
 
186
230
  Returns:
187
- WorkoutList: The list of workouts.
188
-
189
- Info:
190
- ---
191
- This returns data from the same api the [OT Live website](https://otlive.orangetheory.com/) uses.
192
- It is quite a bit of data, and all workouts going back to ~2019. The data includes the class history
193
- UUID, which can be used to get telemetry data for a specific workout.
231
+ Any: The member's body composition list.
194
232
  """
233
+ data = await self._default_request("GET", f"/member/members/{self._member_uuid}/body-composition")
195
234
 
196
- res = await self._default_request("GET", "/virtual-class/in-studio-workouts")
197
-
198
- return WorkoutList(workouts=res["data"])
199
-
200
- async def get_total_classes(self) -> TotalClasses:
201
- """Get the member's total classes. This is a simple object reflecting the total number of classes attended,
202
- both in-studio and OT Live.
203
-
204
- Returns:
205
- TotalClasses: The member's total classes.
206
- """
207
- data = await self._default_request("GET", "/mobile/v1/members/classes/summary")
208
- return TotalClasses(**data["data"])
235
+ return BodyCompositionList(data=data["data"])
209
236
 
210
237
  async def get_classes(
211
238
  self,
@@ -231,7 +258,7 @@ class Api:
231
258
  start_date (str | None): The start date to get classes for, in the format "YYYY-MM-DD". Default is None.
232
259
  end_date (str | None): The end date to get classes for, in the format "YYYY-MM-DD". Default is None.
233
260
  limit (int | None): Limit the number of classes returned. Default is None.
234
- class_type (ClassType | list[ClassType] | None): The class type to filter by. Default is None. Multiple
261
+ class_type (ClassType | list[ClassType] | None): The class type to filter by. Default is None. Multiple\
235
262
  class types can be provided, if there are multiple there will be a call per class type.
236
263
  exclude_cancelled (bool): Whether to exclude cancelled classes. Default is False.
237
264
  day_of_week (list[DoW] | None): The days of the week to filter by. Default is None.
@@ -242,9 +269,9 @@ class Api:
242
269
  """
243
270
 
244
271
  if not studio_uuids:
245
- studio_uuids = [self.home_studio.studio_uuid]
246
- elif include_home_studio and self.home_studio.studio_uuid not in studio_uuids:
247
- studio_uuids.append(self.home_studio.studio_uuid)
272
+ studio_uuids = [self.home_studio_uuid]
273
+ elif include_home_studio and self.home_studio_uuid not in studio_uuids:
274
+ studio_uuids.append(self.home_studio_uuid)
248
275
 
249
276
  path = "/v1/classes"
250
277
 
@@ -280,7 +307,7 @@ class Api:
280
307
  classes_list.classes = [c for c in classes_list.classes if not c.canceled]
281
308
 
282
309
  for otf_class in classes_list.classes:
283
- otf_class.is_home_studio = otf_class.studio.id == self.home_studio.studio_uuid
310
+ otf_class.is_home_studio = otf_class.studio.id == self.home_studio_uuid
284
311
 
285
312
  if day_of_week:
286
313
  classes_list.classes = [c for c in classes_list.classes if c.day_of_week_enum in day_of_week]
@@ -300,6 +327,16 @@ class Api:
300
327
 
301
328
  return classes_list
302
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
+
303
340
  async def book_class(self, class_uuid: str) -> BookClass | typing.Any:
304
341
  """Book a class by class_uuid.
305
342
 
@@ -415,7 +452,7 @@ class Api:
415
452
  for booking in data.bookings:
416
453
  if not booking.otf_class:
417
454
  continue
418
- if booking.otf_class.studio.studio_uuid == self.home_studio.studio_uuid:
455
+ if booking.otf_class.studio.studio_uuid == self.home_studio_uuid:
419
456
  booking.is_home_studio = True
420
457
  else:
421
458
  booking.is_home_studio = False
@@ -660,7 +697,7 @@ class Api:
660
697
  Returns:
661
698
  StudioServiceList: The services available at the studio.
662
699
  """
663
- studio_uuid = studio_uuid or self.home_studio.studio_uuid
700
+ studio_uuid = studio_uuid or self.home_studio_uuid
664
701
  data = await self._default_request("GET", f"/member/studios/{studio_uuid}/services")
665
702
  return StudioServiceList(data=data["data"])
666
703
 
@@ -711,7 +748,7 @@ class Api:
711
748
  Returns:
712
749
  StudioDetail: Detailed information about the studio.
713
750
  """
714
- studio_uuid = studio_uuid or self.home_studio.studio_uuid
751
+ studio_uuid = studio_uuid or self.home_studio_uuid
715
752
 
716
753
  path = f"/mobile/v1/studios/{studio_uuid}"
717
754
  params = {"include": "locations"}
@@ -747,8 +784,11 @@ class Api:
747
784
  """
748
785
  path = "/mobile/v1/studios"
749
786
 
750
- latitude = latitude or self.home_studio.studio_location.latitude
751
- longitude = longitude or self.home_studio.studio_location.longitude
787
+ if not latitude and not longitude:
788
+ home_studio = await self.get_studio_detail()
789
+
790
+ latitude = home_studio.studio_location.latitude
791
+ longitude = home_studio.studio_location.longitude
752
792
 
753
793
  if page_size > 50:
754
794
  self.logger.warning("The API does not support more than 50 results per page, limiting to 50.")
@@ -811,16 +851,15 @@ class Api:
811
851
  res = await self._telemetry_request("GET", path, params=params)
812
852
  return TelemetryMaxHr(**res)
813
853
 
814
- async def get_telemetry(self, class_history_uuid: str, max_data_points: int = 0) -> Telemetry:
815
- """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.
816
856
 
817
857
  This returns an object that contains the max heartrate, start/end bpm for each zone,
818
858
  and a list of telemetry items that contain the heartrate, splat points, calories, and timestamp.
819
859
 
820
860
  Args:
821
- class_history_uuid (str): The class history UUID.
822
- max_data_points (int): The max data points to use for the telemetry. Default is 0, which will attempt to\
823
- 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.
824
863
 
825
864
  Returns:
826
865
  TelemetryItem: The telemetry for the class history.
@@ -828,30 +867,10 @@ class Api:
828
867
  """
829
868
  path = "/v1/performance/summary"
830
869
 
831
- max_data_points = max_data_points or await self._get_max_data_points(class_history_uuid)
832
-
833
- params = {"classHistoryUuid": class_history_uuid, "maxDataPoints": max_data_points}
870
+ params = {"classHistoryUuid": performance_summary_id, "maxDataPoints": max_data_points}
834
871
  res = await self._telemetry_request("GET", path, params=params)
835
872
  return Telemetry(**res)
836
873
 
837
- async def _get_max_data_points(self, class_history_uuid: str) -> int:
838
- """Get the max data points to use for the telemetry.
839
-
840
- Attempts to get the amount of active time for the workout from the OT Live API. If the workout is not found,
841
- it will default to 120 data points. If it is found, it will calculate the amount of data points needed based on
842
- the active time. This should amount to a data point per 30 seconds, roughly.
843
-
844
- Args:
845
- class_history_uuid (str): The class history UUID.
846
-
847
- Returns:
848
- int: The max data points to use.
849
- """
850
- workouts = await self.get_workouts()
851
- workout = workouts.by_class_history_uuid.get(class_history_uuid)
852
- max_data_points = 120 if workout is None else ceil(active_time_to_data_points(workout.active_time))
853
- return max_data_points
854
-
855
874
  # the below do not return any data for me, so I can't test them
856
875
 
857
876
  async def _get_member_services(self, active_only: bool = True) -> typing.Any:
@@ -885,17 +904,3 @@ class Api:
885
904
 
886
905
  data = self._default_request("GET", f"/member/wearables/{self._member_id}/wearable-daily", params=params)
887
906
  return data
888
-
889
- async def get_body_composition_list(self) -> BodyCompositionList:
890
- """Get the member's body composition list.
891
-
892
- Returns:
893
- Any: The member's body composition list.
894
- """
895
- data = await self._default_request("GET", f"/member/members/{self._member_uuid}/body-composition")
896
-
897
- return BodyCompositionList(data=data["data"])
898
-
899
-
900
- def active_time_to_data_points(active_time: int) -> float:
901
- return active_time / 60 * 2