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.
- otf_api/__init__.py +35 -3
- otf_api/api/__init__.py +3 -0
- otf_api/api/_compat.py +77 -0
- otf_api/api/api.py +80 -0
- otf_api/api/bookings/__init__.py +3 -0
- otf_api/api/bookings/booking_api.py +541 -0
- otf_api/api/bookings/booking_client.py +112 -0
- otf_api/api/client.py +203 -0
- otf_api/api/members/__init__.py +3 -0
- otf_api/api/members/member_api.py +187 -0
- otf_api/api/members/member_client.py +112 -0
- otf_api/api/studios/__init__.py +3 -0
- otf_api/api/studios/studio_api.py +173 -0
- otf_api/api/studios/studio_client.py +120 -0
- otf_api/api/utils.py +307 -0
- otf_api/api/workouts/__init__.py +3 -0
- otf_api/api/workouts/workout_api.py +333 -0
- otf_api/api/workouts/workout_client.py +140 -0
- otf_api/auth/__init__.py +1 -1
- otf_api/auth/auth.py +155 -89
- otf_api/auth/user.py +5 -17
- otf_api/auth/utils.py +27 -2
- otf_api/cache.py +132 -0
- otf_api/exceptions.py +18 -6
- otf_api/models/__init__.py +25 -21
- otf_api/models/bookings/__init__.py +23 -0
- otf_api/models/bookings/bookings.py +134 -0
- otf_api/models/{bookings_v2.py → bookings/bookings_v2.py} +72 -31
- otf_api/models/bookings/classes.py +124 -0
- otf_api/models/{enums.py → bookings/enums.py} +7 -81
- otf_api/{filters.py → models/bookings/filters.py} +39 -11
- otf_api/models/{ratings.py → bookings/ratings.py} +2 -6
- otf_api/models/members/__init__.py +5 -0
- otf_api/models/members/member_detail.py +149 -0
- otf_api/models/members/member_membership.py +26 -0
- otf_api/models/members/member_purchases.py +29 -0
- otf_api/models/members/notifications.py +17 -0
- otf_api/models/mixins.py +48 -1
- otf_api/models/studios/__init__.py +5 -0
- otf_api/models/studios/enums.py +11 -0
- otf_api/models/studios/studio_detail.py +93 -0
- otf_api/models/studios/studio_services.py +36 -0
- otf_api/models/workouts/__init__.py +31 -0
- otf_api/models/{body_composition_list.py → workouts/body_composition_list.py} +140 -71
- otf_api/models/workouts/challenge_tracker_content.py +50 -0
- otf_api/models/workouts/challenge_tracker_detail.py +99 -0
- otf_api/models/workouts/enums.py +70 -0
- otf_api/models/workouts/lifetime_stats.py +96 -0
- otf_api/models/workouts/out_of_studio_workout_history.py +32 -0
- otf_api/models/{performance_summary.py → workouts/performance_summary.py} +19 -5
- otf_api/models/workouts/telemetry.py +88 -0
- otf_api/models/{workout.py → workouts/workout.py} +34 -20
- {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/METADATA +4 -2
- otf_api-0.13.0.dist-info/RECORD +59 -0
- {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/WHEEL +1 -1
- otf_api/api.py +0 -1682
- otf_api/logging.py +0 -19
- otf_api/models/bookings.py +0 -109
- otf_api/models/challenge_tracker_content.py +0 -59
- otf_api/models/challenge_tracker_detail.py +0 -88
- otf_api/models/classes.py +0 -70
- otf_api/models/lifetime_stats.py +0 -78
- otf_api/models/member_detail.py +0 -121
- otf_api/models/member_membership.py +0 -26
- otf_api/models/member_purchases.py +0 -29
- otf_api/models/notifications.py +0 -17
- otf_api/models/out_of_studio_workout_history.py +0 -32
- otf_api/models/studio_detail.py +0 -71
- otf_api/models/studio_services.py +0 -36
- otf_api/models/telemetry.py +0 -84
- otf_api/utils.py +0 -164
- otf_api-0.12.0.dist-info/RECORD +0 -38
- {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/licenses/LICENSE +0 -0
- {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}
|