otf-api 0.15.1__tar.gz → 0.15.2__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 (65) hide show
  1. {otf_api-0.15.1/src/otf_api.egg-info → otf_api-0.15.2}/PKG-INFO +2 -1
  2. {otf_api-0.15.1 → otf_api-0.15.2}/pyproject.toml +2 -1
  3. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/__init__.py +16 -5
  4. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/bookings/booking_api.py +49 -36
  5. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/client.py +19 -9
  6. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/members/member_api.py +2 -2
  7. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/studios/studio_api.py +47 -47
  8. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/workouts/workout_api.py +68 -6
  9. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/workouts/workout_client.py +25 -3
  10. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/workouts/performance_summary.py +10 -31
  11. {otf_api-0.15.1 → otf_api-0.15.2/src/otf_api.egg-info}/PKG-INFO +2 -1
  12. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api.egg-info/requires.txt +1 -0
  13. {otf_api-0.15.1 → otf_api-0.15.2}/LICENSE +0 -0
  14. {otf_api-0.15.1 → otf_api-0.15.2}/README.md +0 -0
  15. {otf_api-0.15.1 → otf_api-0.15.2}/setup.cfg +0 -0
  16. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/__init__.py +0 -0
  17. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/_compat.py +0 -0
  18. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/api.py +0 -0
  19. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/bookings/__init__.py +0 -0
  20. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/bookings/booking_client.py +0 -0
  21. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/members/__init__.py +0 -0
  22. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/members/member_client.py +0 -0
  23. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/studios/__init__.py +0 -0
  24. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/studios/studio_client.py +0 -0
  25. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/utils.py +0 -0
  26. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/workouts/__init__.py +0 -0
  27. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/auth/__init__.py +0 -0
  28. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/auth/auth.py +0 -0
  29. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/auth/user.py +0 -0
  30. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/auth/utils.py +0 -0
  31. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/cache.py +0 -0
  32. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/exceptions.py +0 -0
  33. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/__init__.py +0 -0
  34. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/base.py +0 -0
  35. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/bookings/__init__.py +0 -0
  36. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/bookings/bookings.py +0 -0
  37. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/bookings/bookings_v2.py +0 -0
  38. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/bookings/classes.py +0 -0
  39. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/bookings/enums.py +0 -0
  40. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/bookings/filters.py +0 -0
  41. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/bookings/ratings.py +0 -0
  42. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/members/__init__.py +0 -0
  43. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/members/member_detail.py +0 -0
  44. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/members/member_membership.py +0 -0
  45. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/members/member_purchases.py +0 -0
  46. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/members/notifications.py +0 -0
  47. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/mixins.py +0 -0
  48. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/studios/__init__.py +0 -0
  49. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/studios/enums.py +0 -0
  50. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/studios/studio_detail.py +0 -0
  51. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/studios/studio_services.py +0 -0
  52. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/workouts/__init__.py +0 -0
  53. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/workouts/body_composition_list.py +0 -0
  54. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/workouts/challenge_tracker_content.py +0 -0
  55. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/workouts/challenge_tracker_detail.py +0 -0
  56. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/workouts/enums.py +0 -0
  57. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/workouts/lifetime_stats.py +0 -0
  58. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/workouts/out_of_studio_workout_history.py +0 -0
  59. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/workouts/telemetry.py +0 -0
  60. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/workouts/workout.py +0 -0
  61. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/py.typed +0 -0
  62. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api.egg-info/SOURCES.txt +0 -0
  63. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api.egg-info/dependency_links.txt +0 -0
  64. {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api.egg-info/top_level.txt +0 -0
  65. {otf_api-0.15.1 → otf_api-0.15.2}/tests/test_filters.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: otf-api
3
- Version: 0.15.1
3
+ Version: 0.15.2
4
4
  Summary: Python OrangeTheory Fitness API Client
5
5
  Author-email: Jessica Smith <j.smith.git1@gmail.com>
6
6
  License-Expression: MIT
@@ -29,6 +29,7 @@ Requires-Dist: pendulum>=3.1.0
29
29
  Requires-Dist: diskcache>=5.6.3
30
30
  Requires-Dist: platformdirs>=4.3.6
31
31
  Requires-Dist: packaging>=24.2
32
+ Requires-Dist: coloredlogs>=15.0.1
32
33
  Dynamic: license-file
33
34
 
34
35
  Simple API client for interacting with the OrangeTheory Fitness APIs.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "otf-api"
3
- version = "0.15.1"
3
+ version = "0.15.2"
4
4
  description = "Python OrangeTheory Fitness API Client"
5
5
  authors = [{ name = "Jessica Smith", email = "j.smith.git1@gmail.com" }]
6
6
  requires-python = ">=3.11"
@@ -30,6 +30,7 @@ dependencies = [
30
30
  "diskcache>=5.6.3",
31
31
  "platformdirs>=4.3.6",
32
32
  "packaging>=24.2",
33
+ "coloredlogs>=15.0.1",
33
34
  ]
34
35
 
35
36
  [project.urls]
@@ -7,13 +7,15 @@ Use it at your own risk. It may break at any time if Orangetheory changes their
7
7
  import logging
8
8
  import os
9
9
 
10
+ import coloredlogs
11
+
10
12
  from otf_api import models
11
13
  from otf_api.api import Otf
12
14
  from otf_api.auth import OtfUser
13
15
 
14
16
  LOG_LEVEL = os.getenv("OTF_LOG_LEVEL", "INFO").upper()
15
- LOG_LEVEL_NUM = getattr(logging, LOG_LEVEL, logging.INFO)
16
- LOG_FMT = "{asctime} - {module}.{funcName}:{lineno} - {levelname} - {message}"
17
+
18
+ LOG_FMT = "%(asctime)s - %(module)s.%(funcName)s:%(lineno)d - %(levelname)s - %(message)s"
17
19
  DATE_FMT = "%Y-%m-%d %H:%M:%S%z"
18
20
 
19
21
 
@@ -24,19 +26,28 @@ def _setup_logging() -> None:
24
26
  return # Already set up
25
27
 
26
28
  # 2) Set the logger level to INFO (or whatever you need).
27
- logger.setLevel(LOG_LEVEL_NUM)
29
+ logger.setLevel(LOG_LEVEL)
28
30
 
29
31
  # 3) Create a handler (e.g., console) and set its formatter.
30
32
  handler = logging.StreamHandler()
31
- handler.setFormatter(logging.Formatter(fmt=LOG_FMT, datefmt=DATE_FMT, style="{"))
33
+ handler.setFormatter(logging.Formatter(fmt=LOG_FMT, datefmt=DATE_FMT, style="%"))
32
34
 
33
35
  # 4) Add this handler to your package logger.
34
36
  logger.addHandler(handler)
35
37
 
38
+ coloredlogs.install(
39
+ level=LOG_LEVEL,
40
+ logger=logger,
41
+ fmt=LOG_FMT,
42
+ datefmt=DATE_FMT,
43
+ style="%",
44
+ isatty=True, # Use colored output only if the output is a terminal
45
+ )
46
+
36
47
 
37
48
  _setup_logging()
38
49
 
39
- __version__ = "0.15.1"
50
+ __version__ = "0.15.2"
40
51
 
41
52
 
42
53
  __all__ = ["Otf", "OtfUser", "models"]
@@ -8,19 +8,19 @@ import pendulum
8
8
  from otf_api import exceptions as exc
9
9
  from otf_api import models
10
10
  from otf_api.api import utils
11
- from otf_api.api.client import OtfClient
12
11
  from otf_api.models.bookings import HISTORICAL_BOOKING_STATUSES, ClassFilter
13
12
 
14
13
  from .booking_client import BookingClient
15
14
 
16
15
  if typing.TYPE_CHECKING:
17
16
  from otf_api import Otf
17
+ from otf_api.api.client import OtfClient
18
18
 
19
19
  LOGGER = getLogger(__name__)
20
20
 
21
21
 
22
22
  class BookingApi:
23
- def __init__(self, otf: "Otf", otf_client: OtfClient):
23
+ def __init__(self, otf: "Otf", otf_client: "OtfClient"):
24
24
  """Initialize the Booking API client.
25
25
 
26
26
  Args:
@@ -30,6 +30,39 @@ class BookingApi:
30
30
  self.otf = otf
31
31
  self.client = BookingClient(otf_client)
32
32
 
33
+ def _get_all_bookings_new(
34
+ self, exclude_cancelled: bool = True, remove_duplicates: bool = True
35
+ ) -> list[models.BookingV2]:
36
+ """Get bookings from the new endpoint with no date filters.
37
+
38
+ This is marked as private to avoid random users calling it.
39
+ Useful for testing and validating models.
40
+
41
+ Args:
42
+ exclude_cancelled (bool): Whether to exclude cancelled bookings. Default is True.
43
+ remove_duplicates (bool): Whether to remove duplicate bookings. Default is True.
44
+
45
+ Returns:
46
+ list[BookingV2]: List of bookings that match the search criteria.
47
+ """
48
+ start_date = pendulum.datetime(1970, 1, 1)
49
+ end_date = pendulum.today().start_of("day").add(days=45)
50
+ return self.get_bookings_new(start_date, end_date, exclude_cancelled, remove_duplicates)
51
+
52
+ def _get_all_bookings_new_by_date(self) -> dict[datetime, models.BookingV2]:
53
+ """Get all bookings from the new endpoint by date.
54
+
55
+ This is marked as private to avoid random users calling it.
56
+ Useful for testing and validating models.
57
+
58
+ Returns:
59
+ dict[datetime, BookingV2]: Dictionary of bookings by date.
60
+ """
61
+ start_date = pendulum.datetime(1970, 1, 1)
62
+ end_date = pendulum.today().start_of("day").add(days=45)
63
+ bookings = self.get_bookings_new_by_date(start_date, end_date)
64
+ return bookings
65
+
33
66
  def get_bookings_new(
34
67
  self,
35
68
  start_date: datetime | date | str | None = None,
@@ -79,6 +112,7 @@ class BookingApi:
79
112
  bookings_resp = self.client.get_bookings_new(
80
113
  ends_before=end_date, starts_after=start_date, include_canceled=include_canceled, expand=expand
81
114
  )
115
+ LOGGER.debug("Found %d bookings between %s and %s", len(bookings_resp), start_date, end_date)
82
116
 
83
117
  # filter out bookings with ids that start with "no-booking-id"
84
118
  # no idea what these are, but I am praying for the poor sap stuck with maintaining OTF's data model
@@ -89,7 +123,7 @@ class BookingApi:
89
123
  try:
90
124
  results.append(models.BookingV2.create(**b, api=self.otf))
91
125
  except ValueError as e:
92
- LOGGER.warning(f"Failed to create BookingV2 from response: {e}. Booking data:\n{b}")
126
+ LOGGER.error("Failed to create BookingV2 from response: %s. Booking data:\n%s", e, b)
93
127
  continue
94
128
 
95
129
  if not remove_duplicates:
@@ -112,6 +146,9 @@ class BookingApi:
112
146
  list[BookingV2]: The deduplicated list of bookings.
113
147
  """
114
148
  # remove duplicates by class_id, keeping the one with the most recent updated_at timestamp
149
+
150
+ orig_count = len(results)
151
+
115
152
  seen_classes: dict[str, models.BookingV2] = {}
116
153
 
117
154
  for booking in results:
@@ -127,11 +164,20 @@ class BookingApi:
127
164
  "this is unexpected behavior."
128
165
  )
129
166
  if booking.updated_at > existing_booking.updated_at:
167
+ LOGGER.debug(
168
+ "Replacing existing booking for class_id %s with more recent booking %s", class_id, booking
169
+ )
130
170
  seen_classes[class_id] = booking
131
171
 
132
172
  results = list(seen_classes.values())
133
173
  results = sorted(results, key=lambda x: x.starts_at)
134
174
 
175
+ new_count = len(results)
176
+ diff = orig_count - new_count
177
+
178
+ if diff:
179
+ LOGGER.debug("Removed %d duplicate bookings, returning %d unique bookings", diff, new_count)
180
+
135
181
  return results
136
182
 
137
183
  def get_bookings_new_by_date(
@@ -615,36 +661,3 @@ class BookingApi:
615
661
  if e.response.status_code == 403:
616
662
  raise exc.AlreadyRatedError(f"Workout {performance_summary_id} is already rated.") from None
617
663
  raise
618
-
619
- def _get_all_bookings_new(
620
- self, exclude_cancelled: bool = True, remove_duplicates: bool = True
621
- ) -> list[models.BookingV2]:
622
- """Get bookings from the new endpoint with no date filters.
623
-
624
- This is marked as private to avoid random users calling it.
625
- Useful for testing and validating models.
626
-
627
- Args:
628
- exclude_cancelled (bool): Whether to exclude cancelled bookings. Default is True.
629
- remove_duplicates (bool): Whether to remove duplicate bookings. Default is True.
630
-
631
- Returns:
632
- list[BookingV2]: List of bookings that match the search criteria.
633
- """
634
- start_date = pendulum.datetime(1970, 1, 1)
635
- end_date = pendulum.today().start_of("day").add(days=45)
636
- return self.get_bookings_new(start_date, end_date, exclude_cancelled, remove_duplicates)
637
-
638
- def _get_all_bookings_new_by_date(self) -> dict[datetime, models.BookingV2]:
639
- """Get all bookings from the new endpoint by date.
640
-
641
- This is marked as private to avoid random users calling it.
642
- Useful for testing and validating models.
643
-
644
- Returns:
645
- dict[datetime, BookingV2]: Dictionary of bookings by date.
646
- """
647
- start_date = pendulum.datetime(1970, 1, 1)
648
- end_date = pendulum.today().start_of("day").add(days=45)
649
- bookings = self.get_bookings_new_by_date(start_date, end_date)
650
- return bookings
@@ -1,4 +1,6 @@
1
1
  import atexit
2
+ import json
3
+ import os
2
4
  import re
3
5
  from json import JSONDecodeError
4
6
  from logging import getLogger
@@ -47,6 +49,7 @@ class OtfClient:
47
49
  self.session = httpx.Client(
48
50
  headers=HEADERS, auth=self.user.httpx_auth, timeout=httpx.Timeout(20.0, connect=60.0)
49
51
  )
52
+ self.log_raw_response = os.getenv("OTF_LOG_RAW_RESPONSE", "false").lower() == "true"
50
53
  atexit.register(self.session.close)
51
54
 
52
55
  def __getstate__(self):
@@ -110,7 +113,7 @@ class OtfClient:
110
113
  """
111
114
  full_url = str(URL.build(scheme="https", host=base_url, path=path))
112
115
  request = self._build_request(method, full_url, params, headers, **kwargs)
113
- LOGGER.debug(f"Making {method!r} request to '{full_url}', params: {params}, headers: {headers}")
116
+ LOGGER.debug("Making %r request to '%s'", method, str(request.url))
114
117
 
115
118
  try:
116
119
  response = self.session.send(request)
@@ -158,10 +161,14 @@ class OtfClient:
158
161
  if error_code == "602":
159
162
  raise exc.OutsideSchedulingWindowError("Class is outside scheduling window")
160
163
 
161
- msg = f"HTTP error {error.response.status_code} for {request.method} {request.url}"
162
- LOGGER.error(msg)
164
+ LOGGER.error("HTTP error %s for %s %s", response.status_code, request.method, request.url)
163
165
  error_cls = exc.RetryableOtfRequestError if response.status_code >= 500 else exc.OtfRequestError
164
- raise error_cls(message=msg, original_exception=error, request=request, response=response)
166
+ raise error_cls(
167
+ message=f"HTTP error {response.status_code} for {request.method} {request.url}",
168
+ original_exception=error,
169
+ request=request,
170
+ response=response,
171
+ )
165
172
 
166
173
  def _handle_transport_error(self, error: Exception, request: httpx.Request) -> None:
167
174
  """Handle transport errors during API requests.
@@ -177,7 +184,7 @@ class OtfClient:
177
184
  url = request.url
178
185
 
179
186
  if not isinstance(error, httpx.HTTPStatusError):
180
- LOGGER.exception(f"Unexpected error during {method!r} {url!r}: {type(error).__name__} - {error}")
187
+ LOGGER.exception("Unexpected error during %r %r: %s - %s", method, url, type(error).__name__, error)
181
188
  return
182
189
 
183
190
  json_data = get_json_from_response(error.response)
@@ -190,7 +197,7 @@ class OtfClient:
190
197
  data_status: int | None = data.get("Status") or data.get("status") or None
191
198
 
192
199
  if isinstance(data, dict) and isinstance(data_status, int) and not 200 <= data_status <= 299:
193
- LOGGER.error(f"API returned error: {data}")
200
+ LOGGER.error("API returned error: %s", data)
194
201
  raise exc.OtfRequestError("Bad API response", None, response=response, request=request)
195
202
 
196
203
  raise exc.OtfRequestError(
@@ -202,17 +209,20 @@ class OtfClient:
202
209
  if method == "GET":
203
210
  raise exc.OtfRequestError("Empty response", None, response=response, request=request)
204
211
 
205
- LOGGER.debug(f"No content returned from {method} {response.url}")
212
+ LOGGER.debug("No content returned from %s %s", method, response.url)
206
213
  return None
207
214
 
208
215
  try:
209
216
  json_data = response.json()
210
217
  except JSONDecodeError as e:
211
- LOGGER.error(f"Invalid JSON: {e}")
212
- LOGGER.error(f"Response content: {response.text}")
218
+ LOGGER.error("Invalid JSON: %s", e)
219
+ LOGGER.error("Response content: %s", response.text)
213
220
  raise
214
221
 
215
222
  if is_error_response(json_data):
216
223
  self._map_logical_error(json_data, response, request)
217
224
 
225
+ if self.log_raw_response:
226
+ LOGGER.debug("Response from %s %s: %s", method, response.url, json.dumps(json_data, indent=4))
227
+
218
228
  return json_data
@@ -3,18 +3,18 @@ from logging import getLogger
3
3
  from typing import Any
4
4
 
5
5
  from otf_api import models
6
- from otf_api.api.client import OtfClient
7
6
 
8
7
  from .member_client import MemberClient
9
8
 
10
9
  if typing.TYPE_CHECKING:
11
10
  from otf_api import Otf
11
+ from otf_api.api.client import OtfClient
12
12
 
13
13
  LOGGER = getLogger(__name__)
14
14
 
15
15
 
16
16
  class MemberApi:
17
- def __init__(self, otf: "Otf", otf_client: OtfClient):
17
+ def __init__(self, otf: "Otf", otf_client: "OtfClient"):
18
18
  """Initialize the Member API client.
19
19
 
20
20
  Args:
@@ -4,18 +4,18 @@ from logging import getLogger
4
4
  from otf_api import exceptions as exc
5
5
  from otf_api import models
6
6
  from otf_api.api import utils
7
- from otf_api.api.client import OtfClient
8
7
 
9
8
  from .studio_client import StudioClient
10
9
 
11
10
  if typing.TYPE_CHECKING:
12
11
  from otf_api import Otf
12
+ from otf_api.api.client import OtfClient
13
13
 
14
14
  LOGGER = getLogger(__name__)
15
15
 
16
16
 
17
17
  class StudioApi:
18
- def __init__(self, otf: "Otf", otf_client: OtfClient):
18
+ def __init__(self, otf: "Otf", otf_client: "OtfClient"):
19
19
  """Initialize the Studio API client.
20
20
 
21
21
  Args:
@@ -25,6 +25,51 @@ class StudioApi:
25
25
  self.otf = otf
26
26
  self.client = StudioClient(otf_client)
27
27
 
28
+ def _get_all_studios(self) -> list[models.StudioDetail]:
29
+ """Gets all studios. Marked as private to avoid random users calling it.
30
+
31
+ Useful for testing and validating models.
32
+
33
+ Returns:
34
+ list[StudioDetail]: List of studios that match the search criteria.
35
+ """
36
+ # long/lat being None will cause the endpoint to return all studios
37
+ results = self.client.get_studios_by_geo(None, None)
38
+
39
+ studios: list[models.StudioDetail] = []
40
+ for studio in results:
41
+ try:
42
+ studios.append(models.StudioDetail.create(**studio, api=self.otf))
43
+ except ValueError as e:
44
+ LOGGER.error(f"Failed to create StudioDetail for studio {studio}: {e}")
45
+ continue
46
+
47
+ return studios
48
+
49
+ def _get_studio_detail_threaded(self, studio_uuids: list[str]) -> dict[str, models.StudioDetail]:
50
+ """Get detailed information about multiple studios in a threaded manner.
51
+
52
+ This is used to improve performance when fetching details for multiple studios at once.
53
+ This method is on the Otf class because StudioDetail is a model that requires the API instance.
54
+
55
+ Args:
56
+ studio_uuids (list[str]): List of studio UUIDs to get details for.
57
+
58
+ Returns:
59
+ dict[str, StudioDetail]: A dictionary mapping studio UUIDs to their detailed information.
60
+ """
61
+ studio_dicts = self.client.get_studio_detail_threaded(studio_uuids)
62
+
63
+ studios: dict[str, models.StudioDetail] = {}
64
+ for studio_uuid, studio in studio_dicts.items():
65
+ try:
66
+ studios[studio_uuid] = models.StudioDetail.create(**studio, api=self.otf)
67
+ except ValueError as e:
68
+ LOGGER.error(f"Failed to create StudioDetail for studio {studio_uuid}: {e}")
69
+ continue
70
+
71
+ return studios
72
+
28
73
  def get_favorite_studios(self) -> list[models.StudioDetail]:
29
74
  """Get the member's favorite studios.
30
75
 
@@ -159,48 +204,3 @@ class StudioApi:
159
204
  continue
160
205
 
161
206
  return studios
162
-
163
- def _get_all_studios(self) -> list[models.StudioDetail]:
164
- """Gets all studios. Marked as private to avoid random users calling it.
165
-
166
- Useful for testing and validating models.
167
-
168
- Returns:
169
- list[StudioDetail]: List of studios that match the search criteria.
170
- """
171
- # long/lat being None will cause the endpoint to return all studios
172
- results = self.client.get_studios_by_geo(None, None)
173
-
174
- studios: list[models.StudioDetail] = []
175
- for studio in results:
176
- try:
177
- studios.append(models.StudioDetail.create(**studio, api=self.otf))
178
- except ValueError as e:
179
- LOGGER.error(f"Failed to create StudioDetail for studio {studio}: {e}")
180
- continue
181
-
182
- return studios
183
-
184
- def _get_studio_detail_threaded(self, studio_uuids: list[str]) -> dict[str, models.StudioDetail]:
185
- """Get detailed information about multiple studios in a threaded manner.
186
-
187
- This is used to improve performance when fetching details for multiple studios at once.
188
- This method is on the Otf class because StudioDetail is a model that requires the API instance.
189
-
190
- Args:
191
- studio_uuids (list[str]): List of studio UUIDs to get details for.
192
-
193
- Returns:
194
- dict[str, StudioDetail]: A dictionary mapping studio UUIDs to their detailed information.
195
- """
196
- studio_dicts = self.client.get_studio_detail_threaded(studio_uuids)
197
-
198
- studios: dict[str, models.StudioDetail] = {}
199
- for studio_uuid, studio in studio_dicts.items():
200
- try:
201
- studios[studio_uuid] = models.StudioDetail.create(**studio, api=self.otf)
202
- except ValueError as e:
203
- LOGGER.error(f"Failed to create StudioDetail for studio {studio_uuid}: {e}")
204
- continue
205
-
206
- return studios
@@ -9,18 +9,18 @@ import pendulum
9
9
  from otf_api import exceptions as exc
10
10
  from otf_api import models
11
11
  from otf_api.api import utils
12
- from otf_api.api.client import OtfClient
13
12
 
14
13
  from .workout_client import WorkoutClient
15
14
 
16
15
  if typing.TYPE_CHECKING:
17
16
  from otf_api import Otf
17
+ from otf_api.api.client import OtfClient
18
18
 
19
19
  LOGGER = getLogger(__name__)
20
20
 
21
21
 
22
22
  class WorkoutApi:
23
- def __init__(self, otf: "Otf", otf_client: OtfClient):
23
+ def __init__(self, otf: "Otf", otf_client: "OtfClient"):
24
24
  """Initialize the Workout API client.
25
25
 
26
26
  Args:
@@ -262,7 +262,7 @@ class WorkoutApi:
262
262
  bookings = self.otf.bookings.get_bookings_new(
263
263
  start_dtme, end_dtme, exclude_cancelled=True, remove_duplicates=True
264
264
  )
265
- bookings_dict = {b.workout.id: b for b in bookings if b.workout}
265
+ bookings_dict = self._filter_bookings_for_workouts(bookings)
266
266
 
267
267
  perf_summaries_dict = self.client.get_perf_summaries_threaded(list(bookings_dict.keys()))
268
268
  telemetry_dict = self.client.get_telemetry_threaded(list(perf_summaries_dict.keys()), max_data_points)
@@ -279,12 +279,74 @@ class WorkoutApi:
279
279
  api=self.otf,
280
280
  )
281
281
  workouts.append(workout)
282
- except ValueError as e:
283
- LOGGER.error(f"Failed to create Workout for performance summary {perf_id}: {e}")
284
- continue
282
+ except ValueError:
283
+ LOGGER.exception("Failed to create Workout for performance summary %s", perf_id)
284
+
285
+ LOGGER.debug("Returning %d workouts", len(workouts))
285
286
 
286
287
  return workouts
287
288
 
289
+ def _filter_bookings_for_workouts(self, bookings: list[models.BookingV2]) -> dict[str, models.BookingV2]:
290
+ """Filter bookings to only those that have a workout and are not in the future.
291
+
292
+ This is being pulled out of `get_workouts` to add more robust logging and error handling.
293
+
294
+ Args:
295
+ bookings (list[BookingV2]): The list of bookings to filter.
296
+
297
+ Returns:
298
+ dict[str, BookingV2]: A dictionary mapping workout IDs to bookings that have workouts.
299
+ """
300
+ future_bookings = [b for b in bookings if b.starts_at and b.starts_at > pendulum.now().naive()]
301
+ missing_workouts = [b for b in bookings if not b.workout and b not in future_bookings]
302
+ LOGGER.debug("Found %d future bookings and %d missing workouts", len(future_bookings), len(missing_workouts))
303
+
304
+ if future_bookings:
305
+ for booking in future_bookings:
306
+ LOGGER.warning(
307
+ "Booking %s for class '%s' (class_uuid=%s) is in the future, filtering out.",
308
+ booking.booking_id,
309
+ booking.otf_class,
310
+ booking.class_uuid or "Unknown",
311
+ )
312
+
313
+ if missing_workouts:
314
+ for booking in missing_workouts:
315
+ LOGGER.warning(
316
+ "Booking %s for class '%s' (class_uuid=%s) is missing a workout, filtering out.",
317
+ booking.booking_id,
318
+ booking.otf_class,
319
+ booking.class_uuid or "Unknown",
320
+ )
321
+
322
+ bookings_dict = {
323
+ b.workout.id: b for b in bookings if b.workout and b not in future_bookings and b not in missing_workouts
324
+ }
325
+
326
+ LOGGER.debug("Filtered bookings to %d valid bookings for workouts mapping", len(bookings_dict))
327
+
328
+ return bookings_dict
329
+
330
+ def get_lifetime_workouts(self) -> list[models.Workout]:
331
+ """Get the member's lifetime workouts.
332
+
333
+ This is a convenience method that calls `get_workouts` with no date range.
334
+
335
+ Returns:
336
+ list[Workout]: The member's lifetime workouts.
337
+
338
+ Raises:
339
+ ResourceNotFoundError: If the member's created date is not set, as we cannot determine the start date for
340
+ the workouts.
341
+ """
342
+ if not self.otf.member.created_date:
343
+ raise exc.ResourceNotFoundError("Member created date not found, cannot get lifetime workouts.")
344
+
345
+ start_date = self.otf.member.created_date.date()
346
+ end_date = pendulum.tomorrow().date()
347
+
348
+ return self.get_workouts(start_date=start_date, end_date=end_date)
349
+
288
350
  def rate_class_from_workout(
289
351
  self,
290
352
  workout: models.Workout,
@@ -1,9 +1,12 @@
1
1
  from concurrent.futures import ThreadPoolExecutor
2
2
  from functools import partial
3
+ from logging import getLogger
3
4
  from typing import Any
4
5
 
5
6
  from otf_api.api.client import API_IO_BASE_URL, API_TELEMETRY_BASE_URL, CACHE, OtfClient
6
7
 
8
+ LOGGER = getLogger(__name__)
9
+
7
10
 
8
11
  class WorkoutClient:
9
12
  """Client for retrieving workout and performance data from the OTF API.
@@ -98,7 +101,12 @@ class WorkoutClient:
98
101
  dict[str, str | None]: A dictionary mapping performance summary IDs to class UUIDs.
99
102
  """
100
103
  perf_summaries = self.get_performance_summaries()["items"]
101
- return {item["id"]: item["class"].get("ot_base_class_uuid") for item in perf_summaries}
104
+ LOGGER.debug("Retrieved %d performance summaries for mapping", len(perf_summaries))
105
+
106
+ perf_summary_dict = {item["id"]: item["class"].get("ot_base_class_uuid") for item in perf_summaries}
107
+
108
+ LOGGER.debug("Created performance summary to class UUID mapping with %d entries", len(perf_summary_dict))
109
+ return perf_summary_dict
102
110
 
103
111
  def get_perf_summaries_threaded(self, performance_summary_ids: list[str]) -> dict[str, dict[str, Any]]:
104
112
  """Get performance summaries in a ThreadPoolExecutor, to speed up the process.
@@ -112,7 +120,13 @@ class WorkoutClient:
112
120
  with ThreadPoolExecutor(max_workers=10) as pool:
113
121
  perf_summaries = pool.map(self.get_performance_summary, performance_summary_ids)
114
122
 
115
- perf_summaries_dict = {perf_summary["id"]: perf_summary for perf_summary in perf_summaries}
123
+ perf_summaries_list = list(perf_summaries)
124
+ LOGGER.debug("Retrieved %d performance summaries in threaded mode", len(perf_summaries_list))
125
+
126
+ perf_summaries_dict = {perf_summary["id"]: perf_summary for perf_summary in perf_summaries_list}
127
+
128
+ LOGGER.debug("Returning %d performance summaries", len(perf_summaries_dict))
129
+
116
130
  return perf_summaries_dict
117
131
 
118
132
  def get_telemetry_threaded(
@@ -130,7 +144,15 @@ class WorkoutClient:
130
144
  partial_fn = partial(self.get_telemetry, max_data_points=max_data_points)
131
145
  with ThreadPoolExecutor(max_workers=10) as pool:
132
146
  telemetry = pool.map(partial_fn, performance_summary_ids)
133
- telemetry_dict = {perf_summary["classHistoryUuid"]: perf_summary for perf_summary in telemetry}
147
+
148
+ telemetry_list = list(telemetry)
149
+
150
+ LOGGER.debug("Retrieved %d telemetry records in threaded mode", len(telemetry_list))
151
+
152
+ telemetry_dict = {perf_summary["classHistoryUuid"]: perf_summary for perf_summary in telemetry_list}
153
+
154
+ LOGGER.debug("Returning %d telemetry records", len(telemetry_dict))
155
+
134
156
  return telemetry_dict
135
157
 
136
158
  def get_aspire_data(self, datetime: str | None, unit: str | None) -> dict:
@@ -1,9 +1,12 @@
1
- from datetime import time
1
+ from logging import getLogger
2
+ from typing import Any
2
3
 
3
- from pydantic import AliasPath, Field, field_validator
4
+ from pydantic import AliasPath, Field
4
5
 
5
6
  from otf_api.models.base import OtfItemBase
6
7
 
8
+ LOGGER = getLogger(__name__)
9
+
7
10
 
8
11
  class ZoneTimeMinutes(OtfItemBase):
9
12
  gray: int
@@ -22,41 +25,17 @@ class HeartRate(OtfItemBase):
22
25
 
23
26
 
24
27
  class PerformanceMetric(OtfItemBase):
25
- display_value: time | float | None
28
+ display_value: Any
26
29
  display_unit: str
27
- metric_value: float
30
+ metric_value: float | int = Field(
31
+ coerce_numbers_to_str=True,
32
+ description="The raw value of the metric, as a float or int. When time this reflects seconds.",
33
+ )
28
34
 
29
35
  def __str__(self) -> str:
30
36
  """Return a string representation of the PerformanceMetric."""
31
37
  return f"{self.display_value} {self.display_unit}"
32
38
 
33
- @field_validator("display_value", mode="before")
34
- @classmethod
35
- def convert_to_time_format(cls, value: str | None | float | int) -> time | float | None:
36
- """Convert display_value to a time object if it is in the format of HH:MM:SS or MM:SS.
37
-
38
- Args:
39
- value (str | None | float | int): The value to convert.
40
-
41
- Returns:
42
- time | float: The converted value, or the original value if it is not in the expected format.
43
- """
44
- if not value:
45
- return None
46
-
47
- if isinstance(value, float | int):
48
- return value
49
-
50
- if isinstance(value, str) and ":" in value:
51
- if value.count(":") == 1:
52
- minutes, seconds = value.split(":")
53
- return time(minute=int(minutes), second=int(seconds))
54
- if value.count(":") == 2:
55
- hours, minutes, seconds = value.split(":")
56
- return time(hour=int(hours), minute=int(minutes), second=int(seconds))
57
-
58
- return value # type: ignore
59
-
60
39
 
61
40
  class BaseEquipment(OtfItemBase):
62
41
  avg_pace: PerformanceMetric
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: otf-api
3
- Version: 0.15.1
3
+ Version: 0.15.2
4
4
  Summary: Python OrangeTheory Fitness API Client
5
5
  Author-email: Jessica Smith <j.smith.git1@gmail.com>
6
6
  License-Expression: MIT
@@ -29,6 +29,7 @@ Requires-Dist: pendulum>=3.1.0
29
29
  Requires-Dist: diskcache>=5.6.3
30
30
  Requires-Dist: platformdirs>=4.3.6
31
31
  Requires-Dist: packaging>=24.2
32
+ Requires-Dist: coloredlogs>=15.0.1
32
33
  Dynamic: license-file
33
34
 
34
35
  Simple API client for interacting with the OrangeTheory Fitness APIs.
@@ -12,3 +12,4 @@ pendulum>=3.1.0
12
12
  diskcache>=5.6.3
13
13
  platformdirs>=4.3.6
14
14
  packaging>=24.2
15
+ coloredlogs>=15.0.1
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes