otf-api 0.12.0__py3-none-any.whl → 0.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. otf_api/__init__.py +35 -3
  2. otf_api/api/__init__.py +3 -0
  3. otf_api/api/_compat.py +77 -0
  4. otf_api/api/api.py +80 -0
  5. otf_api/api/bookings/__init__.py +3 -0
  6. otf_api/api/bookings/booking_api.py +541 -0
  7. otf_api/api/bookings/booking_client.py +112 -0
  8. otf_api/api/client.py +203 -0
  9. otf_api/api/members/__init__.py +3 -0
  10. otf_api/api/members/member_api.py +187 -0
  11. otf_api/api/members/member_client.py +112 -0
  12. otf_api/api/studios/__init__.py +3 -0
  13. otf_api/api/studios/studio_api.py +173 -0
  14. otf_api/api/studios/studio_client.py +120 -0
  15. otf_api/api/utils.py +307 -0
  16. otf_api/api/workouts/__init__.py +3 -0
  17. otf_api/api/workouts/workout_api.py +333 -0
  18. otf_api/api/workouts/workout_client.py +140 -0
  19. otf_api/auth/__init__.py +1 -1
  20. otf_api/auth/auth.py +155 -89
  21. otf_api/auth/user.py +5 -17
  22. otf_api/auth/utils.py +27 -2
  23. otf_api/cache.py +132 -0
  24. otf_api/exceptions.py +18 -6
  25. otf_api/models/__init__.py +25 -21
  26. otf_api/models/bookings/__init__.py +23 -0
  27. otf_api/models/bookings/bookings.py +134 -0
  28. otf_api/models/{bookings_v2.py → bookings/bookings_v2.py} +72 -31
  29. otf_api/models/bookings/classes.py +124 -0
  30. otf_api/models/{enums.py → bookings/enums.py} +7 -81
  31. otf_api/{filters.py → models/bookings/filters.py} +39 -11
  32. otf_api/models/{ratings.py → bookings/ratings.py} +2 -6
  33. otf_api/models/members/__init__.py +5 -0
  34. otf_api/models/members/member_detail.py +149 -0
  35. otf_api/models/members/member_membership.py +26 -0
  36. otf_api/models/members/member_purchases.py +29 -0
  37. otf_api/models/members/notifications.py +17 -0
  38. otf_api/models/mixins.py +48 -1
  39. otf_api/models/studios/__init__.py +5 -0
  40. otf_api/models/studios/enums.py +11 -0
  41. otf_api/models/studios/studio_detail.py +93 -0
  42. otf_api/models/studios/studio_services.py +36 -0
  43. otf_api/models/workouts/__init__.py +31 -0
  44. otf_api/models/{body_composition_list.py → workouts/body_composition_list.py} +140 -71
  45. otf_api/models/workouts/challenge_tracker_content.py +50 -0
  46. otf_api/models/workouts/challenge_tracker_detail.py +99 -0
  47. otf_api/models/workouts/enums.py +70 -0
  48. otf_api/models/workouts/lifetime_stats.py +96 -0
  49. otf_api/models/workouts/out_of_studio_workout_history.py +32 -0
  50. otf_api/models/{performance_summary.py → workouts/performance_summary.py} +19 -5
  51. otf_api/models/workouts/telemetry.py +88 -0
  52. otf_api/models/{workout.py → workouts/workout.py} +34 -20
  53. {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/METADATA +4 -2
  54. otf_api-0.13.0.dist-info/RECORD +59 -0
  55. {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/WHEEL +1 -1
  56. otf_api/api.py +0 -1682
  57. otf_api/logging.py +0 -19
  58. otf_api/models/bookings.py +0 -109
  59. otf_api/models/challenge_tracker_content.py +0 -59
  60. otf_api/models/challenge_tracker_detail.py +0 -88
  61. otf_api/models/classes.py +0 -70
  62. otf_api/models/lifetime_stats.py +0 -78
  63. otf_api/models/member_detail.py +0 -121
  64. otf_api/models/member_membership.py +0 -26
  65. otf_api/models/member_purchases.py +0 -29
  66. otf_api/models/notifications.py +0 -17
  67. otf_api/models/out_of_studio_workout_history.py +0 -32
  68. otf_api/models/studio_detail.py +0 -71
  69. otf_api/models/studio_services.py +0 -36
  70. otf_api/models/telemetry.py +0 -84
  71. otf_api/utils.py +0 -164
  72. otf_api-0.12.0.dist-info/RECORD +0 -38
  73. {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/licenses/LICENSE +0 -0
  74. {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,120 @@
1
+ from concurrent.futures import ThreadPoolExecutor
2
+ from typing import Any
3
+
4
+ from otf_api.api.client import CACHE, LOGGER, OtfClient
5
+
6
+
7
+ class StudioClient:
8
+ """Client for retrieving studio and service data from the OTF API.
9
+
10
+ This class provides methods to search for studios by geographic location, retrieve studio details,
11
+ manage favorite studios, and get studio services.
12
+ """
13
+
14
+ def __init__(self, client: OtfClient):
15
+ self.client = client
16
+ self.member_uuid = client.member_uuid
17
+
18
+ @CACHE.memoize(expire=600, tag="studio_detail", ignore=(0,))
19
+ def get_studio_detail(self, studio_uuid: str) -> dict:
20
+ """Retrieve raw studio details."""
21
+ return self.client.default_request("GET", f"/mobile/v1/studios/{studio_uuid}")["data"]
22
+
23
+ def _get_studios_by_geo(
24
+ self, latitude: float | None, longitude: float | None, distance: int, page_index: int, page_size: int
25
+ ) -> dict:
26
+ """Retrieve raw studios by geo data."""
27
+ return self.client.default_request(
28
+ "GET",
29
+ "/mobile/v1/studios",
30
+ params={
31
+ "latitude": latitude,
32
+ "longitude": longitude,
33
+ "distance": distance,
34
+ "pageIndex": page_index,
35
+ "pageSize": page_size,
36
+ },
37
+ )
38
+
39
+ def get_studios_by_geo(
40
+ self, latitude: float | None, longitude: float | None, distance: int = 50
41
+ ) -> list[dict[str, Any]]:
42
+ """Searches for studios by geographic location.
43
+
44
+ Args:
45
+ latitude (float | None): Latitude of the location.
46
+ longitude (float | None): Longitude of the location.
47
+ distance (int): The distance in miles to search around the location. Default is 50.
48
+
49
+ Returns:
50
+ list[dict[str, Any]]: A list of studios within the specified distance from the given latitude and longitude.
51
+
52
+ Raises:
53
+ exc.OtfRequestError: If the request to the API fails.
54
+ """
55
+ distance = min(distance, 250) # max distance is 250 miles
56
+ page_size = 100
57
+ page_index = 1
58
+ LOGGER.debug(
59
+ "Starting studio search",
60
+ extra={
61
+ "latitude": latitude,
62
+ "longitude": longitude,
63
+ "distance": distance,
64
+ "page_index": page_index,
65
+ "page_size": page_size,
66
+ },
67
+ )
68
+
69
+ all_results: dict[str, dict[str, Any]] = {}
70
+
71
+ while True:
72
+ res = self._get_studios_by_geo(latitude, longitude, distance, page_index, page_size)
73
+
74
+ studios = res["data"].get("studios", [])
75
+ total_count = res["data"].get("pagination", {}).get("totalCount", 0)
76
+
77
+ all_results.update({studio["studioUUId"]: studio for studio in studios})
78
+ if len(all_results) >= total_count or not studios:
79
+ break
80
+
81
+ page_index += 1
82
+
83
+ LOGGER.info("Studio search completed, fetched %d of %d studios", len(all_results), total_count, stacklevel=2)
84
+
85
+ return list(all_results.values())
86
+
87
+ def get_favorite_studios(self) -> dict:
88
+ """Retrieve raw favorite studios data."""
89
+ return self.client.default_request("GET", f"/member/members/{self.member_uuid}/favorite-studios")["data"]
90
+
91
+ def get_studio_services(self, studio_uuid: str) -> dict:
92
+ """Retrieve raw studio services data."""
93
+ return self.client.default_request("GET", f"/member/studios/{studio_uuid}/services")["data"]
94
+
95
+ def post_favorite_studio(self, studio_uuids: list[str]) -> dict:
96
+ """Retrieve raw response from adding a studio to favorite studios."""
97
+ return self.client.default_request(
98
+ "POST", "/mobile/v1/members/favorite-studios", json={"studioUUIds": studio_uuids}
99
+ )["data"]
100
+
101
+ def delete_favorite_studio(self, studio_uuids: list[str]) -> dict:
102
+ """Retrieve raw response from removing a studio from favorite studios."""
103
+ return self.client.default_request(
104
+ "DELETE", "/mobile/v1/members/favorite-studios", json={"studioUUIds": studio_uuids}
105
+ )
106
+
107
+ def get_studio_detail_threaded(self, studio_uuids: list[str]) -> dict[str, dict[str, Any]]:
108
+ """Get studio details in a ThreadPoolExecutor, to speed up the process.
109
+
110
+ Args:
111
+ studio_uuids (list[str]): The studio UUIDs to get.
112
+
113
+ Returns:
114
+ dict[str, dict[str, Any]]: A dictionary of studio details, keyed by studio UUID.
115
+ """
116
+ with ThreadPoolExecutor(max_workers=10) as pool:
117
+ studios = pool.map(self.get_studio_detail, studio_uuids)
118
+
119
+ studios_dict = {studio["studioUUId"]: studio for studio in studios}
120
+ return studios_dict
otf_api/api/utils.py ADDED
@@ -0,0 +1,307 @@
1
+ import typing
2
+ from datetime import date, datetime, time, timedelta
3
+ from json import JSONDecodeError
4
+ from logging import getLogger
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from otf_api import exceptions as exc
10
+
11
+ if typing.TYPE_CHECKING:
12
+ from otf_api.models.bookings import Booking, BookingV2, BookingV2Class, ClassFilter, OtfClass
13
+
14
+ LOGGER = getLogger(__name__)
15
+
16
+ MIN_TIME = datetime.min.time()
17
+
18
+
19
+ def get_studio_uuid_list(
20
+ home_studio_uuid: str, studio_uuids: list[str] | str | None, include_home_studio: bool = True
21
+ ) -> list[str]:
22
+ """Get a list of studio UUIDs to request classes for.
23
+
24
+ If `studio_uuids` is None or empty, it will return a list containing only the home studio UUID.
25
+ If `studio_uuids` is a string, it will be converted to a list.
26
+ If `studio_uuids` is a list, it will be ensured that it contains unique values.
27
+
28
+ Args:
29
+ home_studio_uuid (str): The UUID of the home studio.
30
+ studio_uuids (list[str] | str | None): A list of studio UUIDs or a single UUID string.
31
+ include_home_studio (bool): Whether to include the home studio UUID in the list. Defaults to True.
32
+
33
+ Returns:
34
+ list[str]: A list of unique studio UUIDs to request classes for.
35
+ """
36
+ studio_uuids = ensure_list(studio_uuids) or [home_studio_uuid]
37
+ studio_uuids = list(set(studio_uuids)) # remove duplicates
38
+
39
+ if len(studio_uuids) > 50:
40
+ LOGGER.warning("Cannot request classes for more than 50 studios at a time.")
41
+ studio_uuids = studio_uuids[:50]
42
+
43
+ if include_home_studio and home_studio_uuid not in studio_uuids:
44
+ if len(studio_uuids) == 50:
45
+ LOGGER.warning("Cannot include home studio, request already includes 50 studios.")
46
+ else:
47
+ studio_uuids.append(home_studio_uuid)
48
+
49
+ return studio_uuids
50
+
51
+
52
+ def check_for_booking_conflicts(bookings: list["Booking"], otf_class: "OtfClass") -> None:
53
+ """Check for booking conflicts with the provided class.
54
+
55
+ Checks the member's bookings to see if the provided class overlaps with any existing bookings. If a conflict is
56
+ found, a ConflictingBookingError is raised.
57
+ """
58
+ if not bookings:
59
+ return
60
+
61
+ for booking in bookings:
62
+ booking_start = booking.otf_class.starts_at
63
+ booking_end = booking.otf_class.ends_at
64
+ # Check for overlap
65
+ if not (otf_class.ends_at < booking_start or otf_class.starts_at > booking_end):
66
+ raise exc.ConflictingBookingError(
67
+ f"You already have a booking that conflicts with this class ({booking.otf_class.class_uuid}).",
68
+ booking_uuid=booking.booking_uuid,
69
+ )
70
+
71
+
72
+ def filter_classes_by_filters(
73
+ classes: list["OtfClass"], filters: "list[ClassFilter] | ClassFilter | None"
74
+ ) -> list["OtfClass"]:
75
+ """Filter classes by the provided filters.
76
+
77
+ Args:
78
+ classes (list[OtfClass]): The classes to filter.
79
+ filters (list[ClassFilter] | ClassFilter | None): The filters to apply.
80
+
81
+ Returns:
82
+ list[OtfClass]: The filtered classes.
83
+ """
84
+ if not filters:
85
+ return classes
86
+
87
+ filters = ensure_list(filters)
88
+ filtered_classes: list[OtfClass] = []
89
+
90
+ # apply each filter as an OR operation
91
+ for f in filters:
92
+ filtered_classes.extend(f.filter_classes(classes))
93
+
94
+ # remove duplicates
95
+ classes = list({c.class_uuid: c for c in filtered_classes}.values())
96
+
97
+ return classes
98
+
99
+
100
+ def filter_classes_by_date(
101
+ classes: list["OtfClass"], start_date: date | None, end_date: date | None
102
+ ) -> list["OtfClass"]:
103
+ """Filter classes by start and end dates, as well as the max date the booking endpoint will accept.
104
+
105
+ Args:
106
+ classes (list[OtfClass]): The classes to filter.
107
+ start_date (date | None): The start date to filter by.
108
+ end_date (date | None): The end date to filter by.
109
+
110
+ Returns:
111
+ list[OtfClass]: The filtered classes.
112
+ """
113
+ # this endpoint returns classes that the `book_class` endpoint will reject, this filters them out
114
+ max_date = datetime.today().date() + timedelta(days=29)
115
+
116
+ classes = [c for c in classes if c.starts_at.date() <= max_date]
117
+
118
+ # if not start date or end date, we're done
119
+ if not start_date and not end_date:
120
+ return classes
121
+
122
+ if start_date := ensure_date(start_date):
123
+ classes = [c for c in classes if c.starts_at.date() >= start_date]
124
+
125
+ if end_date := ensure_date(end_date):
126
+ classes = [c for c in classes if c.starts_at.date() <= end_date]
127
+
128
+ return classes
129
+
130
+
131
+ def get_booking_uuid(booking_or_uuid: "str | Booking") -> str:
132
+ """Gets the booking UUID from the input, which can be a string or Booking object.
133
+
134
+ Args:
135
+ booking_or_uuid (str | Booking): The input booking or UUID.
136
+
137
+ Returns:
138
+ str: The booking UUID.
139
+
140
+ Raises:
141
+ TypeError: If the input is not a string or Booking object.
142
+ """
143
+ from otf_api.models.bookings import Booking
144
+
145
+ if isinstance(booking_or_uuid, str):
146
+ return booking_or_uuid
147
+
148
+ if isinstance(booking_or_uuid, Booking):
149
+ return booking_or_uuid.booking_uuid
150
+
151
+ raise TypeError(f"Expected Booking or str, got {type(booking_or_uuid)}")
152
+
153
+
154
+ def get_booking_id(booking_or_id: "str | BookingV2") -> str:
155
+ """Gets the booking ID from the input, which can be a string or BookingV2 object.
156
+
157
+ Args:
158
+ booking_or_id (str | BookingV2): The input booking or ID.
159
+
160
+ Returns:
161
+ str: The booking ID.
162
+
163
+ Raises:
164
+ TypeError: If the input is not a string or BookingV2 object.
165
+ """
166
+ from otf_api.models.bookings import BookingV2
167
+
168
+ if isinstance(booking_or_id, str):
169
+ return booking_or_id
170
+
171
+ if isinstance(booking_or_id, BookingV2):
172
+ return booking_or_id.booking_id
173
+
174
+ raise TypeError(f"Expected BookingV2 or str, got {type(booking_or_id)}")
175
+
176
+
177
+ def get_class_uuid(class_or_uuid: "str | OtfClass | BookingV2Class") -> str:
178
+ """Gets the class UUID from the input, which can be a string, OtfClass, or BookingV2Class.
179
+
180
+ Args:
181
+ class_or_uuid (str | OtfClass | BookingV2Class): The input class or UUID.
182
+
183
+ Returns:
184
+ str: The class UUID.
185
+
186
+ Raises:
187
+ ValueError: If the class does not have a class_uuid.
188
+ TypeError: If the input is not a string, OtfClass, or BookingV2Class.
189
+
190
+ """
191
+ if isinstance(class_or_uuid, str):
192
+ return class_or_uuid
193
+
194
+ if hasattr(class_or_uuid, "class_uuid"):
195
+ class_uuid = getattr(class_or_uuid, "class_uuid", None)
196
+ if class_uuid:
197
+ return class_uuid
198
+ raise ValueError("Class does not have a class_uuid")
199
+
200
+ raise TypeError(f"Expected OtfClass, BookingV2Class, or str, got {type(class_or_uuid)}")
201
+
202
+
203
+ def get_class_id(class_or_id: "str | BookingV2Class") -> str:
204
+ """Gets the class ID from the input, which can be a string or BookingV2Class.
205
+
206
+ Args:
207
+ class_or_id (str | BookingV2Class): The input class or ID.
208
+
209
+ Returns:
210
+ str: The class ID.
211
+
212
+ Raises:
213
+ TypeError: If the input is not a string or BookingV2Class.
214
+ """
215
+ from otf_api.models.bookings import BookingV2Class
216
+
217
+ if isinstance(class_or_id, str):
218
+ return class_or_id
219
+
220
+ if isinstance(class_or_id, BookingV2Class):
221
+ return class_or_id.class_id
222
+
223
+ raise TypeError(f"Expected BookingV2Class or str, got {type(class_or_id)}")
224
+
225
+
226
+ def ensure_list(obj: list | Any | None) -> list: # noqa: ANN401
227
+ """Ensures the input is a list. If None, returns an empty list. If not a list, returns a list containing the input.
228
+
229
+ Args:
230
+ obj (list | Any | None): The input object to ensure is a list.
231
+
232
+ Returns:
233
+ list: The input object as a list. If None, returns an empty list.
234
+ """
235
+ if obj is None:
236
+ return []
237
+ if not isinstance(obj, list):
238
+ return [obj]
239
+ return obj
240
+
241
+
242
+ def ensure_datetime(date_str: str | date | datetime | date | None, combine_with: time = MIN_TIME) -> datetime | None:
243
+ """Ensures the input is a date/datetime object or a string that can be converted to a datetime.
244
+
245
+ Args:
246
+ date_str (str | date | datetime | None): The input date string or date object. If None, returns None.
247
+ combine_with (time): The time to combine with if the input is a date object. Defaults to MIN_TIME.
248
+
249
+ Returns:
250
+ datetime | None: The converted datetime object or None if the input is None.
251
+
252
+ Raises:
253
+ TypeError: If the input is not a string, date, or datetime object.
254
+ """
255
+ if not date_str:
256
+ return None
257
+
258
+ if isinstance(date_str, str):
259
+ return datetime.fromisoformat(date_str)
260
+
261
+ if isinstance(date_str, datetime):
262
+ return date_str
263
+
264
+ if isinstance(date_str, date):
265
+ return datetime.combine(date_str, combine_with)
266
+
267
+ raise TypeError(f"Expected str or datetime, got {type(date_str)}")
268
+
269
+
270
+ def ensure_date(date_str: str | date | datetime | None) -> date | None:
271
+ """Ensures the input is a date object or a string that can be converted to a date.
272
+
273
+ Args:
274
+ date_str (str | date | None): The input date string or date object.
275
+
276
+ Returns:
277
+ date | None: The converted date object or None if the input is None.
278
+
279
+ Raises:
280
+ TypeError: If the input is not a string or date object.
281
+ """
282
+ if not date_str:
283
+ return None
284
+
285
+ if isinstance(date_str, str):
286
+ return datetime.fromisoformat(date_str).date()
287
+
288
+ if isinstance(date_str, datetime):
289
+ return date_str.date()
290
+
291
+ if isinstance(date_str, date):
292
+ return date_str
293
+
294
+ raise TypeError(f"Expected str or date, got {type(date_str)}")
295
+
296
+
297
+ def is_error_response(data: dict[str, Any]) -> bool:
298
+ """Check if the response data indicates an error."""
299
+ return isinstance(data, dict) and (data.get("code") == "ERROR" or "error" in data)
300
+
301
+
302
+ def get_json_from_response(response: httpx.Response) -> dict[str, Any]:
303
+ """Extract JSON data from an HTTP response."""
304
+ try:
305
+ return response.json()
306
+ except JSONDecodeError:
307
+ return {"raw": response.text}
@@ -0,0 +1,3 @@
1
+ from .workout_api import WorkoutApi
2
+
3
+ __all__ = ["WorkoutApi"]