pysportbot 0.0.5__tar.gz → 0.0.7__tar.gz

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 (24) hide show
  1. {pysportbot-0.0.5 → pysportbot-0.0.7}/PKG-INFO +6 -26
  2. {pysportbot-0.0.5 → pysportbot-0.0.7}/README.md +5 -24
  3. {pysportbot-0.0.5 → pysportbot-0.0.7}/pyproject.toml +1 -2
  4. {pysportbot-0.0.5 → pysportbot-0.0.7}/pysportbot/activities.py +1 -1
  5. {pysportbot-0.0.5 → pysportbot-0.0.7}/pysportbot/service/__main__.py +5 -7
  6. pysportbot-0.0.7/pysportbot/service/booking.py +157 -0
  7. {pysportbot-0.0.5 → pysportbot-0.0.7}/pysportbot/service/config_validator.py +39 -20
  8. pysportbot-0.0.7/pysportbot/service/service.py +59 -0
  9. {pysportbot-0.0.5 → pysportbot-0.0.7}/pysportbot/utils/errors.py +1 -5
  10. {pysportbot-0.0.5 → pysportbot-0.0.7}/pysportbot/utils/logger.py +47 -7
  11. pysportbot-0.0.5/pysportbot/service/booking.py +0 -147
  12. pysportbot-0.0.5/pysportbot/service/service.py +0 -49
  13. {pysportbot-0.0.5 → pysportbot-0.0.7}/LICENSE +0 -0
  14. {pysportbot-0.0.5 → pysportbot-0.0.7}/pysportbot/__init__.py +0 -0
  15. {pysportbot-0.0.5 → pysportbot-0.0.7}/pysportbot/authenticator.py +0 -0
  16. {pysportbot-0.0.5 → pysportbot-0.0.7}/pysportbot/bookings.py +0 -0
  17. {pysportbot-0.0.5 → pysportbot-0.0.7}/pysportbot/centres.py +0 -0
  18. {pysportbot-0.0.5 → pysportbot-0.0.7}/pysportbot/endpoints.py +0 -0
  19. {pysportbot-0.0.5 → pysportbot-0.0.7}/pysportbot/service/__init__.py +0 -0
  20. {pysportbot-0.0.5 → pysportbot-0.0.7}/pysportbot/service/config_loader.py +0 -0
  21. {pysportbot-0.0.5 → pysportbot-0.0.7}/pysportbot/service/scheduling.py +0 -0
  22. {pysportbot-0.0.5 → pysportbot-0.0.7}/pysportbot/session.py +0 -0
  23. {pysportbot-0.0.5 → pysportbot-0.0.7}/pysportbot/utils/__init__.py +0 -0
  24. {pysportbot-0.0.5 → pysportbot-0.0.7}/pysportbot/utils/time.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pysportbot
3
- Version: 0.0.5
3
+ Version: 0.0.7
4
4
  Summary: A python-based bot for automatic resasports slot booking
5
5
  Home-page: https://github.com/jbeirer/resasports-bot
6
6
  Author: Joshua Falco Beirer
@@ -15,7 +15,6 @@ Requires-Dist: beautifulsoup4 (>=4.12.3,<5.0.0)
15
15
  Requires-Dist: pandas (>=2.2.3,<3.0.0)
16
16
  Requires-Dist: pytz (>=2024.2,<2025.0)
17
17
  Requires-Dist: requests (>=2.32.3,<3.0.0)
18
- Requires-Dist: schedule (>=1.2.2,<2.0.0)
19
18
  Project-URL: Documentation, https://jbeirer.github.io/resasports-bot/
20
19
  Project-URL: Repository, https://github.com/jbeirer/resasports-bot
21
20
  Description-Content-Type: text/markdown
@@ -70,7 +69,7 @@ You can easily run `pysportbot` as a service to manage your bookings automatical
70
69
  ```bash
71
70
  python -m pysportbot.service --config config.json
72
71
  ```
73
- The service requires a `json` configuration file that specifies your user data and how you would like to book your classes. Currently, three types of configuration are supported:
72
+ The service requires a `json` configuration file that specifies your user data and how you would like to book your classes. Currently, two types of configuration are supported:
74
73
 
75
74
  ##### 1. Book an upcoming class now
76
75
 
@@ -81,13 +80,13 @@ Let's say you would like to book Yoga next Monday at 18:00:00, then your `config
81
80
  "email": "your-email",
82
81
  "password": "your-password",
83
82
  "center": "your-gym-name",
83
+ "booking_execution": "now",
84
+
84
85
  "classes": [
85
86
  {
86
87
  "activity": "Yoga",
87
88
  "class_day": "Monday",
88
89
  "class_time": "18:00:00",
89
- "booking_execution": "now",
90
- "weekly": false
91
90
  }
92
91
  ]
93
92
  }
@@ -101,32 +100,13 @@ Let's say you would like to book Yoga next Monday at 18:00:00, but the execution
101
100
  "email": "your-email",
102
101
  "password": "your-password",
103
102
  "center": "your-gym-name",
104
- "classes": [
105
- {
106
- "activity": "Yoga",
107
- "class_day": "Monday",
108
- "class_time": "18:00:00",
109
- "booking_execution": "Friday 07:30:00",
110
- "weekly": false
111
- }
112
- ]
113
- }
114
- ```
115
- ##### 3. Schedule weekly booking at specific execution day and time
116
- Let's say you would like to book Yoga every Monday at 18:00:00 and the booking execution should be every Friday at 07:30:00 then your `config.json` would look like:
103
+ "booking_execution": "Friday 07:30:00",
117
104
 
118
- ```json
119
- {
120
- "email": "your-email",
121
- "password": "your-password",
122
- "center": "your-gym-name",
123
105
  "classes": [
124
106
  {
125
107
  "activity": "Yoga",
126
108
  "class_day": "Monday",
127
109
  "class_time": "18:00:00",
128
- "booking_execution": "Friday 07:30:00",
129
- "weekly": true
130
110
  }
131
111
  ]
132
112
  }
@@ -139,7 +119,7 @@ python -m pysportbot.service --help
139
119
  ```
140
120
  Currently supported options include
141
121
  1. ```--retry-attempts``` sets the number of retries attempted in case a booking attempt fails
142
- 2. ```--retry-delay-minutes``` sets the delay in minutes between retries for weekly bookings
122
+ 2. ```--retry-delay``` sets the delay in seconds between retries booking retries
143
123
  3. ```--time-zone``` sets the time zone for the service (e.g. Europe/Madrid)
144
124
  4. ```--log-level``` sets the log-level of the service (e.g. DEBUG, INFO, WARNING, ERROR)
145
125
 
@@ -48,7 +48,7 @@ You can easily run `pysportbot` as a service to manage your bookings automatical
48
48
  ```bash
49
49
  python -m pysportbot.service --config config.json
50
50
  ```
51
- The service requires a `json` configuration file that specifies your user data and how you would like to book your classes. Currently, three types of configuration are supported:
51
+ The service requires a `json` configuration file that specifies your user data and how you would like to book your classes. Currently, two types of configuration are supported:
52
52
 
53
53
  ##### 1. Book an upcoming class now
54
54
 
@@ -59,13 +59,13 @@ Let's say you would like to book Yoga next Monday at 18:00:00, then your `config
59
59
  "email": "your-email",
60
60
  "password": "your-password",
61
61
  "center": "your-gym-name",
62
+ "booking_execution": "now",
63
+
62
64
  "classes": [
63
65
  {
64
66
  "activity": "Yoga",
65
67
  "class_day": "Monday",
66
68
  "class_time": "18:00:00",
67
- "booking_execution": "now",
68
- "weekly": false
69
69
  }
70
70
  ]
71
71
  }
@@ -79,32 +79,13 @@ Let's say you would like to book Yoga next Monday at 18:00:00, but the execution
79
79
  "email": "your-email",
80
80
  "password": "your-password",
81
81
  "center": "your-gym-name",
82
- "classes": [
83
- {
84
- "activity": "Yoga",
85
- "class_day": "Monday",
86
- "class_time": "18:00:00",
87
- "booking_execution": "Friday 07:30:00",
88
- "weekly": false
89
- }
90
- ]
91
- }
92
- ```
93
- ##### 3. Schedule weekly booking at specific execution day and time
94
- Let's say you would like to book Yoga every Monday at 18:00:00 and the booking execution should be every Friday at 07:30:00 then your `config.json` would look like:
82
+ "booking_execution": "Friday 07:30:00",
95
83
 
96
- ```json
97
- {
98
- "email": "your-email",
99
- "password": "your-password",
100
- "center": "your-gym-name",
101
84
  "classes": [
102
85
  {
103
86
  "activity": "Yoga",
104
87
  "class_day": "Monday",
105
88
  "class_time": "18:00:00",
106
- "booking_execution": "Friday 07:30:00",
107
- "weekly": true
108
89
  }
109
90
  ]
110
91
  }
@@ -117,7 +98,7 @@ python -m pysportbot.service --help
117
98
  ```
118
99
  Currently supported options include
119
100
  1. ```--retry-attempts``` sets the number of retries attempted in case a booking attempt fails
120
- 2. ```--retry-delay-minutes``` sets the delay in minutes between retries for weekly bookings
101
+ 2. ```--retry-delay``` sets the delay in seconds between retries booking retries
121
102
  3. ```--time-zone``` sets the time zone for the service (e.g. Europe/Madrid)
122
103
  4. ```--log-level``` sets the log-level of the service (e.g. DEBUG, INFO, WARNING, ERROR)
123
104
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "pysportbot"
3
- version = "v0.0.5"
3
+ version = "v0.0.7"
4
4
  description = " A python-based bot for automatic resasports slot booking"
5
5
  authors = ["Joshua Falco Beirer <jbeirer@cern.ch>"]
6
6
  repository = "https://github.com/jbeirer/resasports-bot"
@@ -16,7 +16,6 @@ requests = "^2.32.3"
16
16
  beautifulsoup4 = "^4.12.3"
17
17
  pandas = "^2.2.3"
18
18
  pytz = "^2024.2"
19
- schedule = "^1.2.2"
20
19
 
21
20
  [tool.poetry.group.dev.dependencies]
22
21
  pytest = "^8.3.4"
@@ -92,7 +92,7 @@ class Activities:
92
92
  logger.warning(warning_msg)
93
93
  return DataFrame()
94
94
 
95
- logger.info(f"Daily slots fetched for '{activity_name}' on {day}.")
95
+ logger.debug(f"Daily slots fetched for '{activity_name}' on {day}.")
96
96
 
97
97
  # Filter desired columns
98
98
  columns = [
@@ -10,11 +10,9 @@ from .service import run_service
10
10
  def main() -> None:
11
11
  parser = argparse.ArgumentParser(description="Run the pysportbot as a service.")
12
12
  parser.add_argument("--config", type=str, required=True, help="Path to the JSON configuration file.")
13
- parser.add_argument("--offset-seconds", type=int, default=10, help="Time offset in seconds before booking.")
14
- parser.add_argument("--retry-attempts", type=int, default=3, help="Number of retry attempts for weekly bookings.")
15
- parser.add_argument(
16
- "--retry-delay-minutes", type=int, default=2, help="Delay in minutes between retries for weekly bookings."
17
- )
13
+ parser.add_argument("--booking-delay", type=int, default=5, help="Global booking delay in seconds before booking.")
14
+ parser.add_argument("--retry-attempts", type=int, default=3, help="Number of retry attempts for bookings.")
15
+ parser.add_argument("--retry-delay", type=int, default=30, help="Delay in seconds between retries for bookings.")
18
16
  parser.add_argument("--time-zone", type=str, default="Europe/Madrid", help="Timezone for the service.")
19
17
  parser.add_argument("--log-level", type=str, default="INFO", help="Logging level for the service.")
20
18
  args = parser.parse_args()
@@ -22,9 +20,9 @@ def main() -> None:
22
20
  config: Dict[str, Any] = load_config(args.config)
23
21
  run_service(
24
22
  config,
25
- offset_seconds=args.offset_seconds,
23
+ booking_delay=args.booking_delay,
26
24
  retry_attempts=args.retry_attempts,
27
- retry_delay_minutes=args.retry_delay_minutes,
25
+ retry_delay=args.retry_delay,
28
26
  time_zone=args.time_zone,
29
27
  log_level=args.log_level,
30
28
  )
@@ -0,0 +1,157 @@
1
+ import time
2
+ from concurrent.futures import ThreadPoolExecutor, as_completed
3
+ from datetime import datetime
4
+ from typing import Any, Dict, List
5
+
6
+ import pytz
7
+
8
+ from pysportbot import SportBot
9
+ from pysportbot.utils.errors import ErrorMessages
10
+ from pysportbot.utils.logger import get_logger
11
+
12
+ from .scheduling import calculate_class_day, calculate_next_execution
13
+
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ def _raise_no_matching_slots_error(activity: str, class_time: str, booking_date: str) -> None:
18
+ raise ValueError(ErrorMessages.no_matching_slots_for_time(activity, class_time, booking_date))
19
+
20
+
21
+ def wait_for_execution(booking_execution: str, time_zone: str) -> None:
22
+ """
23
+ Wait until the specified global execution time.
24
+
25
+ Args:
26
+ booking_execution (str): Global execution time in "Day HH:MM:SS" format.
27
+ time_zone (str): Timezone for calculation.
28
+ """
29
+ tz = pytz.timezone(time_zone)
30
+ execution_time = calculate_next_execution(booking_execution, time_zone)
31
+ now = datetime.now(tz)
32
+ time_until_execution = (execution_time - now).total_seconds()
33
+
34
+ if time_until_execution > 0:
35
+ logger.info(
36
+ f"Waiting {time_until_execution:.2f} seconds until global execution time: "
37
+ f"{execution_time.strftime('%Y-%m-%d %H:%M:%S %z')}."
38
+ )
39
+ time.sleep(time_until_execution)
40
+
41
+
42
+ def attempt_booking(
43
+ bot: SportBot,
44
+ activity: str,
45
+ class_day: str,
46
+ class_time: str,
47
+ booking_delay: int,
48
+ retry_attempts: int = 1,
49
+ retry_delay: int = 0,
50
+ time_zone: str = "Europe/Madrid",
51
+ ) -> None:
52
+ """
53
+ Attempt to book a slot for the given class.
54
+
55
+ Args:
56
+ bot (SportBot): The SportBot instance.
57
+ activity (str): Activity name.
58
+ class_day (str): Day of the class.
59
+ class_time (str): Time of the class.
60
+ booking_delay (int): Delay before attempting booking.
61
+ retry_attempts (int): Number of retry attempts.
62
+ retry_delay (int): Delay between retries.
63
+ time_zone (str): Time zone for execution.
64
+ """
65
+ for attempt_num in range(1, retry_attempts + 1):
66
+ booking_date = calculate_class_day(class_day, time_zone).strftime("%Y-%m-%d")
67
+
68
+ try:
69
+ logger.info(f"Fetching available slots for '{activity}' on {booking_date}.")
70
+ available_slots = bot.daily_slots(activity=activity, day=booking_date)
71
+
72
+ matching_slots = available_slots[available_slots["start_timestamp"] == f"{booking_date} {class_time}"]
73
+ if matching_slots.empty:
74
+ _raise_no_matching_slots_error(activity, class_time, booking_date)
75
+
76
+ slot_id = matching_slots.iloc[0]["start_timestamp"]
77
+ logger.info(f"Attempting to book '{activity}' at {slot_id} (Attempt {attempt_num}/{retry_attempts}).")
78
+ bot.book(activity=activity, start_time=slot_id)
79
+
80
+ except Exception as e:
81
+ error_str = str(e)
82
+ logger.warning(f"Attempt {attempt_num} failed: {error_str}")
83
+
84
+ if ErrorMessages.slot_already_booked() in error_str:
85
+ logger.warning("Slot already booked; skipping further retries.")
86
+ return
87
+
88
+ if attempt_num < retry_attempts:
89
+ logger.info(f"Retrying in {retry_delay} seconds...")
90
+ time.sleep(retry_delay)
91
+ else:
92
+ # If the booking attempt succeeds, log and exit
93
+ logger.info(f"Successfully booked '{activity}' at {slot_id}.")
94
+ return
95
+
96
+ # If all attempts fail, log an error
97
+ logger.error(f"Failed to book '{activity}' after {retry_attempts} attempts.")
98
+
99
+
100
+ def schedule_bookings(
101
+ bot: SportBot,
102
+ classes: List[Dict[str, Any]],
103
+ booking_execution: str,
104
+ booking_delay: int,
105
+ retry_attempts: int,
106
+ retry_delay: int,
107
+ time_zone: str,
108
+ max_threads: int,
109
+ ) -> None:
110
+ """
111
+ Execute bookings in parallel with a limit on the number of threads.
112
+
113
+ Args:
114
+ bot (SportBot): The SportBot instance.
115
+ classes (list): List of class configurations.
116
+ booking_execution (str): Global execution time for all bookings.
117
+ booking_delay (int): Delay before each booking attempt.
118
+ retry_attempts (int): Number of retry attempts.
119
+ retry_delay (int): Delay between retries.
120
+ time_zone (str): Timezone for booking.
121
+ max_threads (int): Maximum number of threads to use.
122
+ """
123
+ # Log planned bookings
124
+ for cls in classes:
125
+ logger.info(f"Scheduled to book '{cls['activity']}' next {cls['class_day']} at {cls['class_time']}.")
126
+
127
+ # Wait globally before starting bookings
128
+ wait_for_execution(booking_execution, time_zone)
129
+
130
+ # Global booking delay
131
+ logger.info(f"Waiting {booking_delay} seconds before attempting booking.")
132
+ time.sleep(booking_delay)
133
+
134
+ with ThreadPoolExecutor(max_workers=max_threads) as executor:
135
+ future_to_class = {
136
+ executor.submit(
137
+ attempt_booking,
138
+ bot,
139
+ cls["activity"],
140
+ cls["class_day"],
141
+ cls["class_time"],
142
+ booking_delay,
143
+ retry_attempts,
144
+ retry_delay,
145
+ time_zone,
146
+ ): cls
147
+ for cls in classes
148
+ }
149
+
150
+ for future in as_completed(future_to_class):
151
+ cls = future_to_class[future]
152
+ activity, class_time = cls["activity"], cls["class_time"]
153
+ try:
154
+ future.result()
155
+ logger.info(f"Booking for '{activity}' at {class_time} completed successfully.")
156
+ except Exception:
157
+ logger.exception(f"Booking for '{activity}' at {class_time} failed.")
@@ -11,36 +11,54 @@ DAY_MAP = {"monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3, "friday": 4
11
11
 
12
12
 
13
13
  def validate_config(config: Dict[str, Any]) -> None:
14
- required_keys = ["email", "password", "centre", "classes"]
14
+ """
15
+ Validate the overall configuration structure and values.
16
+
17
+ Args:
18
+ config (Dict[str, Any]): Configuration dictionary.
19
+
20
+ Raises:
21
+ ValueError: If the configuration is invalid.
22
+ """
23
+
24
+ def raise_invalid_booking_format_error() -> None:
25
+ """Helper function to raise an error for invalid booking_execution format."""
26
+ raise ValueError(ErrorMessages.invalid_booking_execution_format())
27
+
28
+ required_keys = ["email", "password", "centre", "classes", "booking_execution"]
15
29
  for key in required_keys:
16
30
  if key not in config:
17
31
  raise ValueError(ErrorMessages.missing_required_key(key))
18
32
 
33
+ # Validate global booking_execution
34
+ if config["booking_execution"] != "now":
35
+ try:
36
+ day_and_time = config["booking_execution"].split()
37
+ if len(day_and_time) != 2:
38
+ raise_invalid_booking_format_error()
39
+
40
+ _, exec_time = day_and_time
41
+ datetime.strptime(exec_time, "%H:%M:%S")
42
+ except ValueError:
43
+ raise_invalid_booking_format_error()
44
+
45
+ # Validate individual class definitions
19
46
  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
- ):
47
+ if "activity" not in cls or "class_day" not in cls or "class_time" not in cls:
27
48
  raise ValueError(ErrorMessages.invalid_class_definition())
28
49
 
29
- if cls["weekly"] and cls["booking_execution"] == "now":
30
- raise ValueError(ErrorMessages.invalid_weekly_now())
31
50
 
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
51
+ def validate_activities(bot: SportBot, config: Dict[str, Any]) -> None:
52
+ """
53
+ Validate that all activities specified in the configuration exist.
41
54
 
55
+ Args:
56
+ bot (SportBot): The SportBot instance.
57
+ config (Dict[str, Any]): Configuration dictionary.
42
58
 
43
- def validate_activities(bot: SportBot, config: Dict[str, Any]) -> None:
59
+ Raises:
60
+ ValueError: If an activity is not found.
61
+ """
44
62
  logger.info("Fetching available activities for validation...")
45
63
  available_activities = bot.activities()
46
64
  available_activity_names = set(available_activities["name_activity"].tolist())
@@ -51,4 +69,5 @@ def validate_activities(bot: SportBot, config: Dict[str, Any]) -> None:
51
69
  activity_name = cls["activity"]
52
70
  if activity_name not in available_activity_names:
53
71
  raise ValueError(ErrorMessages.activity_not_found(activity_name, list(available_activity_names)))
72
+
54
73
  logger.info("All activities in the configuration file have been validated.")
@@ -0,0 +1,59 @@
1
+ import os
2
+ from typing import Any, Dict
3
+
4
+ from pysportbot import SportBot
5
+ from pysportbot.service.booking import schedule_bookings
6
+ from pysportbot.service.config_validator import validate_activities, validate_config
7
+ from pysportbot.utils.logger import get_logger
8
+
9
+
10
+ def run_service(
11
+ config: Dict[str, Any],
12
+ booking_delay: int,
13
+ retry_attempts: int,
14
+ retry_delay: int,
15
+ time_zone: str = "Europe/Madrid",
16
+ log_level: str = "INFO",
17
+ ) -> None:
18
+ """
19
+ Run the booking service with the given configuration.
20
+
21
+ Args:
22
+ config (dict): Configuration dictionary for booking service.
23
+ booking_delay (int): Delay before each booking attempt.
24
+ retry_attempts (int): Number of retry attempts.
25
+ retry_delay (int): Delay between retry attempts in minutes.
26
+ time_zone (str): Time zone for the booking.
27
+ log_level (str): Logging level for the service.
28
+ """
29
+ # Initialize logger
30
+ logger = get_logger(__name__)
31
+ logger.setLevel(log_level)
32
+
33
+ # Validate configuration
34
+ validate_config(config)
35
+
36
+ # Initialize the SportBot and authenticate
37
+ bot = SportBot(log_level=log_level, time_zone=time_zone)
38
+ bot.login(config["email"], config["password"], config["centre"])
39
+
40
+ # Validate activities in the configuration
41
+ validate_activities(bot, config)
42
+
43
+ # Determine the number of threads
44
+ max_threads = min(len(config["classes"]), os.cpu_count() or 1)
45
+ logger.info(f"Using up to {max_threads} threads for booking {len(config['classes'])} activities.")
46
+
47
+ # Schedule bookings in parallel
48
+ schedule_bookings(
49
+ bot,
50
+ config["classes"],
51
+ config["booking_execution"],
52
+ booking_delay,
53
+ retry_attempts,
54
+ retry_delay,
55
+ time_zone,
56
+ max_threads,
57
+ )
58
+
59
+ logger.info("All bookings completed.")
@@ -20,11 +20,7 @@ class ErrorMessages:
20
20
 
21
21
  @staticmethod
22
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'."
23
+ return "Each class must include 'activity', 'class_day', " "'class_time'"
28
24
 
29
25
  @staticmethod
30
26
  def invalid_booking_execution_format() -> str:
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ import threading
2
3
  from datetime import datetime
3
4
  from typing import ClassVar, Optional
4
5
 
@@ -8,7 +9,7 @@ from .errors import ErrorMessages
8
9
 
9
10
 
10
11
  class ColorFormatter(logging.Formatter):
11
- """Custom formatter to add color-coded log levels."""
12
+ """Custom formatter to add color-coded log levels and thread information."""
12
13
 
13
14
  COLORS: ClassVar[dict[str, str]] = {
14
15
  "DEBUG": "\033[94m", # Blue
@@ -18,17 +19,34 @@ class ColorFormatter(logging.Formatter):
18
19
  "RESET": "\033[0m", # Reset
19
20
  }
20
21
 
21
- def __init__(self, fmt: str, datefmt: str, tz: pytz.BaseTzInfo) -> None:
22
+ THREAD_COLORS: ClassVar[list[str]] = [
23
+ "\033[95m", # Magenta
24
+ "\033[96m", # Cyan
25
+ "\033[93m", # Yellow
26
+ "\033[92m", # Green
27
+ "\033[94m", # Blue
28
+ "\033[90m", # Gray
29
+ "\033[37m", # White
30
+ "\033[33m", # Orange
31
+ "\033[35m", # Purple
32
+ ]
33
+
34
+ thread_colors: dict[str, str]
35
+
36
+ def __init__(self, fmt: str, datefmt: str, tz: pytz.BaseTzInfo, include_threads: bool = False) -> None:
22
37
  """
23
- Initialize the formatter with a specific timezone.
38
+ Initialize the formatter with a specific timezone and optional thread formatting.
24
39
 
25
40
  Args:
26
41
  fmt (str): The log message format.
27
42
  datefmt (str): The date format.
28
43
  tz (pytz.BaseTzInfo): The timezone for log timestamps.
44
+ include_threads (bool): Whether to include thread information in logs.
29
45
  """
30
46
  super().__init__(fmt, datefmt)
31
47
  self.timezone = tz
48
+ self.include_threads = include_threads
49
+ self.thread_colors = {} # Initialize as an empty dictionary
32
50
 
33
51
  def formatTime(self, record: logging.LogRecord, datefmt: Optional[str] = None) -> str:
34
52
  """
@@ -46,7 +64,7 @@ class ColorFormatter(logging.Formatter):
46
64
 
47
65
  def format(self, record: logging.LogRecord) -> str:
48
66
  """
49
- Format the log record with color-coded log levels.
67
+ Format the log record with color-coded log levels and optional thread information.
50
68
 
51
69
  Args:
52
70
  record (logging.LogRecord): The log record to format.
@@ -56,6 +74,24 @@ class ColorFormatter(logging.Formatter):
56
74
  """
57
75
  color = self.COLORS.get(record.levelname, self.COLORS["RESET"])
58
76
  record.levelname = f"{color}{record.levelname}{self.COLORS['RESET']}"
77
+
78
+ if self.include_threads:
79
+ thread_name = threading.current_thread().name
80
+ if thread_name == "MainThread":
81
+ # Skip adding thread info for the main thread
82
+ record.thread_info = ""
83
+ else:
84
+ # Map thread names to simplified format (Thread 0, Thread 1, etc.)
85
+ if thread_name not in self.thread_colors:
86
+ color_index = len(self.thread_colors) % len(self.THREAD_COLORS)
87
+ self.thread_colors[thread_name] = self.THREAD_COLORS[color_index]
88
+
89
+ thread_color = self.thread_colors[thread_name]
90
+ simplified_thread_name = thread_name.split("_")[-1]
91
+ record.thread_info = f"[{thread_color}Thread {simplified_thread_name}{self.COLORS['RESET']}] "
92
+ else:
93
+ record.thread_info = ""
94
+
59
95
  return super().format(record)
60
96
 
61
97
 
@@ -74,12 +110,16 @@ def setup_logger(level: str = "INFO", timezone: str = "Europe/Madrid") -> None:
74
110
  handler = logging.StreamHandler()
75
111
  handler.setLevel(logging._nameToLevel[level.upper()])
76
112
  tz = pytz.timezone(timezone)
77
- formatter = ColorFormatter(
78
- "[%(asctime)s] [%(levelname)s] %(message)s",
113
+
114
+ # Default formatter for the main thread
115
+ thread_formatter = ColorFormatter(
116
+ "[%(asctime)s] [%(levelname)s] %(thread_info)s%(message)s",
79
117
  datefmt="%Y-%m-%d %H:%M:%S",
80
118
  tz=tz,
119
+ include_threads=True,
81
120
  )
82
- handler.setFormatter(formatter)
121
+
122
+ handler.setFormatter(thread_formatter)
83
123
  root_logger.addHandler(handler)
84
124
 
85
125
 
@@ -1,147 +0,0 @@
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
- )
@@ -1,49 +0,0 @@
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
-
13
- def run_service(
14
- config: Dict[str, Any],
15
- offset_seconds: int,
16
- retry_attempts: int,
17
- retry_delay_minutes: int,
18
- time_zone: str = "Europe/Madrid",
19
- log_level: str = "INFO",
20
- ) -> None:
21
- # Initialize service logger
22
- logger = get_logger(__name__)
23
- logger.setLevel(log_level)
24
-
25
- # Validate the configuration file
26
- validate_config(config)
27
- # Initialize the SportBot instance
28
- bot = SportBot(log_level=log_level, time_zone=time_zone)
29
- bot.login(config["email"], config["password"], config["centre"])
30
-
31
- # Validate the activities in the configuration file
32
- validate_activities(bot, config)
33
-
34
- for cls in config["classes"]:
35
- schedule_bookings(
36
- bot,
37
- config,
38
- cls,
39
- offset_seconds,
40
- retry_attempts,
41
- retry_delay_minutes,
42
- time_zone,
43
- )
44
-
45
- if schedule.jobs:
46
- logger.info("Weekly bookings scheduled. Running the scheduler...")
47
- while True:
48
- schedule.run_pending()
49
- time.sleep(1)
File without changes