pysportbot 0.0.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.
- pysportbot/__init__.py +116 -0
- pysportbot/activities.py +122 -0
- pysportbot/authenticator.py +118 -0
- pysportbot/bookings.py +87 -0
- pysportbot/centres.py +108 -0
- pysportbot/endpoints.py +40 -0
- pysportbot/service/__init__.py +0 -0
- pysportbot/service/__main__.py +32 -0
- pysportbot/service/booking.py +147 -0
- pysportbot/service/config_loader.py +7 -0
- pysportbot/service/config_validator.py +54 -0
- pysportbot/service/scheduling.py +61 -0
- pysportbot/service/service.py +47 -0
- pysportbot/session.py +47 -0
- pysportbot/utils/__init__.py +0 -0
- pysportbot/utils/errors.py +140 -0
- pysportbot/utils/logger.py +78 -0
- pysportbot/utils/time.py +39 -0
- pysportbot-0.0.1.dist-info/LICENSE +21 -0
- pysportbot-0.0.1.dist-info/METADATA +148 -0
- pysportbot-0.0.1.dist-info/RECORD +22 -0
- pysportbot-0.0.1.dist-info/WHEEL +4 -0
@@ -0,0 +1,147 @@
|
|
1
|
+
import time
|
2
|
+
from datetime import datetime, timedelta
|
3
|
+
from typing import Any, Dict
|
4
|
+
|
5
|
+
import pytz
|
6
|
+
import schedule
|
7
|
+
|
8
|
+
from pysportbot import SportBot
|
9
|
+
from pysportbot.utils.errors import ErrorMessages
|
10
|
+
from pysportbot.utils.logger import get_logger
|
11
|
+
|
12
|
+
from .config_validator import DAY_MAP
|
13
|
+
from .scheduling import calculate_class_day, calculate_next_execution
|
14
|
+
|
15
|
+
logger = get_logger(__name__)
|
16
|
+
|
17
|
+
|
18
|
+
def _raise_no_matching_slots_error(activity: str, class_time: str, booking_date: str) -> None:
|
19
|
+
"""
|
20
|
+
Helper function to raise a ValueError for no matching slots.
|
21
|
+
"""
|
22
|
+
raise ValueError(ErrorMessages.no_matching_slots_for_time(activity, class_time, booking_date))
|
23
|
+
|
24
|
+
|
25
|
+
def attempt_booking(
|
26
|
+
bot: SportBot,
|
27
|
+
cls: Dict[str, Any],
|
28
|
+
offset_seconds: int,
|
29
|
+
retry_attempts: int = 1,
|
30
|
+
retry_delay_minutes: int = 0,
|
31
|
+
time_zone: str = "Europe/Madrid",
|
32
|
+
) -> None:
|
33
|
+
activity = cls["activity"]
|
34
|
+
class_day = cls["class_day"]
|
35
|
+
class_time = cls["class_time"]
|
36
|
+
booking_execution = cls["booking_execution"]
|
37
|
+
|
38
|
+
for attempt_num in range(1, retry_attempts + 1):
|
39
|
+
booking_date = calculate_class_day(class_day, time_zone).strftime("%Y-%m-%d")
|
40
|
+
|
41
|
+
try:
|
42
|
+
logger.info(f"Fetching available slots for {activity} on {booking_date}")
|
43
|
+
available_slots = bot.daily_slots(activity=activity, day=booking_date)
|
44
|
+
|
45
|
+
matching_slots = available_slots[available_slots["start_timestamp"] == f"{booking_date} {class_time}"]
|
46
|
+
if matching_slots.empty:
|
47
|
+
_raise_no_matching_slots_error(activity, class_time, booking_date)
|
48
|
+
|
49
|
+
if booking_execution != "now":
|
50
|
+
logger.info(f"Waiting {offset_seconds} seconds before attempting booking.")
|
51
|
+
time.sleep(offset_seconds)
|
52
|
+
|
53
|
+
slot_id = matching_slots.iloc[0]["start_timestamp"]
|
54
|
+
logger.info(f"Attempting to book slot for {activity} at {slot_id} (Attempt {attempt_num}/{retry_attempts})")
|
55
|
+
bot.book(activity=activity, start_time=slot_id)
|
56
|
+
logger.info(f"Successfully booked {activity} at {slot_id}")
|
57
|
+
|
58
|
+
except Exception as e:
|
59
|
+
error_str = str(e)
|
60
|
+
logger.warning(f"Attempt {attempt_num} failed for {activity}: {error_str}")
|
61
|
+
|
62
|
+
if ErrorMessages.slot_already_booked() in error_str:
|
63
|
+
logger.warning(f"{activity} at {class_time} on {booking_date} is already booked; skipping retry.")
|
64
|
+
return
|
65
|
+
|
66
|
+
if attempt_num < retry_attempts:
|
67
|
+
logger.info(f"Retrying in {retry_delay_minutes} minutes...")
|
68
|
+
time.sleep(retry_delay_minutes * 60)
|
69
|
+
else:
|
70
|
+
return
|
71
|
+
|
72
|
+
logger.error(f"Failed to book {activity} after {retry_attempts} attempts.")
|
73
|
+
|
74
|
+
|
75
|
+
def schedule_bookings(
|
76
|
+
bot: SportBot,
|
77
|
+
config: Dict[str, Any],
|
78
|
+
cls: Dict[str, Any],
|
79
|
+
offset_seconds: int,
|
80
|
+
retry_attempts: int,
|
81
|
+
retry_delay_minutes: int,
|
82
|
+
time_zone: str = "Europe/Madrid",
|
83
|
+
) -> None:
|
84
|
+
booking_execution = cls["booking_execution"]
|
85
|
+
weekly = cls["weekly"]
|
86
|
+
activity = cls["activity"]
|
87
|
+
class_day = cls["class_day"]
|
88
|
+
class_time = cls["class_time"]
|
89
|
+
|
90
|
+
if weekly:
|
91
|
+
# For weekly bookings, schedule recurring jobs
|
92
|
+
execution_day, execution_time = booking_execution.split()
|
93
|
+
logger.info(
|
94
|
+
f"Class '{activity}' on {class_day} at {class_time} "
|
95
|
+
f"will be booked every {execution_day} at {execution_time}."
|
96
|
+
)
|
97
|
+
|
98
|
+
def booking_task() -> None:
|
99
|
+
try:
|
100
|
+
logger.info("Re-authenticating before weekly booking...")
|
101
|
+
bot.login(config["email"], config["password"], config["centre"])
|
102
|
+
logger.info("Re-authentication successful.")
|
103
|
+
attempt_booking(
|
104
|
+
bot,
|
105
|
+
cls,
|
106
|
+
offset_seconds,
|
107
|
+
retry_attempts,
|
108
|
+
retry_delay_minutes,
|
109
|
+
time_zone,
|
110
|
+
)
|
111
|
+
except Exception:
|
112
|
+
logger.exception(f"Failed to execute weekly booking task for {activity}")
|
113
|
+
|
114
|
+
# e.g., schedule.every().monday.at("HH:MM:SS").do(...)
|
115
|
+
getattr(schedule.every(), execution_day.lower()).at(execution_time).do(booking_task)
|
116
|
+
|
117
|
+
else:
|
118
|
+
# For one-off (non-weekly) bookings, calculate exact date/time
|
119
|
+
next_execution = calculate_next_execution(booking_execution, time_zone)
|
120
|
+
tz = pytz.timezone(time_zone)
|
121
|
+
|
122
|
+
day_of_week_target = DAY_MAP[class_day.lower().strip()]
|
123
|
+
execution_day_of_week = next_execution.weekday()
|
124
|
+
|
125
|
+
days_to_class = (day_of_week_target - execution_day_of_week + 7) % 7
|
126
|
+
planned_class_date_dt = next_execution + timedelta(days=days_to_class)
|
127
|
+
planned_class_date_str = planned_class_date_dt.strftime("%Y-%m-%d (%A)")
|
128
|
+
|
129
|
+
next_execution_str = next_execution.strftime("%Y-%m-%d (%A) %H:%M:%S %z")
|
130
|
+
|
131
|
+
logger.info(
|
132
|
+
f"Class '{activity}' on {planned_class_date_str} at {class_time} "
|
133
|
+
f"will be booked on {next_execution_str}."
|
134
|
+
)
|
135
|
+
|
136
|
+
# Wait until the next execution time
|
137
|
+
time_until_execution = (next_execution - datetime.now(tz)).total_seconds()
|
138
|
+
time.sleep(max(0, time_until_execution))
|
139
|
+
|
140
|
+
attempt_booking(
|
141
|
+
bot,
|
142
|
+
cls,
|
143
|
+
offset_seconds,
|
144
|
+
retry_attempts=retry_attempts,
|
145
|
+
retry_delay_minutes=retry_delay_minutes,
|
146
|
+
time_zone=time_zone,
|
147
|
+
)
|
@@ -0,0 +1,54 @@
|
|
1
|
+
from datetime import datetime
|
2
|
+
from typing import Any, Dict
|
3
|
+
|
4
|
+
from pysportbot import SportBot
|
5
|
+
from pysportbot.utils.errors import ErrorMessages
|
6
|
+
from pysportbot.utils.logger import get_logger
|
7
|
+
|
8
|
+
logger = get_logger(__name__)
|
9
|
+
|
10
|
+
DAY_MAP = {"monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3, "friday": 4, "saturday": 5, "sunday": 6}
|
11
|
+
|
12
|
+
|
13
|
+
def validate_config(config: Dict[str, Any]) -> None:
|
14
|
+
required_keys = ["email", "password", "centre", "classes"]
|
15
|
+
for key in required_keys:
|
16
|
+
if key not in config:
|
17
|
+
raise ValueError(ErrorMessages.missing_required_key(key))
|
18
|
+
|
19
|
+
for cls in config["classes"]:
|
20
|
+
if (
|
21
|
+
"activity" not in cls
|
22
|
+
or "class_day" not in cls
|
23
|
+
or "class_time" not in cls
|
24
|
+
or "booking_execution" not in cls
|
25
|
+
or "weekly" not in cls
|
26
|
+
):
|
27
|
+
raise ValueError(ErrorMessages.invalid_class_definition())
|
28
|
+
|
29
|
+
if cls["weekly"] and cls["booking_execution"] == "now":
|
30
|
+
raise ValueError(ErrorMessages.invalid_weekly_now())
|
31
|
+
|
32
|
+
if cls["booking_execution"] != "now":
|
33
|
+
day_and_time = cls["booking_execution"].split()
|
34
|
+
if len(day_and_time) != 2:
|
35
|
+
raise ValueError(ErrorMessages.invalid_booking_execution_format())
|
36
|
+
_, exec_time = day_and_time
|
37
|
+
try:
|
38
|
+
datetime.strptime(exec_time, "%H:%M:%S")
|
39
|
+
except ValueError as err:
|
40
|
+
raise ValueError(ErrorMessages.invalid_booking_execution_format()) from err
|
41
|
+
|
42
|
+
|
43
|
+
def validate_activities(bot: SportBot, config: Dict[str, Any]) -> None:
|
44
|
+
logger.info("Fetching available activities for validation...")
|
45
|
+
available_activities = bot.activities()
|
46
|
+
available_activity_names = set(available_activities["name_activity"].tolist())
|
47
|
+
|
48
|
+
logger.debug(f"Available activities: {available_activity_names}")
|
49
|
+
|
50
|
+
for cls in config["classes"]:
|
51
|
+
activity_name = cls["activity"]
|
52
|
+
if activity_name not in available_activity_names:
|
53
|
+
raise ValueError(ErrorMessages.activity_not_found(activity_name, list(available_activity_names)))
|
54
|
+
logger.info("All activities in the configuration file have been validated.")
|
@@ -0,0 +1,61 @@
|
|
1
|
+
from datetime import datetime, timedelta
|
2
|
+
|
3
|
+
import pytz
|
4
|
+
|
5
|
+
from .config_validator import DAY_MAP
|
6
|
+
|
7
|
+
|
8
|
+
def calculate_next_execution(booking_execution: str, time_zone: str = "Europe/Madrid") -> datetime:
|
9
|
+
"""
|
10
|
+
Calculate the next execution time based on the booking execution day and time.
|
11
|
+
|
12
|
+
Args:
|
13
|
+
booking_execution (str): Execution in the format 'Day HH:MM:SS' or 'now'.
|
14
|
+
time_zone (str): The timezone for localization.
|
15
|
+
|
16
|
+
Returns:
|
17
|
+
datetime: The next execution time as a timezone-aware datetime.
|
18
|
+
"""
|
19
|
+
tz = pytz.timezone(time_zone)
|
20
|
+
|
21
|
+
# Handle the special case where execution is "now"
|
22
|
+
if booking_execution == "now":
|
23
|
+
return datetime.now(tz)
|
24
|
+
|
25
|
+
execution_day, execution_time = booking_execution.split()
|
26
|
+
now = datetime.now(tz)
|
27
|
+
|
28
|
+
# Map the day name to a day-of-week index
|
29
|
+
day_of_week_target = DAY_MAP[execution_day.lower().strip()]
|
30
|
+
current_weekday = now.weekday()
|
31
|
+
|
32
|
+
# Parse the execution time
|
33
|
+
exec_time = datetime.strptime(execution_time, "%H:%M:%S").time()
|
34
|
+
|
35
|
+
# Determine the next execution date
|
36
|
+
if day_of_week_target == current_weekday and now.time() < exec_time:
|
37
|
+
next_execution_date = now
|
38
|
+
else:
|
39
|
+
days_ahead = day_of_week_target - current_weekday
|
40
|
+
if days_ahead <= 0:
|
41
|
+
days_ahead += 7
|
42
|
+
next_execution_date = now + timedelta(days=days_ahead)
|
43
|
+
|
44
|
+
# Combine date and time
|
45
|
+
execution_datetime = datetime.combine(next_execution_date.date(), exec_time)
|
46
|
+
|
47
|
+
# Localize if naive
|
48
|
+
if execution_datetime.tzinfo is None:
|
49
|
+
execution_datetime = tz.localize(execution_datetime)
|
50
|
+
|
51
|
+
return execution_datetime
|
52
|
+
|
53
|
+
|
54
|
+
def calculate_class_day(class_day: str, time_zone: str = "Europe/Madrid") -> datetime:
|
55
|
+
tz = pytz.timezone(time_zone)
|
56
|
+
now = datetime.now(tz)
|
57
|
+
target_weekday = DAY_MAP[class_day.lower().strip()]
|
58
|
+
days_ahead = target_weekday - now.weekday()
|
59
|
+
if days_ahead < 0:
|
60
|
+
days_ahead += 7
|
61
|
+
return now + timedelta(days=days_ahead)
|
@@ -0,0 +1,47 @@
|
|
1
|
+
import time
|
2
|
+
from typing import Any, Dict
|
3
|
+
|
4
|
+
import schedule
|
5
|
+
|
6
|
+
from pysportbot import SportBot
|
7
|
+
from pysportbot.utils.logger import get_logger
|
8
|
+
|
9
|
+
from .booking import schedule_bookings
|
10
|
+
from .config_validator import validate_activities, validate_config
|
11
|
+
|
12
|
+
logger = get_logger(__name__)
|
13
|
+
|
14
|
+
|
15
|
+
def run_service(
|
16
|
+
config: Dict[str, Any],
|
17
|
+
offset_seconds: int,
|
18
|
+
retry_attempts: int,
|
19
|
+
retry_delay_minutes: int,
|
20
|
+
time_zone: str = "Europe/Madrid",
|
21
|
+
) -> None:
|
22
|
+
|
23
|
+
# Validate the configuration file
|
24
|
+
validate_config(config)
|
25
|
+
|
26
|
+
bot = SportBot()
|
27
|
+
bot.login(config["email"], config["password"], config["centre"])
|
28
|
+
|
29
|
+
# Validate the activities in the configuration file
|
30
|
+
validate_activities(bot, config)
|
31
|
+
|
32
|
+
for cls in config["classes"]:
|
33
|
+
schedule_bookings(
|
34
|
+
bot,
|
35
|
+
config,
|
36
|
+
cls,
|
37
|
+
offset_seconds,
|
38
|
+
retry_attempts,
|
39
|
+
retry_delay_minutes,
|
40
|
+
time_zone,
|
41
|
+
)
|
42
|
+
|
43
|
+
if schedule.jobs:
|
44
|
+
logger.info("Weekly bookings scheduled. Running the scheduler...")
|
45
|
+
while True:
|
46
|
+
schedule.run_pending()
|
47
|
+
time.sleep(1)
|
pysportbot/session.py
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
from requests import Session as RequestsSession
|
2
|
+
|
3
|
+
from .utils.logger import get_logger
|
4
|
+
|
5
|
+
logger = get_logger(__name__)
|
6
|
+
|
7
|
+
|
8
|
+
class Session:
|
9
|
+
"""Handles the session and headers for HTTP requests."""
|
10
|
+
|
11
|
+
def __init__(self) -> None:
|
12
|
+
"""Initialize a new session and set default headers."""
|
13
|
+
self.session: RequestsSession = RequestsSession()
|
14
|
+
self.headers: dict[str, str] = {
|
15
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0",
|
16
|
+
"Accept": "application/json, text/plain, */*",
|
17
|
+
"Accept-Language": "en-US,en;q=0.5",
|
18
|
+
"DNT": "1",
|
19
|
+
"Sec-GPC": "1",
|
20
|
+
"Connection": "keep-alive",
|
21
|
+
"Upgrade-Insecure-Requests": "1",
|
22
|
+
"Sec-Fetch-Dest": "empty",
|
23
|
+
"Sec-Fetch-Mode": "cors",
|
24
|
+
"Sec-Fetch-Site": "same-origin",
|
25
|
+
"TE": "trailers",
|
26
|
+
}
|
27
|
+
logger.info("Session initialized.")
|
28
|
+
|
29
|
+
def set_header(self, key: str, value: str) -> None:
|
30
|
+
"""
|
31
|
+
Set or update a header in the session.
|
32
|
+
|
33
|
+
Args:
|
34
|
+
key (str): The header key to set or update.
|
35
|
+
value (str): The header value to assign.
|
36
|
+
"""
|
37
|
+
self.headers[key] = value
|
38
|
+
logger.debug(f"Header updated: {key} = {value}")
|
39
|
+
|
40
|
+
def get_session(self) -> RequestsSession:
|
41
|
+
"""
|
42
|
+
Get the underlying Requests session object.
|
43
|
+
|
44
|
+
Returns:
|
45
|
+
RequestsSession: The Requests session instance.
|
46
|
+
"""
|
47
|
+
return self.session
|
File without changes
|
@@ -0,0 +1,140 @@
|
|
1
|
+
import logging
|
2
|
+
|
3
|
+
|
4
|
+
class ErrorMessages:
|
5
|
+
"""Centralized error messages for the application."""
|
6
|
+
|
7
|
+
@staticmethod
|
8
|
+
def no_centre_selected() -> str:
|
9
|
+
"""Return an error message for no centre selected."""
|
10
|
+
return "No centre selected. Please set a centre first."
|
11
|
+
|
12
|
+
@staticmethod
|
13
|
+
def centre_not_found(centre: str) -> str:
|
14
|
+
"""Return an error message for a centre not found."""
|
15
|
+
return f"Centre '{centre}' not found. Please check the list of available centres."
|
16
|
+
|
17
|
+
@staticmethod
|
18
|
+
def missing_required_key(key: str) -> str:
|
19
|
+
return f"Missing required key in config: {key}"
|
20
|
+
|
21
|
+
@staticmethod
|
22
|
+
def invalid_class_definition() -> str:
|
23
|
+
return "Each class must include 'activity', 'class_day', " "'class_time', 'booking_execution', and 'weekly'."
|
24
|
+
|
25
|
+
@staticmethod
|
26
|
+
def invalid_weekly_now() -> str:
|
27
|
+
return "Invalid combination: cannot use weekly=True with booking_execution='now'."
|
28
|
+
|
29
|
+
@staticmethod
|
30
|
+
def invalid_booking_execution_format() -> str:
|
31
|
+
return "Invalid booking_execution format. Use 'now' or 'Day HH:MM:SS'."
|
32
|
+
|
33
|
+
@staticmethod
|
34
|
+
def no_matching_slots_for_time(activity: str, class_time: str, booking_date: str) -> str:
|
35
|
+
return f"No matching slots available for {activity} at {class_time} on {booking_date}"
|
36
|
+
|
37
|
+
@staticmethod
|
38
|
+
def not_logged_in() -> str:
|
39
|
+
"""Return an error message for not being logged in."""
|
40
|
+
return "You must log in first."
|
41
|
+
|
42
|
+
@staticmethod
|
43
|
+
def login_failed() -> str:
|
44
|
+
"""Return an error message for a failed login."""
|
45
|
+
return "Login failed. Please check your credentials and try again."
|
46
|
+
|
47
|
+
@staticmethod
|
48
|
+
def invalid_log_level(level: str) -> ValueError:
|
49
|
+
"""
|
50
|
+
Generate a ValueError for an invalid logging level.
|
51
|
+
|
52
|
+
Args:
|
53
|
+
level (str): The invalid logging level.
|
54
|
+
|
55
|
+
Returns:
|
56
|
+
ValueError: A ValueError with a detailed message.
|
57
|
+
"""
|
58
|
+
valid_levels = ", ".join(logging._nameToLevel.keys())
|
59
|
+
return ValueError(f"Invalid logging level: {level}. Valid levels are {valid_levels}.")
|
60
|
+
|
61
|
+
@staticmethod
|
62
|
+
def endpoint_not_found(name: str) -> ValueError:
|
63
|
+
"""
|
64
|
+
Generate a ValueError for a missing endpoint.
|
65
|
+
|
66
|
+
Args:
|
67
|
+
name (str): The name of the endpoint that was not found.
|
68
|
+
|
69
|
+
Returns:
|
70
|
+
ValueError: A ValueError with a detailed message.
|
71
|
+
"""
|
72
|
+
return ValueError(f"Endpoint '{name}' does not exist.")
|
73
|
+
|
74
|
+
@staticmethod
|
75
|
+
def no_activities_loaded() -> str:
|
76
|
+
"""Return an error message for no activities loaded."""
|
77
|
+
return "No activities loaded. Please log in first."
|
78
|
+
|
79
|
+
@staticmethod
|
80
|
+
def failed_fetch(resource: str) -> str:
|
81
|
+
"""Return an error message for a failed fetch request."""
|
82
|
+
return f"Failed to fetch {resource}. Please try again later."
|
83
|
+
|
84
|
+
@staticmethod
|
85
|
+
def activity_not_found(activity_name: str, available_activities: list) -> str:
|
86
|
+
"""Return an error message for an activity not found."""
|
87
|
+
return (
|
88
|
+
f"No activity found with the name '{activity_name}'. "
|
89
|
+
f"Available activities are: {', '.join(available_activities)}."
|
90
|
+
)
|
91
|
+
|
92
|
+
@staticmethod
|
93
|
+
def no_slots(activity_name: str, day: str) -> str:
|
94
|
+
"""Return a warning message when no slots are available."""
|
95
|
+
return f"No slots available for activity '{activity_name}' on {day}."
|
96
|
+
|
97
|
+
@staticmethod
|
98
|
+
def no_matching_slots(activity_name: str, day: str) -> str:
|
99
|
+
"""Return a warning message for no matching slots found."""
|
100
|
+
return f"No matching slots found for activity '{activity_name}' on {day}."
|
101
|
+
|
102
|
+
@staticmethod
|
103
|
+
def slot_not_found(activity_name: str, start_time: str) -> str:
|
104
|
+
"""Return an error message for a slot not found."""
|
105
|
+
return f"No slot found for activity '{activity_name}' at {start_time}."
|
106
|
+
|
107
|
+
@staticmethod
|
108
|
+
def slot_not_bookable_yet() -> str:
|
109
|
+
"""Return an error message for a slot not bookable yet."""
|
110
|
+
return "The slot is not bookable yet."
|
111
|
+
|
112
|
+
@staticmethod
|
113
|
+
def slot_already_booked() -> str:
|
114
|
+
"""Return an error message for a slot already booked."""
|
115
|
+
return "The slot is already booked."
|
116
|
+
|
117
|
+
@staticmethod
|
118
|
+
def slot_unavailable() -> str:
|
119
|
+
"""Return an error message for a slot that is unavailable."""
|
120
|
+
return "The slot is not available."
|
121
|
+
|
122
|
+
@staticmethod
|
123
|
+
def cancellation_failed() -> str:
|
124
|
+
"""Return an error message for a failed cancellation."""
|
125
|
+
return "Cancellation failed. The slot may not have been booked."
|
126
|
+
|
127
|
+
@staticmethod
|
128
|
+
def failed_login() -> str:
|
129
|
+
"""Return an error message for a failed login."""
|
130
|
+
return "Login failed. Please check your credentials and try again."
|
131
|
+
|
132
|
+
@staticmethod
|
133
|
+
def failed_login_nubapp() -> str:
|
134
|
+
"""Return an error message for a failed login to Nubapp."""
|
135
|
+
return "Login to Nubapp failed. Please try again later."
|
136
|
+
|
137
|
+
@staticmethod
|
138
|
+
def unknown_error(action: str) -> str:
|
139
|
+
"""Return an error message for an unknown error."""
|
140
|
+
return f"An unknown error occurred during {action}. Please try again later."
|
@@ -0,0 +1,78 @@
|
|
1
|
+
import logging
|
2
|
+
from typing import ClassVar
|
3
|
+
|
4
|
+
from .errors import ErrorMessages
|
5
|
+
|
6
|
+
|
7
|
+
class ColorFormatter(logging.Formatter):
|
8
|
+
"""Custom formatter to add color-coded log levels."""
|
9
|
+
|
10
|
+
COLORS: ClassVar[dict[str, str]] = {
|
11
|
+
"DEBUG": "\033[94m", # Blue
|
12
|
+
"INFO": "\033[92m", # Green
|
13
|
+
"WARNING": "\033[93m", # Yellow
|
14
|
+
"ERROR": "\033[91m", # Red
|
15
|
+
"RESET": "\033[0m", # Reset
|
16
|
+
}
|
17
|
+
|
18
|
+
def format(self, record: logging.LogRecord) -> str:
|
19
|
+
"""
|
20
|
+
Format the log record with color-coded log levels.
|
21
|
+
|
22
|
+
Args:
|
23
|
+
record (logging.LogRecord): The log record to format.
|
24
|
+
|
25
|
+
Returns:
|
26
|
+
str: The formatted log record as a string.
|
27
|
+
"""
|
28
|
+
color = self.COLORS.get(record.levelname, self.COLORS["RESET"])
|
29
|
+
record.levelname = f"{color}{record.levelname}{self.COLORS['RESET']}"
|
30
|
+
return super().format(record)
|
31
|
+
|
32
|
+
|
33
|
+
def setup_logger(level: str = "INFO") -> None:
|
34
|
+
"""
|
35
|
+
Configure the root logger with color-coded output.
|
36
|
+
|
37
|
+
Args:
|
38
|
+
level (str): The desired logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
|
39
|
+
"""
|
40
|
+
root_logger = logging.getLogger()
|
41
|
+
root_logger.setLevel(logging._nameToLevel[level.upper()])
|
42
|
+
|
43
|
+
if not root_logger.hasHandlers(): # Avoid duplicate handlers
|
44
|
+
handler = logging.StreamHandler()
|
45
|
+
handler.setLevel(logging._nameToLevel[level.upper()])
|
46
|
+
formatter = ColorFormatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
|
47
|
+
handler.setFormatter(formatter)
|
48
|
+
root_logger.addHandler(handler)
|
49
|
+
|
50
|
+
|
51
|
+
def set_log_level(level: str) -> None:
|
52
|
+
"""
|
53
|
+
Change the logging level of the root logger.
|
54
|
+
|
55
|
+
Args:
|
56
|
+
level (str): The desired logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
|
57
|
+
|
58
|
+
Raises:
|
59
|
+
ValueError: If the provided logging level is invalid.
|
60
|
+
"""
|
61
|
+
level = level.upper()
|
62
|
+
if level not in logging._nameToLevel:
|
63
|
+
raise ErrorMessages.invalid_log_level(level)
|
64
|
+
logging.getLogger().setLevel(logging._nameToLevel[level])
|
65
|
+
logging.info(f"Log level changed to {level}.")
|
66
|
+
|
67
|
+
|
68
|
+
def get_logger(name: str) -> logging.Logger:
|
69
|
+
"""
|
70
|
+
Retrieve a logger by name.
|
71
|
+
|
72
|
+
Args:
|
73
|
+
name (str): The name of the logger.
|
74
|
+
|
75
|
+
Returns:
|
76
|
+
logging.Logger: The logger instance.
|
77
|
+
"""
|
78
|
+
return logging.getLogger(name)
|
pysportbot/utils/time.py
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
from datetime import datetime, time
|
2
|
+
|
3
|
+
import pytz
|
4
|
+
|
5
|
+
|
6
|
+
def get_unix_day_bounds(date_string: str, fmt: str = "%Y-%m-%d", tz: str = "UTC") -> tuple[int, int]:
|
7
|
+
"""
|
8
|
+
Get the Unix timestamp bounds for a given day.
|
9
|
+
|
10
|
+
Args:
|
11
|
+
date_string (str): The date in 'YYYY-MM-DD' format.
|
12
|
+
fmt (str): The format of the input date string.
|
13
|
+
tz (str): The timezone name.
|
14
|
+
|
15
|
+
Returns:
|
16
|
+
tuple[int, int]: The start and end Unix timestamps for the day.
|
17
|
+
"""
|
18
|
+
tzinfo = pytz.timezone(tz)
|
19
|
+
date = datetime.strptime(date_string, fmt).replace(tzinfo=tzinfo)
|
20
|
+
return (
|
21
|
+
int(datetime.combine(date.date(), time.min, tzinfo=tzinfo).timestamp()),
|
22
|
+
int(datetime.combine(date.date(), time.max, tzinfo=tzinfo).timestamp()),
|
23
|
+
)
|
24
|
+
|
25
|
+
|
26
|
+
def format_unix_to_date(unix_timestamp: int, fmt: str = "%Y-%m-%d %H:%M:%S", tz: str = "UTC") -> str:
|
27
|
+
"""
|
28
|
+
Convert a Unix timestamp to a formatted date string.
|
29
|
+
|
30
|
+
Args:
|
31
|
+
unix_timestamp (int): The Unix timestamp to convert.
|
32
|
+
fmt (str): The desired output format.
|
33
|
+
tz (str): The timezone name.
|
34
|
+
|
35
|
+
Returns:
|
36
|
+
str: The formatted date string.
|
37
|
+
"""
|
38
|
+
tzinfo = pytz.timezone(tz)
|
39
|
+
return datetime.fromtimestamp(unix_timestamp, tz=tzinfo).strftime(fmt)
|
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025, Joshua Falco Beirer
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|