Skip to content

Commit fa9def4

Browse files
authored
Merge pull request #102 from NodeJSmith/fix/fix_performance_metric_and_add_logging
- closes #100 - add more logging, especially where filtering/removal of records happens - add logic to dump raw response if `OTF_LOG_RAW_RESPONSE` env var is set to true - only logging at DEBUG level so need to have that set too, which can be done with OTF_LOG_LEVEL env var - add `coloredlogs` package to make logs easier to read - remove headers from request log - change log format structure slightly, use % formatting - change some log lines to use % to avoid calling methods/converting to string when not logging at that level - relax data type on `PerformanceMetric` class, remove custom parsing, was not useful
2 parents 987769e + d726fe4 commit fa9def4

File tree

12 files changed

+276
-143
lines changed

12 files changed

+276
-143
lines changed

.bumpversion.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[tool.bumpversion]
2-
current_version = "0.15.1"
2+
current_version = "0.15.2"
33

44
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(?:-(?P<rc_l>rc)(?P<rc>0|[1-9]\\d*))?"
55

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "otf-api"
3-
version = "0.15.1"
3+
version = "0.15.2"
44
description = "Python OrangeTheory Fitness API Client"
55
authors = [{ name = "Jessica Smith", email = "[email protected]" }]
66
requires-python = ">=3.11"
@@ -30,6 +30,7 @@ dependencies = [
3030
"diskcache>=5.6.3",
3131
"platformdirs>=4.3.6",
3232
"packaging>=24.2",
33+
"coloredlogs>=15.0.1",
3334
]
3435

3536
[project.urls]

source/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
project = "OrangeTheory API"
1515
copyright = "2025, Jessica Smith"
1616
author = "Jessica Smith"
17-
release = "0.15.1"
17+
release = "0.15.2"
1818

1919
# -- General configuration ---------------------------------------------------
2020
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

src/otf_api/__init__.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@
77
import logging
88
import os
99

10+
import coloredlogs
11+
1012
from otf_api import models
1113
from otf_api.api import Otf
1214
from otf_api.auth import OtfUser
1315

1416
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"
1719
DATE_FMT = "%Y-%m-%d %H:%M:%S%z"
1820

1921

@@ -24,19 +26,28 @@ def _setup_logging() -> None:
2426
return # Already set up
2527

2628
# 2) Set the logger level to INFO (or whatever you need).
27-
logger.setLevel(LOG_LEVEL_NUM)
29+
logger.setLevel(LOG_LEVEL)
2830

2931
# 3) Create a handler (e.g., console) and set its formatter.
3032
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="%"))
3234

3335
# 4) Add this handler to your package logger.
3436
logger.addHandler(handler)
3537

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+
3647

3748
_setup_logging()
3849

39-
__version__ = "0.15.1"
50+
__version__ = "0.15.2"
4051

4152

4253
__all__ = ["Otf", "OtfUser", "models"]

src/otf_api/api/bookings/booking_api.py

Lines changed: 49 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,19 @@
88
from otf_api import exceptions as exc
99
from otf_api import models
1010
from otf_api.api import utils
11-
from otf_api.api.client import OtfClient
1211
from otf_api.models.bookings import HISTORICAL_BOOKING_STATUSES, ClassFilter
1312

1413
from .booking_client import BookingClient
1514

1615
if typing.TYPE_CHECKING:
1716
from otf_api import Otf
17+
from otf_api.api.client import OtfClient
1818

1919
LOGGER = getLogger(__name__)
2020

2121

2222
class BookingApi:
23-
def __init__(self, otf: "Otf", otf_client: OtfClient):
23+
def __init__(self, otf: "Otf", otf_client: "OtfClient"):
2424
"""Initialize the Booking API client.
2525
2626
Args:
@@ -30,6 +30,39 @@ def __init__(self, otf: "Otf", otf_client: OtfClient):
3030
self.otf = otf
3131
self.client = BookingClient(otf_client)
3232

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+
3366
def get_bookings_new(
3467
self,
3568
start_date: datetime | date | str | None = None,
@@ -79,6 +112,7 @@ def get_bookings_new(
79112
bookings_resp = self.client.get_bookings_new(
80113
ends_before=end_date, starts_after=start_date, include_canceled=include_canceled, expand=expand
81114
)
115+
LOGGER.debug("Found %d bookings between %s and %s", len(bookings_resp), start_date, end_date)
82116

83117
# filter out bookings with ids that start with "no-booking-id"
84118
# no idea what these are, but I am praying for the poor sap stuck with maintaining OTF's data model
@@ -89,7 +123,7 @@ def get_bookings_new(
89123
try:
90124
results.append(models.BookingV2.create(**b, api=self.otf))
91125
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)
93127
continue
94128

95129
if not remove_duplicates:
@@ -112,6 +146,9 @@ def _deduplicate_bookings(
112146
list[BookingV2]: The deduplicated list of bookings.
113147
"""
114148
# remove duplicates by class_id, keeping the one with the most recent updated_at timestamp
149+
150+
orig_count = len(results)
151+
115152
seen_classes: dict[str, models.BookingV2] = {}
116153

117154
for booking in results:
@@ -127,11 +164,20 @@ def _deduplicate_bookings(
127164
"this is unexpected behavior."
128165
)
129166
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+
)
130170
seen_classes[class_id] = booking
131171

132172
results = list(seen_classes.values())
133173
results = sorted(results, key=lambda x: x.starts_at)
134174

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+
135181
return results
136182

137183
def get_bookings_new_by_date(
@@ -615,36 +661,3 @@ def rate_class(
615661
if e.response.status_code == 403:
616662
raise exc.AlreadyRatedError(f"Workout {performance_summary_id} is already rated.") from None
617663
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

src/otf_api/api/client.py

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import atexit
2+
import json
3+
import os
24
import re
35
from json import JSONDecodeError
46
from logging import getLogger
@@ -47,6 +49,7 @@ def __init__(self, user: OtfUser | None = None):
4749
self.session = httpx.Client(
4850
headers=HEADERS, auth=self.user.httpx_auth, timeout=httpx.Timeout(20.0, connect=60.0)
4951
)
52+
self.log_raw_response = os.getenv("OTF_LOG_RAW_RESPONSE", "false").lower() == "true"
5053
atexit.register(self.session.close)
5154

5255
def __getstate__(self):
@@ -110,7 +113,7 @@ def do(
110113
"""
111114
full_url = str(URL.build(scheme="https", host=base_url, path=path))
112115
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))
114117

115118
try:
116119
response = self.session.send(request)
@@ -158,10 +161,14 @@ def _map_http_error(
158161
if error_code == "602":
159162
raise exc.OutsideSchedulingWindowError("Class is outside scheduling window")
160163

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)
163165
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+
)
165172

166173
def _handle_transport_error(self, error: Exception, request: httpx.Request) -> None:
167174
"""Handle transport errors during API requests.
@@ -177,7 +184,7 @@ def _handle_transport_error(self, error: Exception, request: httpx.Request) -> N
177184
url = request.url
178185

179186
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)
181188
return
182189

183190
json_data = get_json_from_response(error.response)
@@ -190,7 +197,7 @@ def _map_logical_error(self, data: dict, response: httpx.Response, request: http
190197
data_status: int | None = data.get("Status") or data.get("status") or None
191198

192199
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)
194201
raise exc.OtfRequestError("Bad API response", None, response=response, request=request)
195202

196203
raise exc.OtfRequestError(
@@ -202,17 +209,20 @@ def _handle_response(self, method: str, response: httpx.Response, request: httpx
202209
if method == "GET":
203210
raise exc.OtfRequestError("Empty response", None, response=response, request=request)
204211

205-
LOGGER.debug(f"No content returned from {method} {response.url}")
212+
LOGGER.debug("No content returned from %s %s", method, response.url)
206213
return None
207214

208215
try:
209216
json_data = response.json()
210217
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)
213220
raise
214221

215222
if is_error_response(json_data):
216223
self._map_logical_error(json_data, response, request)
217224

225+
if self.log_raw_response:
226+
LOGGER.debug("Response from %s %s: %s", method, response.url, json.dumps(json_data, indent=4))
227+
218228
return json_data

src/otf_api/api/members/member_api.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,18 @@
33
from typing import Any
44

55
from otf_api import models
6-
from otf_api.api.client import OtfClient
76

87
from .member_client import MemberClient
98

109
if typing.TYPE_CHECKING:
1110
from otf_api import Otf
11+
from otf_api.api.client import OtfClient
1212

1313
LOGGER = getLogger(__name__)
1414

1515

1616
class MemberApi:
17-
def __init__(self, otf: "Otf", otf_client: OtfClient):
17+
def __init__(self, otf: "Otf", otf_client: "OtfClient"):
1818
"""Initialize the Member API client.
1919
2020
Args:

0 commit comments

Comments
 (0)