otf-api 0.7.0__tar.gz → 0.8.0__tar.gz

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 (36) hide show
  1. {otf_api-0.7.0 → otf_api-0.8.0}/PKG-INFO +2 -1
  2. {otf_api-0.7.0 → otf_api-0.8.0}/pyproject.toml +1 -1
  3. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/__init__.py +1 -1
  4. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/api.py +270 -178
  5. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/bookings.py +2 -2
  6. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/telemetry.py +1 -1
  7. {otf_api-0.7.0 → otf_api-0.8.0}/AUTHORS.md +0 -0
  8. {otf_api-0.7.0 → otf_api-0.8.0}/LICENSE +0 -0
  9. {otf_api-0.7.0 → otf_api-0.8.0}/README.md +0 -0
  10. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/auth.py +0 -0
  11. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/exceptions.py +0 -0
  12. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/__init__.py +0 -0
  13. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/base.py +0 -0
  14. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/body_composition_list.py +0 -0
  15. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/book_class.py +0 -0
  16. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/cancel_booking.py +0 -0
  17. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/challenge_tracker_content.py +0 -0
  18. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/challenge_tracker_detail.py +0 -0
  19. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/classes.py +0 -0
  20. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/enums.py +0 -0
  21. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/favorite_studios.py +0 -0
  22. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/latest_agreement.py +0 -0
  23. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/lifetime_stats.py +0 -0
  24. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/member_detail.py +0 -0
  25. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/member_membership.py +0 -0
  26. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/member_purchases.py +0 -0
  27. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/mixins.py +0 -0
  28. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/out_of_studio_workout_history.py +0 -0
  29. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/performance_summary_detail.py +0 -0
  30. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/performance_summary_list.py +0 -0
  31. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/studio_detail.py +0 -0
  32. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/studio_services.py +0 -0
  33. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/telemetry_hr_history.py +0 -0
  34. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/telemetry_max_hr.py +0 -0
  35. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/models/total_classes.py +0 -0
  36. {otf_api-0.7.0 → otf_api-0.8.0}/src/otf_api/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: otf-api
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Python OrangeTheory Fitness API Client
5
5
  License: MIT
6
6
  Author: Jessica Smith
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3
14
14
  Classifier: Programming Language :: Python :: 3.10
15
15
  Classifier: Programming Language :: Python :: 3.11
16
16
  Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
17
18
  Classifier: Topic :: Internet :: WWW/HTTP
18
19
  Classifier: Topic :: Software Development :: Libraries
19
20
  Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "otf-api"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "Python OrangeTheory Fitness API Client"
5
5
  authors = ["Jessica Smith <j.smith.git1@gmail.com>"]
6
6
  license = "MIT"
@@ -1,7 +1,7 @@
1
1
  from .api import Otf
2
2
  from .auth import OtfUser
3
3
 
4
- __version__ = "0.7.0"
4
+ __version__ = "0.8.0"
5
5
 
6
6
 
7
7
  __all__ = ["Otf", "OtfUser"]
@@ -1,22 +1,22 @@
1
1
  import asyncio
2
2
  import contextlib
3
3
  import json
4
- import typing
5
- from datetime import date, datetime
4
+ from datetime import date, datetime, timedelta
5
+ from logging import Logger, getLogger
6
6
  from typing import Any
7
7
 
8
8
  import aiohttp
9
9
  import requests
10
- from loguru import logger
11
10
  from yarl import URL
12
11
 
13
12
  from otf_api import models
14
13
  from otf_api.auth import OtfUser
15
- from otf_api.exceptions import AlreadyBookedError
16
-
17
- if typing.TYPE_CHECKING:
18
- from loguru import Logger
19
-
14
+ from otf_api.exceptions import (
15
+ AlreadyBookedError,
16
+ BookingAlreadyCancelledError,
17
+ BookingNotFoundError,
18
+ OutsideSchedulingWindowError,
19
+ )
20
20
 
21
21
  API_BASE_URL = "api.orangetheory.co"
22
22
  API_IO_BASE_URL = "api.orangetheory.io"
@@ -25,7 +25,7 @@ REQUEST_HEADERS = {"Authorization": None, "Content-Type": "application/json", "A
25
25
 
26
26
 
27
27
  class Otf:
28
- logger: "Logger" = logger
28
+ logger: "Logger" = getLogger(__file__)
29
29
  user: OtfUser
30
30
  _session: aiohttp.ClientSession
31
31
 
@@ -148,7 +148,7 @@ class Otf:
148
148
 
149
149
  full_url = str(URL.build(scheme="https", host=base_url, path=url))
150
150
 
151
- logger.debug(f"Making {method!r} request to {full_url}, params: {params}")
151
+ self.logger.debug(f"Making {method!r} request to {full_url}, params: {params}")
152
152
 
153
153
  # ensure we have headers that contain the most up-to-date token
154
154
  if not headers:
@@ -164,10 +164,10 @@ class Otf:
164
164
  try:
165
165
  response.raise_for_status()
166
166
  except aiohttp.ClientResponseError as e:
167
- logger.exception(f"Error making request: {e}")
168
- logger.exception(f"Response: {text}")
167
+ self.logger.exception(f"Error making request: {e}")
168
+ self.logger.exception(f"Response: {text}")
169
169
  except Exception as e:
170
- logger.exception(f"Error making request: {e}")
170
+ self.logger.exception(f"Error making request: {e}")
171
171
 
172
172
  return await response.json()
173
173
 
@@ -189,16 +189,6 @@ class Otf:
189
189
  """Perform an API request to the performance summary API."""
190
190
  return await self._do(method, API_IO_BASE_URL, url, params, headers)
191
191
 
192
- async def get_body_composition_list(self):
193
- """Get the member's body composition list.
194
-
195
- Returns:
196
- Any: The member's body composition list.
197
- """
198
- data = await self._default_request("GET", f"/member/members/{self._member_uuid}/body-composition")
199
-
200
- return models.BodyCompositionList(data=data["data"])
201
-
202
192
  async def get_classes(
203
193
  self,
204
194
  studio_uuids: list[str] | None = None,
@@ -210,7 +200,8 @@ class Otf:
210
200
  exclude_cancelled: bool = False,
211
201
  day_of_week: list[models.DoW] | None = None,
212
202
  start_time: list[str] | None = None,
213
- ):
203
+ exclude_unbookable: bool = True,
204
+ ) -> models.OtfClassList:
214
205
  """Get the classes for the user.
215
206
 
216
207
  Returns a list of classes that are available for the user, based on the studio UUIDs provided. If no studio
@@ -228,6 +219,8 @@ class Otf:
228
219
  exclude_cancelled (bool): Whether to exclude cancelled classes. Default is False.
229
220
  day_of_week (list[DoW] | None): The days of the week to filter by. Default is None.
230
221
  start_time (list[str] | None): The start time to filter by. Default is None.
222
+ exclude_unbookable (bool): Whether to exclude classes that are outside the scheduling window. Default is\
223
+ True.
231
224
 
232
225
  Returns:
233
226
  OtfClassList: The classes for the user.
@@ -238,11 +231,7 @@ class Otf:
238
231
  elif include_home_studio and self.home_studio_uuid not in studio_uuids:
239
232
  studio_uuids.append(self.home_studio_uuid)
240
233
 
241
- path = "/v1/classes"
242
-
243
- params = {"studio_ids": studio_uuids}
244
-
245
- classes_resp = await self._classes_request("GET", path, params=params)
234
+ classes_resp = await self._classes_request("GET", "/v1/classes", params={"studio_ids": studio_uuids})
246
235
  classes_list = models.OtfClassList(classes=classes_resp["items"])
247
236
 
248
237
  if start_date:
@@ -284,6 +273,11 @@ class Otf:
284
273
 
285
274
  classes_list.classes = list(filter(lambda c: not c.canceled, classes_list.classes))
286
275
 
276
+ if exclude_unbookable:
277
+ # this endpoint returns classes that the `book_class` endpoint will reject, this filters them out
278
+ max_date = datetime.today().date() + timedelta(days=29)
279
+ classes_list.classes = [c for c in classes_list.classes if c.starts_at_local.date() <= max_date]
280
+
287
281
  booking_resp = await self.get_bookings(start_date, end_date, status=models.BookingStatus.Booked)
288
282
  booked_classes = {b.otf_class.class_uuid for b in booking_resp.bookings}
289
283
 
@@ -292,31 +286,77 @@ class Otf:
292
286
 
293
287
  return classes_list
294
288
 
295
- async def get_total_classes(self):
296
- """Get the member's total classes. This is a simple object reflecting the total number of classes attended,
297
- both in-studio and OT Live.
289
+ async def get_booking(self, booking_uuid: str) -> models.Booking:
290
+ """Get a specific booking by booking_uuid.
291
+
292
+ Args:
293
+ booking_uuid (str): The booking UUID to get.
298
294
 
299
295
  Returns:
300
- TotalClasses: The member's total classes.
296
+ BookingList: The booking.
297
+
298
+ Raises:
299
+ ValueError: If booking_uuid is None or empty string.
301
300
  """
302
- data = await self._default_request("GET", "/mobile/v1/members/classes/summary")
303
- return models.TotalClasses(**data["data"])
301
+ if not booking_uuid:
302
+ raise ValueError("booking_uuid is required")
303
+
304
+ data = await self._default_request("GET", f"/member/members/{self._member_id}/bookings/{booking_uuid}")
305
+ return models.Booking(**data["data"])
304
306
 
305
- async def book_class(self, class_uuid: str):
306
- """Book a class by class_uuid.
307
+ async def get_booking_by_class(self, class_: str | models.OtfClass) -> models.Booking:
308
+ """Get a specific booking by class_uuid or OtfClass object.
307
309
 
308
310
  Args:
309
- class_uuid (str): The class UUID to book.
311
+ class_ (str | OtfClass): The class UUID or the OtfClass object to get the booking for.
310
312
 
311
313
  Returns:
312
- None: The response is empty.
314
+ Booking: The booking.
315
+
316
+ Raises:
317
+ BookingNotFoundError: If the booking does not exist.
318
+ ValueError: If class_uuid is None or empty string.
313
319
  """
314
320
 
315
- bookings = await self.get_bookings()
321
+ class_uuid = class_.ot_class_uuid if isinstance(class_, models.OtfClass) else class_
316
322
 
317
- for booking in bookings.bookings:
323
+ if not class_uuid:
324
+ raise ValueError("class_uuid is required")
325
+
326
+ all_bookings = await self.get_bookings(exclude_cancelled=False, exclude_checkedin=False)
327
+
328
+ for booking in all_bookings.bookings:
318
329
  if booking.otf_class.class_uuid == class_uuid:
319
- raise AlreadyBookedError(f"Class {class_uuid} is already booked.")
330
+ return booking
331
+
332
+ raise BookingNotFoundError(f"Booking for class {class_uuid} not found.")
333
+
334
+ async def book_class(self, class_: str | models.OtfClass) -> models.Booking:
335
+ """Book a class by providing either the class_uuid or the OtfClass object.
336
+
337
+ Args:
338
+ class_ (str | OtfClass): The class UUID or the OtfClass object to book.
339
+
340
+ Returns:
341
+ Booking: The booking.
342
+
343
+ Raises:
344
+ AlreadyBookedError: If the class is already booked.
345
+ OutsideSchedulingWindowError: If the class is outside the scheduling window.
346
+ ValueError: If class_uuid is None or empty string.
347
+ Exception: If there is an error booking the class.
348
+ """
349
+
350
+ class_uuid = class_.ot_class_uuid if isinstance(class_, models.OtfClass) else class_
351
+ if not class_uuid:
352
+ raise ValueError("class_uuid is required")
353
+
354
+ with contextlib.suppress(BookingNotFoundError):
355
+ existing_booking = await self.get_booking_by_class(class_uuid)
356
+ if existing_booking.status != models.BookingStatus.Cancelled:
357
+ raise AlreadyBookedError(
358
+ f"Class {class_uuid} is already booked.", booking_uuid=existing_booking.class_booking_uuid
359
+ )
320
360
 
321
361
  body = {"classUUId": class_uuid, "confirmed": False, "waitlist": False}
322
362
 
@@ -325,25 +365,50 @@ class Otf:
325
365
  if resp["code"] == "ERROR":
326
366
  if resp["data"]["errorCode"] == "603":
327
367
  raise AlreadyBookedError(f"Class {class_uuid} is already booked.")
368
+ if resp["data"]["errorCode"] == "602":
369
+ raise OutsideSchedulingWindowError(f"Class {class_uuid} is outside the scheduling window.")
370
+
328
371
  raise Exception(f"Error booking class {class_uuid}: {json.dumps(resp)}")
329
372
 
330
- data = models.BookClass(**resp["data"])
331
- return data
373
+ # get the booking details - we will only use this to get the booking_uuid
374
+ book_class = models.BookClass(**resp["data"])
375
+
376
+ booking = await self.get_booking(book_class.booking_uuid)
332
377
 
333
- async def cancel_booking(self, booking_uuid: str):
334
- """Cancel a class by booking_uuid.
378
+ return booking
379
+
380
+ async def cancel_booking(self, booking: str | models.Booking):
381
+ """Cancel a booking by providing either the booking_uuid or the Booking object.
335
382
 
336
383
  Args:
337
- booking_uuid (str): The booking UUID to cancel.
384
+ booking (str | Booking): The booking UUID or the Booking object to cancel.
338
385
 
339
386
  Returns:
340
- None: The response is empty.
387
+ CancelBooking: The cancelled booking.
388
+
389
+ Raises:
390
+ ValueError: If booking_uuid is None or empty string
391
+ BookingNotFoundError: If the booking does not exist.
341
392
  """
393
+ booking_uuid = booking.class_booking_uuid if isinstance(booking, models.Booking) else booking
394
+
395
+ if not booking_uuid:
396
+ raise ValueError("booking_uuid is required")
397
+
398
+ try:
399
+ await self.get_booking(booking_uuid)
400
+ except Exception:
401
+ raise BookingNotFoundError(f"Booking {booking_uuid} does not exist.")
342
402
 
343
403
  params = {"confirmed": "true"}
344
404
  resp = await self._default_request(
345
405
  "DELETE", f"/member/members/{self._member_id}/bookings/{booking_uuid}", params=params
346
406
  )
407
+ if resp["code"] == "NOT_AUTHORIZED" and resp["message"].startswith("This class booking has"):
408
+ raise BookingAlreadyCancelledError(
409
+ f"Booking {booking_uuid} is already cancelled.", booking_uuid=booking_uuid
410
+ )
411
+
347
412
  return models.CancelBooking(**resp["data"])
348
413
 
349
414
  async def get_bookings(
@@ -392,7 +457,7 @@ class Otf:
392
457
  """
393
458
 
394
459
  if exclude_cancelled and status == models.BookingStatus.Cancelled:
395
- logger.warning(
460
+ self.logger.warning(
396
461
  "Cannot exclude cancelled bookings when status is Cancelled. Setting exclude_cancelled to False."
397
462
  )
398
463
  exclude_cancelled = False
@@ -430,7 +495,7 @@ class Otf:
430
495
 
431
496
  return data
432
497
 
433
- async def _get_bookings_old(self, status: models.BookingStatus | None = None):
498
+ async def _get_bookings_old(self, status: models.BookingStatus | None = None) -> models.BookingList:
434
499
  """Get the member's bookings.
435
500
 
436
501
  Args:
@@ -473,74 +538,12 @@ class Otf:
473
538
 
474
539
  status_value = status.value if status else None
475
540
 
476
- params = {"status": status_value}
477
-
478
- res = await self._default_request("GET", f"/member/members/{self._member_id}/bookings", params=params)
541
+ res = await self._default_request(
542
+ "GET", f"/member/members/{self._member_id}/bookings", params={"status": status_value}
543
+ )
479
544
 
480
545
  return models.BookingList(bookings=res["data"])
481
546
 
482
- async def get_challenge_tracker_content(self):
483
- """Get the member's challenge tracker content.
484
-
485
- Returns:
486
- ChallengeTrackerContent: The member's challenge tracker content.
487
- """
488
- data = await self._default_request("GET", f"/challenges/v3.1/member/{self._member_id}")
489
- return models.ChallengeTrackerContent(**data["Dto"])
490
-
491
- async def get_challenge_tracker_detail(
492
- self,
493
- equipment_id: models.EquipmentType,
494
- challenge_type_id: models.ChallengeType,
495
- challenge_sub_type_id: int = 0,
496
- ):
497
- """Get the member's challenge tracker details.
498
-
499
- Args:
500
- equipment_id (EquipmentType): The equipment ID.
501
- challenge_type_id (ChallengeType): The challenge type ID.
502
- challenge_sub_type_id (int): The challenge sub type ID. Default is 0.
503
-
504
- Returns:
505
- ChallengeTrackerDetailList: The member's challenge tracker details.
506
-
507
- Notes:
508
- ---
509
- I'm not sure what the challenge_sub_type_id is supposed to be, so it defaults to 0.
510
-
511
- """
512
- params = {
513
- "equipmentId": equipment_id.value,
514
- "challengeTypeId": challenge_type_id.value,
515
- "challengeSubTypeId": challenge_sub_type_id,
516
- }
517
-
518
- data = await self._default_request("GET", f"/challenges/v3/member/{self._member_id}/benchmarks", params=params)
519
-
520
- return models.ChallengeTrackerDetailList(details=data["Dto"])
521
-
522
- async def get_challenge_tracker_participation(self, challenge_type_id: models.ChallengeType):
523
- """Get the member's participation in a challenge.
524
-
525
- Args:
526
- challenge_type_id (ChallengeType): The challenge type ID.
527
-
528
- Returns:
529
- Any: The member's participation in the challenge.
530
-
531
- Notes:
532
- ---
533
- I've never gotten this to return anything other than invalid response. I'm not sure if it's a bug
534
- in my code or the API.
535
-
536
- """
537
- params = {"challengeTypeId": challenge_type_id.value}
538
-
539
- data = await self._default_request(
540
- "GET", f"/challenges/v1/member/{self._member_id}/participation", params=params
541
- )
542
- return data
543
-
544
547
  async def get_member_detail(
545
548
  self, include_addresses: bool = True, include_class_summary: bool = True, include_credit_card: bool = False
546
549
  ):
@@ -581,7 +584,7 @@ class Otf:
581
584
  data = await self._default_request("GET", f"/member/members/{self._member_id}", params=params)
582
585
  return models.MemberDetail(**data["data"])
583
586
 
584
- async def get_member_membership(self):
587
+ async def get_member_membership(self) -> models.MemberMembership:
585
588
  """Get the member's membership details.
586
589
 
587
590
  Returns:
@@ -591,7 +594,7 @@ class Otf:
591
594
  data = await self._default_request("GET", f"/member/members/{self._member_id}/memberships")
592
595
  return models.MemberMembership(**data["data"])
593
596
 
594
- async def get_member_purchases(self):
597
+ async def get_member_purchases(self) -> models.MemberPurchaseList:
595
598
  """Get the member's purchases, including monthly subscriptions and class packs.
596
599
 
597
600
  Returns:
@@ -600,7 +603,9 @@ class Otf:
600
603
  data = await self._default_request("GET", f"/member/members/{self._member_id}/purchases")
601
604
  return models.MemberPurchaseList(data=data["data"])
602
605
 
603
- async def get_member_lifetime_stats(self, select_time: models.StatsTime = models.StatsTime.AllTime):
606
+ async def get_member_lifetime_stats(
607
+ self, select_time: models.StatsTime = models.StatsTime.AllTime
608
+ ) -> models.StatsResponse:
604
609
  """Get the member's lifetime stats.
605
610
 
606
611
  Args:
@@ -617,9 +622,24 @@ class Otf:
617
622
 
618
623
  data = await self._default_request("GET", f"/performance/v2/{self._member_id}/over-time/{select_time.value}")
619
624
 
620
- return models.StatsResponse(**data["data"])
625
+ stats = models.StatsResponse(**data["data"])
626
+ return stats
627
+
628
+ async def get_latest_agreement(self) -> models.LatestAgreement:
629
+ """Get the latest agreement for the member.
630
+
631
+ Returns:
632
+ LatestAgreement: The agreement.
633
+
634
+ Notes:
635
+ ---
636
+ In this context, "latest" means the most recent agreement with a specific ID, not the most recent agreement
637
+ in general. The agreement ID is hardcoded in the endpoint, so it will always return the same agreement.
638
+ """
639
+ data = await self._default_request("GET", "/member/agreements/9d98fb27-0f00-4598-ad08-5b1655a59af6")
640
+ return models.LatestAgreement(**data["data"])
621
641
 
622
- async def get_out_of_studio_workout_history(self):
642
+ async def get_out_of_studio_workout_history(self) -> models.OutOfStudioWorkoutHistoryList:
623
643
  """Get the member's out of studio workout history.
624
644
 
625
645
  Returns:
@@ -627,9 +647,9 @@ class Otf:
627
647
  """
628
648
  data = await self._default_request("GET", f"/member/members/{self._member_id}/out-of-studio-workout")
629
649
 
630
- return models.OutOfStudioWorkoutHistoryList(data=data["data"])
650
+ return models.OutOfStudioWorkoutHistoryList(workouts=data["data"])
631
651
 
632
- async def get_favorite_studios(self):
652
+ async def get_favorite_studios(self) -> models.FavoriteStudioList:
633
653
  """Get the member's favorite studios.
634
654
 
635
655
  Returns:
@@ -639,21 +659,7 @@ class Otf:
639
659
 
640
660
  return models.FavoriteStudioList(studios=data["data"])
641
661
 
642
- async def get_latest_agreement(self):
643
- """Get the latest agreement for the member.
644
-
645
- Returns:
646
- LatestAgreement: The agreement.
647
-
648
- Notes:
649
- ---
650
- In this context, "latest" means the most recent agreement with a specific ID, not the most recent agreement
651
- in general. The agreement ID is hardcoded in the endpoint, so it will always return the same agreement.
652
- """
653
- data = await self._default_request("GET", "/member/agreements/9d98fb27-0f00-4598-ad08-5b1655a59af6")
654
- return models.LatestAgreement(**data["data"])
655
-
656
- async def get_studio_services(self, studio_uuid: str | None = None):
662
+ async def get_studio_services(self, studio_uuid: str | None = None) -> models.StudioServiceList:
657
663
  """Get the services available at a specific studio. If no studio UUID is provided, the member's home studio
658
664
  will be used.
659
665
 
@@ -668,41 +674,7 @@ class Otf:
668
674
  data = await self._default_request("GET", f"/member/studios/{studio_uuid}/services")
669
675
  return models.StudioServiceList(data=data["data"])
670
676
 
671
- async def get_performance_summaries(self, limit: int = 30):
672
- """Get a list of performance summaries for the authenticated user.
673
-
674
- Args:
675
- limit (int): The maximum number of performance summaries to return. Defaults to 30.
676
-
677
- Returns:
678
- PerformanceSummaryList: A list of performance summaries.
679
-
680
- Developer Notes:
681
- ---
682
- In the app, this is referred to as 'getInStudioWorkoutHistory'.
683
-
684
- """
685
-
686
- path = "/v1/performance-summaries"
687
- params = {"limit": limit}
688
- res = await self._performance_summary_request("GET", path, headers=self._perf_api_headers, params=params)
689
- return models.PerformanceSummaryList(summaries=res["items"])
690
-
691
- async def get_performance_summary(self, performance_summary_id: str):
692
- """Get a detailed performance summary for a given workout.
693
-
694
- Args:
695
- performance_summary_id (str): The ID of the performance summary to retrieve.
696
-
697
- Returns:
698
- PerformanceSummaryDetail: A detailed performance summary.
699
- """
700
-
701
- path = f"/v1/performance-summaries/{performance_summary_id}"
702
- res = await self._performance_summary_request("GET", path, headers=self._perf_api_headers)
703
- return models.PerformanceSummaryDetail(**res)
704
-
705
- async def get_studio_detail(self, studio_uuid: str | None = None):
677
+ async def get_studio_detail(self, studio_uuid: str | None = None) -> models.StudioDetail:
706
678
  """Get detailed information about a specific studio. If no studio UUID is provided, it will default to the
707
679
  user's home studio.
708
680
 
@@ -728,7 +700,7 @@ class Otf:
728
700
  distance: float = 50,
729
701
  page_index: int = 1,
730
702
  page_size: int = 50,
731
- ):
703
+ ) -> models.StudioDetailList:
732
704
  """Search for studios by geographic location.
733
705
 
734
706
  Args:
@@ -785,7 +757,127 @@ class Otf:
785
757
 
786
758
  return models.StudioDetailList(studios=all_results)
787
759
 
788
- async def get_hr_history(self):
760
+ async def get_total_classes(self) -> models.TotalClasses:
761
+ """Get the member's total classes. This is a simple object reflecting the total number of classes attended,
762
+ both in-studio and OT Live.
763
+
764
+ Returns:
765
+ TotalClasses: The member's total classes.
766
+ """
767
+ data = await self._default_request("GET", "/mobile/v1/members/classes/summary")
768
+ return models.TotalClasses(**data["data"])
769
+
770
+ async def get_body_composition_list(self) -> models.BodyCompositionList:
771
+ """Get the member's body composition list.
772
+
773
+ Returns:
774
+ Any: The member's body composition list.
775
+ """
776
+ data = await self._default_request("GET", f"/member/members/{self._member_uuid}/body-composition")
777
+
778
+ return models.BodyCompositionList(data=data["data"])
779
+
780
+ async def get_challenge_tracker_content(self) -> models.ChallengeTrackerContent:
781
+ """Get the member's challenge tracker content.
782
+
783
+ Returns:
784
+ ChallengeTrackerContent: The member's challenge tracker content.
785
+ """
786
+ data = await self._default_request("GET", f"/challenges/v3.1/member/{self._member_id}")
787
+ return models.ChallengeTrackerContent(**data["Dto"])
788
+
789
+ async def get_challenge_tracker_detail(
790
+ self,
791
+ equipment_id: models.EquipmentType,
792
+ challenge_type_id: models.ChallengeType,
793
+ challenge_sub_type_id: int = 0,
794
+ ):
795
+ """Get the member's challenge tracker details.
796
+
797
+ Args:
798
+ equipment_id (EquipmentType): The equipment ID.
799
+ challenge_type_id (ChallengeType): The challenge type ID.
800
+ challenge_sub_type_id (int): The challenge sub type ID. Default is 0.
801
+
802
+ Returns:
803
+ ChallengeTrackerDetailList: The member's challenge tracker details.
804
+
805
+ Notes:
806
+ ---
807
+ I'm not sure what the challenge_sub_type_id is supposed to be, so it defaults to 0.
808
+
809
+ """
810
+ params = {
811
+ "equipmentId": equipment_id.value,
812
+ "challengeTypeId": challenge_type_id.value,
813
+ "challengeSubTypeId": challenge_sub_type_id,
814
+ }
815
+
816
+ data = await self._default_request("GET", f"/challenges/v3/member/{self._member_id}/benchmarks", params=params)
817
+
818
+ return models.ChallengeTrackerDetailList(details=data["Dto"])
819
+
820
+ async def get_challenge_tracker_participation(self, challenge_type_id: models.ChallengeType) -> Any:
821
+ """Get the member's participation in a challenge.
822
+
823
+ Args:
824
+ challenge_type_id (ChallengeType): The challenge type ID.
825
+
826
+ Returns:
827
+ Any: The member's participation in the challenge.
828
+
829
+ Notes:
830
+ ---
831
+ I've never gotten this to return anything other than invalid response. I'm not sure if it's a bug
832
+ in my code or the API.
833
+
834
+ """
835
+
836
+ data = await self._default_request(
837
+ "GET",
838
+ f"/challenges/v1/member/{self._member_id}/participation",
839
+ params={"challengeTypeId": challenge_type_id.value},
840
+ )
841
+ return data
842
+
843
+ async def get_performance_summaries(self, limit: int = 30) -> models.PerformanceSummaryList:
844
+ """Get a list of performance summaries for the authenticated user.
845
+
846
+ Args:
847
+ limit (int): The maximum number of performance summaries to return. Defaults to 30.
848
+
849
+ Returns:
850
+ PerformanceSummaryList: A list of performance summaries.
851
+
852
+ Developer Notes:
853
+ ---
854
+ In the app, this is referred to as 'getInStudioWorkoutHistory'.
855
+
856
+ """
857
+
858
+ res = await self._performance_summary_request(
859
+ "GET",
860
+ "/v1/performance-summaries",
861
+ headers=self._perf_api_headers,
862
+ params={"limit": limit},
863
+ )
864
+ return models.PerformanceSummaryList(summaries=res["items"])
865
+
866
+ async def get_performance_summary(self, performance_summary_id: str) -> models.PerformanceSummaryDetail:
867
+ """Get a detailed performance summary for a given workout.
868
+
869
+ Args:
870
+ performance_summary_id (str): The ID of the performance summary to retrieve.
871
+
872
+ Returns:
873
+ PerformanceSummaryDetail: A detailed performance summary.
874
+ """
875
+
876
+ path = f"/v1/performance-summaries/{performance_summary_id}"
877
+ res = await self._performance_summary_request("GET", path, headers=self._perf_api_headers)
878
+ return models.PerformanceSummaryDetail(**res)
879
+
880
+ async def get_hr_history(self) -> models.TelemetryHrHistory:
789
881
  """Get the heartrate history for the user.
790
882
 
791
883
  Returns a list of history items that contain the max heartrate, start/end bpm for each zone,
@@ -801,7 +893,7 @@ class Otf:
801
893
  res = await self._telemetry_request("GET", path, params=params)
802
894
  return models.TelemetryHrHistory(**res)
803
895
 
804
- async def get_max_hr(self):
896
+ async def get_max_hr(self) -> models.TelemetryMaxHr:
805
897
  """Get the max heartrate for the user.
806
898
 
807
899
  Returns a simple object that has the member_uuid and the max_hr.
@@ -816,7 +908,7 @@ class Otf:
816
908
  res = await self._telemetry_request("GET", path, params=params)
817
909
  return models.TelemetryMaxHr(**res)
818
910
 
819
- async def get_telemetry(self, performance_summary_id: str, max_data_points: int = 120):
911
+ async def get_telemetry(self, performance_summary_id: str, max_data_points: int = 120) -> models.Telemetry:
820
912
  """Get the telemetry for a performance summary.
821
913
 
822
914
  This returns an object that contains the max heartrate, start/end bpm for each zone,
@@ -838,7 +930,7 @@ class Otf:
838
930
 
839
931
  # the below do not return any data for me, so I can't test them
840
932
 
841
- async def _get_member_services(self, active_only: bool = True):
933
+ async def _get_member_services(self, active_only: bool = True) -> Any:
842
934
  """Get the member's services.
843
935
 
844
936
  Args:
@@ -853,7 +945,7 @@ class Otf:
853
945
  )
854
946
  return data
855
947
 
856
- async def _get_aspire_data(self, datetime: str | None = None, unit: str | None = None):
948
+ async def _get_aspire_data(self, datetime: str | None = None, unit: str | None = None) -> Any:
857
949
  """Get data from the member's aspire wearable.
858
950
 
859
951
  Note: I don't have an aspire wearable, so I can't test this.
@@ -72,8 +72,8 @@ class OtfClass(OtfItemBase, OtfClassTimeMixin):
72
72
  is_cancelled: bool = Field(alias="isCancelled")
73
73
  program_name: str | None = Field(None, alias="programName")
74
74
  coach_id: int | None = Field(None, alias="coachId")
75
- studio: Studio | None = None
76
- coach: Coach | None = None
75
+ studio: Studio
76
+ coach: Coach
77
77
  location: Location | None = None
78
78
  virtual_class: bool | None = Field(None, alias="virtualClass")
79
79
 
@@ -28,7 +28,7 @@ class TreadData(OtfItemBase):
28
28
  class RowData(OtfItemBase):
29
29
  row_speed: float = Field(..., alias="rowSpeed")
30
30
  row_pps: float = Field(..., alias="rowPps")
31
- row_Spm: float = Field(..., alias="rowSpm")
31
+ row_spm: float = Field(..., alias="rowSpm")
32
32
  agg_row_distance: int = Field(..., alias="aggRowDistance")
33
33
  row_pace: int = Field(..., alias="rowPace")
34
34
 
File without changes
File without changes
File without changes
File without changes
File without changes