otf-api 0.8.2__py3-none-any.whl → 0.9.1__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 (43) hide show
  1. otf_api/__init__.py +7 -4
  2. otf_api/api.py +699 -480
  3. otf_api/auth/__init__.py +4 -0
  4. otf_api/auth/auth.py +234 -0
  5. otf_api/auth/user.py +66 -0
  6. otf_api/auth/utils.py +129 -0
  7. otf_api/exceptions.py +38 -5
  8. otf_api/filters.py +97 -0
  9. otf_api/logging.py +19 -0
  10. otf_api/models/__init__.py +27 -38
  11. otf_api/models/body_composition_list.py +47 -50
  12. otf_api/models/bookings.py +63 -87
  13. otf_api/models/challenge_tracker_content.py +42 -21
  14. otf_api/models/challenge_tracker_detail.py +68 -48
  15. otf_api/models/classes.py +53 -62
  16. otf_api/models/enums.py +108 -30
  17. otf_api/models/lifetime_stats.py +59 -45
  18. otf_api/models/member_detail.py +95 -115
  19. otf_api/models/member_membership.py +18 -17
  20. otf_api/models/member_purchases.py +21 -127
  21. otf_api/models/mixins.py +37 -33
  22. otf_api/models/notifications.py +17 -0
  23. otf_api/models/out_of_studio_workout_history.py +22 -31
  24. otf_api/models/performance_summary_detail.py +47 -42
  25. otf_api/models/performance_summary_list.py +19 -37
  26. otf_api/models/studio_detail.py +51 -98
  27. otf_api/models/studio_services.py +27 -48
  28. otf_api/models/telemetry.py +14 -5
  29. otf_api/utils.py +134 -0
  30. {otf_api-0.8.2.dist-info → otf_api-0.9.1.dist-info}/METADATA +21 -10
  31. otf_api-0.9.1.dist-info/RECORD +35 -0
  32. {otf_api-0.8.2.dist-info → otf_api-0.9.1.dist-info}/WHEEL +1 -1
  33. otf_api/auth.py +0 -316
  34. otf_api/models/book_class.py +0 -89
  35. otf_api/models/cancel_booking.py +0 -49
  36. otf_api/models/favorite_studios.py +0 -106
  37. otf_api/models/latest_agreement.py +0 -21
  38. otf_api/models/telemetry_hr_history.py +0 -34
  39. otf_api/models/telemetry_max_hr.py +0 -13
  40. otf_api/models/total_classes.py +0 -8
  41. otf_api-0.8.2.dist-info/AUTHORS.md +0 -9
  42. otf_api-0.8.2.dist-info/RECORD +0 -36
  43. {otf_api-0.8.2.dist-info → otf_api-0.9.1.dist-info}/LICENSE +0 -0
@@ -0,0 +1,4 @@
1
+ from .user import OtfUser
2
+ from .utils import HttpxCognitoAuth
3
+
4
+ __all__ = ["OTF_AUTH_TYPE", "HttpxCognitoAuth", "OtfAuth", "OtfUser"]
otf_api/auth/auth.py ADDED
@@ -0,0 +1,234 @@
1
+ import platform
2
+ import typing
3
+ from logging import getLogger
4
+ from pathlib import Path
5
+ from time import sleep
6
+ from typing import Any, ClassVar
7
+
8
+ from boto3 import Session
9
+ from botocore import UNSIGNED
10
+ from botocore.config import Config
11
+ from botocore.exceptions import ClientError
12
+ from pycognito import AWSSRP, Cognito
13
+ from pycognito.aws_srp import generate_hash_device
14
+
15
+ from otf_api.utils import CacheableData
16
+
17
+ if typing.TYPE_CHECKING:
18
+ from mypy_boto3_cognito_idp import CognitoIdentityProviderClient
19
+ from mypy_boto3_cognito_idp.type_defs import InitiateAuthResponseTypeDef
20
+
21
+ LOGGER = getLogger(__name__)
22
+ CLIENT_ID = "1457d19r0pcjgmp5agooi0rb1b" # from android app
23
+ USER_POOL_ID = "us-east-1_dYDxUeyL1"
24
+ REGION = "us-east-1"
25
+ COGNITO_IDP_URL = f"https://cognito-idp.{REGION}.amazonaws.com/"
26
+
27
+ BOTO_CONFIG = Config(region_name=REGION, signature_version=UNSIGNED)
28
+ CRED_CACHE = CacheableData("creds", Path("~/.otf-api"))
29
+
30
+ DEVICE_KEYS = ["device_key", "device_group_key", "device_password"]
31
+ TOKEN_KEYS = ["access_token", "id_token", "refresh_token"]
32
+
33
+
34
+ class NoCredentialsError(Exception):
35
+ """Raised when no credentials are found."""
36
+
37
+
38
+ class OtfCognito(Cognito):
39
+ """A subclass of the pycognito Cognito class that adds the device_key to the auth_params. Without this
40
+ being set the renew_access_token call will always fail with NOT_AUTHORIZED."""
41
+
42
+ user_pool_id: ClassVar[str] = USER_POOL_ID
43
+ client_id: ClassVar[str] = CLIENT_ID
44
+ user_pool_region: ClassVar[str] = REGION
45
+ client_secret: ClassVar[str] = ""
46
+
47
+ id_token: str
48
+ access_token: str
49
+ device_key: str
50
+ device_group_key: str
51
+ device_password: str
52
+ device_name: str
53
+
54
+ def __init__(
55
+ self,
56
+ username: str | None = None,
57
+ password: str | None = None,
58
+ id_token: str | None = None,
59
+ access_token: str | None = None,
60
+ refresh_token: str | None = None,
61
+ ):
62
+ """
63
+
64
+ Args:
65
+ username (str, optional): User Pool username
66
+ password (str, optional): User Pool password
67
+ id_token (str, optional): ID Token returned by authentication
68
+ access_token (str, optional): Access Token returned by authentication
69
+ refresh_token (str, optional): Refresh Token returned by authentication
70
+ """
71
+
72
+ self.username = username
73
+ self.id_token = id_token # type: ignore
74
+ self.access_token = access_token # type: ignore
75
+ self.refresh_token = refresh_token
76
+
77
+ self.id_claims: dict[str, Any] = {}
78
+ self.access_claims: dict[str, Any] = {}
79
+ self.custom_attributes: dict[str, Any] = {}
80
+ self.base_attributes: dict[str, Any] = {}
81
+ self.pool_jwk: dict[str, Any] = {}
82
+ self.mfa_tokens: dict[str, Any] = {}
83
+ self.pool_domain_url: str | None = None
84
+
85
+ try:
86
+ dd = CRED_CACHE.get_cached_data(DEVICE_KEYS)
87
+ except Exception:
88
+ LOGGER.exception("Failed to read device key cache")
89
+ dd = {}
90
+
91
+ self.device_name = platform.node()
92
+ self.device_key = dd.get("device_key") # type: ignore
93
+ self.device_group_key = dd.get("device_group_key") # type: ignore
94
+ self.device_password = dd.get("device_password") # type: ignore
95
+
96
+ self.client: CognitoIdentityProviderClient = Session().client(
97
+ "cognito-idp", config=BOTO_CONFIG, region_name=REGION
98
+ )
99
+
100
+ try:
101
+ token_cache = CRED_CACHE.get_cached_data(TOKEN_KEYS)
102
+ except Exception:
103
+ LOGGER.exception("Failed to read token cache")
104
+ token_cache = {}
105
+
106
+ if not (username and password) and not (id_token and access_token) and not token_cache:
107
+ raise NoCredentialsError("No credentials provided and no tokens cached, cannot authenticate")
108
+
109
+ if username and password:
110
+ self.login(password)
111
+ elif token_cache and not (id_token and access_token):
112
+ LOGGER.debug("Using cached tokens")
113
+ self.id_token = token_cache["id_token"]
114
+ self.access_token = token_cache["access_token"]
115
+ self.refresh_token = token_cache["refresh_token"]
116
+
117
+ self.check_token()
118
+ self.verify_tokens()
119
+ CRED_CACHE.write_to_cache(self.tokens)
120
+
121
+ @property
122
+ def tokens(self) -> dict[str, str]:
123
+ tokens = {
124
+ "access_token": self.access_token,
125
+ "id_token": self.id_token,
126
+ "refresh_token": self.refresh_token,
127
+ }
128
+ return {k: v for k, v in tokens.items() if v}
129
+
130
+ @property
131
+ def device_metadata(self) -> dict[str, str]:
132
+ dm = {
133
+ "device_key": self.device_key,
134
+ "device_group_key": self.device_group_key,
135
+ "device_password": self.device_password,
136
+ }
137
+ return {k: v for k, v in dm.items() if v}
138
+
139
+ def login(self, password: str) -> None:
140
+ """Called when logging in with a username and password. Will set the tokens and device metadata."""
141
+
142
+ LOGGER.debug("Logging in with username and password...")
143
+
144
+ aws = AWSSRP(
145
+ username=self.username,
146
+ password=password,
147
+ pool_id=USER_POOL_ID,
148
+ client_id=CLIENT_ID,
149
+ client=self.client,
150
+ )
151
+ try:
152
+ tokens: InitiateAuthResponseTypeDef = aws.authenticate_user()
153
+ except ClientError as e:
154
+ code = e.response["Error"]["Code"]
155
+ msg = e.response["Error"]["Message"]
156
+ if "UserLambdaValidationException" in msg or "UserLambdaValidation" in code:
157
+ sleep(5)
158
+ tokens = aws.authenticate_user()
159
+ else:
160
+ raise
161
+
162
+ self._set_tokens(tokens)
163
+ self._handle_device_setup()
164
+
165
+ def _handle_device_setup(self) -> None:
166
+ """Confirms the device with Cognito and caches the device metadata.
167
+
168
+ Devices are not remembered at this time, as OTF does not have MFA set up currently. Without MFA setup, there
169
+ is no benefit to remembering the device. Additionally, it does not appear that the OTF app remembers devices,
170
+ so this matches the behavior of the app.
171
+ """
172
+
173
+ if not self.device_key:
174
+ raise ValueError("Device key not set - device key is required by this Cognito pool")
175
+
176
+ self.device_password, device_secret_verifier_config = generate_hash_device(
177
+ self.device_group_key, self.device_key
178
+ )
179
+
180
+ self.client.confirm_device(
181
+ AccessToken=self.access_token,
182
+ DeviceKey=self.device_key,
183
+ DeviceSecretVerifierConfig=device_secret_verifier_config,
184
+ DeviceName=self.device_name,
185
+ )
186
+
187
+ try:
188
+ CRED_CACHE.write_to_cache(self.device_metadata)
189
+ except Exception:
190
+ LOGGER.exception("Failed to write device key cache")
191
+
192
+ ##### OVERRIDDEN METHODS #####
193
+
194
+ def renew_access_token(self) -> None:
195
+ """Sets a new access token on the User using the cached refresh token and device metadata.
196
+
197
+ Overridden to add the device key to the auth_params if it is set. Without this all calls to renew_access_token
198
+ will fail with NOT_AUTHORIZED. Also skips the call to _add_secret_hash since we don't have a client secret.
199
+
200
+ # https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_InitiateAuth.html#CognitoUserPools-InitiateAuth-request-AuthFlow
201
+
202
+ """
203
+ if not self.device_key:
204
+ raise ValueError("Device key not set - device key is required by this Cognito pool")
205
+
206
+ if not self.refresh_token:
207
+ raise ValueError("No refresh token set - cannot renew access token")
208
+
209
+ refresh_response = self.client.initiate_auth(
210
+ ClientId=self.client_id,
211
+ AuthFlow="REFRESH_TOKEN_AUTH",
212
+ AuthParameters={"REFRESH_TOKEN": self.refresh_token, "DEVICE_KEY": self.device_key},
213
+ )
214
+ self._set_tokens(refresh_response)
215
+
216
+ def _set_tokens(self, tokens: "InitiateAuthResponseTypeDef") -> None:
217
+ """Helper function to verify and set token attributes based on a Cognito AuthenticationResult.
218
+
219
+ Overridden to cache the tokens and set/cache the device metadata.
220
+ """
221
+ auth_result = tokens["AuthenticationResult"]
222
+ device_metadata = auth_result.get("NewDeviceMetadata", {})
223
+
224
+ # tokens - refresh token defaults to existing value if not present
225
+ # note: verify_token also sets the token attribute
226
+ self.verify_token(auth_result["AccessToken"], "access_token", "access")
227
+ self.verify_token(auth_result["IdToken"], "id_token", "id")
228
+ self.refresh_token = auth_result.get("RefreshToken", self.refresh_token)
229
+ CRED_CACHE.write_to_cache(self.tokens)
230
+
231
+ # device metadata - default to existing values if not present
232
+ self.device_key = device_metadata.get("DeviceKey", self.device_key)
233
+ self.device_group_key = device_metadata.get("DeviceGroupKey", self.device_group_key)
234
+ CRED_CACHE.write_to_cache(self.device_metadata)
otf_api/auth/user.py ADDED
@@ -0,0 +1,66 @@
1
+ from logging import getLogger
2
+
3
+ import attrs
4
+
5
+ from otf_api.auth.auth import CRED_CACHE, NoCredentialsError, OtfCognito
6
+ from otf_api.auth.utils import HttpxCognitoAuth, can_provide_input, prompt_for_username_and_password
7
+
8
+ LOGGER = getLogger(__name__)
9
+
10
+
11
+ @attrs.define(init=False)
12
+ class OtfUser:
13
+ """OtfUser is a thin wrapper around OtfCognito, meant to hide all of the gory details from end users."""
14
+
15
+ cognito_id: str
16
+ member_uuid: str
17
+ email_address: str
18
+ cognito: OtfCognito
19
+ httpx_auth: HttpxCognitoAuth
20
+
21
+ def __init__(
22
+ self,
23
+ username: str | None = None,
24
+ password: str | None = None,
25
+ id_token: str | None = None,
26
+ access_token: str | None = None,
27
+ refresh_token: str | None = None,
28
+ ):
29
+ """Create a User instance.
30
+
31
+ Args:
32
+ username (str, optional): User Pool username
33
+ password (str, optional): User Pool password
34
+ id_token (str, optional): ID Token returned by authentication
35
+ access_token (str, optional): Access Token returned by authentication
36
+ refresh_token (str, optional): Refresh Token returned by authentication
37
+
38
+ Raises:
39
+ NoCredentialsError: If neither username/password nor id/access tokens are provided.
40
+ """
41
+ try:
42
+ self.cognito = OtfCognito(
43
+ username=username,
44
+ password=password,
45
+ id_token=id_token,
46
+ access_token=access_token,
47
+ refresh_token=refresh_token,
48
+ )
49
+ except NoCredentialsError:
50
+ if not can_provide_input():
51
+ LOGGER.error("Unable to prompt for credentials in a non-interactive shell")
52
+ raise
53
+
54
+ username, password = prompt_for_username_and_password()
55
+ self.cognito = OtfCognito(username=username, password=password)
56
+
57
+ self.cognito_id = self.cognito.access_claims["sub"]
58
+ self.member_uuid = self.cognito.id_claims["cognito:username"]
59
+ self.email_address = self.cognito.id_claims["email"]
60
+
61
+ self.httpx_auth = HttpxCognitoAuth(cognito=self.cognito)
62
+
63
+ @staticmethod
64
+ def clear_cache():
65
+ """Clear the cached credentials."""
66
+ CRED_CACHE.clear_cache()
otf_api/auth/utils.py ADDED
@@ -0,0 +1,129 @@
1
+ import os
2
+ import sys
3
+ from collections.abc import Generator
4
+ from functools import partial
5
+ from getpass import getpass
6
+ from logging import getLogger
7
+ from typing import Any
8
+
9
+ import httpx
10
+ from httpx import Request
11
+ from pycognito import Cognito
12
+
13
+ LOGGER = getLogger(__name__)
14
+ USERNAME_PROMPT = "Enter your Orangetheory Fitness username/email: "
15
+ PASSWORD_PROMPT = "Enter your Orangetheory Fitness password: "
16
+
17
+
18
+ def _show_error_message(message: str) -> None:
19
+ try:
20
+ from rich import print # type: ignore
21
+
22
+ print(message, style="bold red")
23
+ except ImportError:
24
+ print(message)
25
+
26
+
27
+ def _get_password_input(prompt: str) -> str:
28
+ try:
29
+ from rich import get_console # type: ignore
30
+
31
+ otf_input = partial(get_console().input, password=True)
32
+ prompt = f"[bold blue]{prompt}[/bold blue]"
33
+ except ImportError:
34
+ otf_input = getpass
35
+
36
+ return otf_input(prompt)
37
+
38
+
39
+ def _get_input(prompt: str) -> str:
40
+ try:
41
+ from rich import get_console # type: ignore
42
+
43
+ otf_input = get_console().input
44
+ prompt = f"[bold blue]{prompt}[/bold blue]"
45
+ except ImportError:
46
+ otf_input = input
47
+
48
+ return otf_input(prompt)
49
+
50
+
51
+ def _prompt_for_username() -> str:
52
+ def get_username() -> str:
53
+ username = _get_input(USERNAME_PROMPT)
54
+
55
+ if not username:
56
+ _show_error_message("Username is required")
57
+ return ""
58
+
59
+ if "@" not in username or username.endswith("@"):
60
+ _show_error_message("Username should be a valid email address")
61
+ return ""
62
+
63
+ return username
64
+
65
+ while not (username := get_username()):
66
+ pass
67
+
68
+ return username
69
+
70
+
71
+ def _prompt_for_password() -> str:
72
+ def get_password() -> str:
73
+ password = _get_password_input(PASSWORD_PROMPT)
74
+
75
+ if not password:
76
+ _show_error_message("Password is required")
77
+ return ""
78
+
79
+ return password
80
+
81
+ while not (password := get_password()):
82
+ pass
83
+
84
+ return password
85
+
86
+
87
+ def prompt_for_username_and_password() -> tuple[str, str]:
88
+ """Prompt for a username and password.
89
+
90
+ Returns:
91
+ tuple[str, str]: A tuple containing the username and password.
92
+ """
93
+
94
+ username = os.getenv("OTF_EMAIL") or _prompt_for_username()
95
+ password = os.getenv("OTF_PASSWORD") or _prompt_for_password()
96
+
97
+ return username, password
98
+
99
+
100
+ def can_provide_input() -> bool:
101
+ """Check if the script is running in an interactive shell.
102
+
103
+ Returns:
104
+ bool: True if the script is running in an interactive shell.
105
+ """
106
+ return os.isatty(sys.stdin.fileno()) and os.isatty(sys.stdout.fileno())
107
+
108
+
109
+ class HttpxCognitoAuth(httpx.Auth):
110
+ http_header: str = "Authorization"
111
+ http_header_prefix: str = "Bearer "
112
+
113
+ def __init__(self, cognito: Cognito):
114
+ """HTTPX Authentication extension for Cognito User Pools.
115
+
116
+ Args:
117
+ cognito (Cognito): A Cognito instance.
118
+ """
119
+
120
+ self.cognito = cognito
121
+
122
+ def auth_flow(self, request: Request) -> Generator[Request, Any, None]:
123
+ self.cognito.check_token(renew=True)
124
+
125
+ token = self.cognito.id_token
126
+
127
+ request.headers[self.http_header] = self.http_header_prefix + token
128
+
129
+ yield request
otf_api/exceptions.py CHANGED
@@ -1,4 +1,25 @@
1
- class BookingError(Exception):
1
+ from httpx import Request, Response
2
+
3
+
4
+ class OtfException(Exception):
5
+ """Base class for all exceptions in this package."""
6
+
7
+
8
+ class OtfRequestError(OtfException):
9
+ """Raised when an error occurs while making a request to the OTF API."""
10
+
11
+ response: Response
12
+ request: Request
13
+
14
+ def __init__(self, message: str, response: Response, request: Request):
15
+ super().__init__(message)
16
+ self.response = response
17
+ self.request = request
18
+
19
+
20
+ class BookingError(OtfException):
21
+ """Base class for booking-related errors, with an optional booking UUID attribute."""
22
+
2
23
  booking_uuid: str | None
3
24
 
4
25
  def __init__(self, message: str, booking_uuid: str | None = None):
@@ -6,13 +27,25 @@ class BookingError(Exception):
6
27
  self.booking_uuid = booking_uuid
7
28
 
8
29
 
9
- class AlreadyBookedError(BookingError): ...
30
+ class AlreadyBookedError(BookingError):
31
+ """Raised when attempting to book a class that is already booked."""
32
+
33
+
34
+ class ConflictingBookingError(BookingError):
35
+ """Raised when attempting to book a class that conflicts with an existing booking."""
36
+
37
+
38
+ class BookingAlreadyCancelledError(BookingError):
39
+ """Raised when attempting to cancel a booking that is already cancelled."""
10
40
 
11
41
 
12
- class BookingAlreadyCancelledError(BookingError): ...
42
+ class OutsideSchedulingWindowError(OtfException):
43
+ """Raised when attempting to book a class outside the scheduling window."""
13
44
 
14
45
 
15
- class OutsideSchedulingWindowError(Exception): ...
46
+ class BookingNotFoundError(OtfException):
47
+ """Raised when a booking is not found."""
16
48
 
17
49
 
18
- class BookingNotFoundError(Exception): ...
50
+ class ResourceNotFoundError(OtfException):
51
+ """Raised when a resource is not found."""
otf_api/filters.py ADDED
@@ -0,0 +1,97 @@
1
+ from datetime import date, datetime, time
2
+
3
+ from pydantic import BaseModel, field_validator
4
+
5
+ from otf_api.models import ClassType, DoW, OtfClass
6
+
7
+
8
+ class ClassFilter(BaseModel):
9
+ """ClassFilter is used to filter classes, to separate the filtering logic from the API client.
10
+
11
+ The `class_type`, `day_of_week`, and `start_time` fields can either be a single value or a list of values.
12
+ If a single value is provided, it will be converted to a list.
13
+
14
+ The `class_type` and `day_of_week` fields can be provided as strings or as the corresponding Enum values. The
15
+ class will attempt to match the string to the Enum value, regardless of case.
16
+
17
+ The arguments are applied as an AND filter, meaning that all filters must match for a class to be included. If
18
+ a filter is not provided, it is not applied. If multiple values are provided for a filter the values are treated
19
+ as an OR filter, meaning that a class will be included if it matches any of the values.
20
+
21
+ All arguments are optional and default to None.
22
+
23
+ Args:
24
+ start_date (date): Filter classes that start on or after this date.
25
+ end_date (date): Filter classes that start on or before this date.
26
+ class_type (list[ClassType]): Filter classes by class type.
27
+ day_of_week (list[DoW]): Filter classes by day of the week.
28
+ start_time (list[time]): Filter classes by start time.
29
+ """
30
+
31
+ start_date: date | None = None
32
+ end_date: date | None = None
33
+ class_type: list[ClassType] | None = None
34
+ day_of_week: list[DoW] | None = None
35
+ start_time: list[time] | None = None
36
+
37
+ def filter_classes(self, classes: list[OtfClass]) -> list[OtfClass]:
38
+ """Filters a list of classes based on the filter arguments.
39
+
40
+ Args:
41
+ classes (list[OtfClass]): A list of classes to filter.
42
+
43
+ Returns:
44
+ list[OtfClass]: The filtered list of classes.
45
+ """
46
+ # in case these are set after the class is created
47
+ if self.start_date and isinstance(self.start_date, datetime):
48
+ self.start_date = self.start_date.date()
49
+
50
+ if self.end_date and isinstance(self.end_date, datetime):
51
+ self.end_date = self.end_date.date()
52
+
53
+ if self.start_date:
54
+ classes = [c for c in classes if c.starts_at.date() >= self.start_date]
55
+
56
+ if self.end_date:
57
+ classes = [c for c in classes if c.starts_at.date() <= self.end_date]
58
+
59
+ if self.class_type:
60
+ classes = [c for c in classes if c.class_type in self.class_type]
61
+
62
+ if self.day_of_week:
63
+ classes = [c for c in classes if c.day_of_week in self.day_of_week]
64
+
65
+ if self.start_time:
66
+ classes = [c for c in classes if c.starts_at.time() in self.start_time]
67
+
68
+ return classes
69
+
70
+ @field_validator("class_type", "day_of_week", "start_time", mode="before")
71
+ @classmethod
72
+ def _single_item_to_list(cls, v):
73
+ if v and not isinstance(v, list):
74
+ return [v]
75
+ return v
76
+
77
+ @field_validator("day_of_week", mode="before")
78
+ @classmethod
79
+ def _day_of_week_str_to_enum(cls, v):
80
+ if v and isinstance(v, str):
81
+ return [DoW(v.title())]
82
+
83
+ if v and isinstance(v, list) and not all(isinstance(i, DoW) for i in v):
84
+ return [DoW(i.title()) for i in v]
85
+
86
+ return v
87
+
88
+ @field_validator("class_type", mode="before")
89
+ @classmethod
90
+ def _class_type_str_to_enum(cls, v):
91
+ if v and isinstance(v, str):
92
+ return [ClassType.get_case_insensitive(v)]
93
+
94
+ if v and isinstance(v, list) and not all(isinstance(i, ClassType) for i in v):
95
+ return [ClassType.get_case_insensitive(i) for i in v]
96
+
97
+ return v
otf_api/logging.py ADDED
@@ -0,0 +1,19 @@
1
+ import logging
2
+ import os
3
+
4
+ LOG_LEVEL = os.getenv("OTF_LOG_LEVEL", "INFO")
5
+
6
+ LOG_FMT = "{asctime} - {module}.{funcName}:{lineno} - {levelname} - {message}"
7
+ DATE_FMT = "%Y-%m-%d %H:%M:%S%z"
8
+
9
+ logger = logging.getLogger("otf_api")
10
+
11
+ # 2) Set the logger level to INFO (or whatever you need).
12
+ logger.setLevel(LOG_LEVEL)
13
+
14
+ # 3) Create a handler (e.g., console) and set its formatter.
15
+ handler = logging.StreamHandler()
16
+ handler.setFormatter(logging.Formatter(fmt=LOG_FMT, datefmt=DATE_FMT, style="{"))
17
+
18
+ # 4) Add this handler to your package logger.
19
+ logger.addHandler(handler)