pysportbot 0.0.7__tar.gz → 0.0.8__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.
- {pysportbot-0.0.7 → pysportbot-0.0.8}/PKG-INFO +19 -17
- {pysportbot-0.0.7 → pysportbot-0.0.8}/README.md +14 -10
- {pysportbot-0.0.7 → pysportbot-0.0.8}/pyproject.toml +9 -6
- {pysportbot-0.0.7 → pysportbot-0.0.8}/pysportbot/__init__.py +20 -3
- {pysportbot-0.0.7 → pysportbot-0.0.8}/pysportbot/activities.py +1 -1
- {pysportbot-0.0.7 → pysportbot-0.0.8}/pysportbot/bookings.py +4 -4
- {pysportbot-0.0.7 → pysportbot-0.0.8}/pysportbot/service/__main__.py +9 -2
- {pysportbot-0.0.7 → pysportbot-0.0.8}/pysportbot/service/booking.py +16 -14
- {pysportbot-0.0.7 → pysportbot-0.0.8}/pysportbot/service/service.py +8 -6
- pysportbot-0.0.8/pysportbot/service/threading.py +51 -0
- {pysportbot-0.0.7 → pysportbot-0.0.8}/pysportbot/utils/logger.py +33 -37
- {pysportbot-0.0.7 → pysportbot-0.0.8}/LICENSE +0 -0
- {pysportbot-0.0.7 → pysportbot-0.0.8}/pysportbot/authenticator.py +0 -0
- {pysportbot-0.0.7 → pysportbot-0.0.8}/pysportbot/centres.py +0 -0
- {pysportbot-0.0.7 → pysportbot-0.0.8}/pysportbot/endpoints.py +0 -0
- {pysportbot-0.0.7 → pysportbot-0.0.8}/pysportbot/service/__init__.py +0 -0
- {pysportbot-0.0.7 → pysportbot-0.0.8}/pysportbot/service/config_loader.py +0 -0
- {pysportbot-0.0.7 → pysportbot-0.0.8}/pysportbot/service/config_validator.py +0 -0
- {pysportbot-0.0.7 → pysportbot-0.0.8}/pysportbot/service/scheduling.py +0 -0
- {pysportbot-0.0.7 → pysportbot-0.0.8}/pysportbot/session.py +0 -0
- {pysportbot-0.0.7 → pysportbot-0.0.8}/pysportbot/utils/__init__.py +0 -0
- {pysportbot-0.0.7 → pysportbot-0.0.8}/pysportbot/utils/errors.py +0 -0
- {pysportbot-0.0.7 → pysportbot-0.0.8}/pysportbot/utils/time.py +0 -0
@@ -1,22 +1,20 @@
|
|
1
|
-
Metadata-Version: 2.
|
1
|
+
Metadata-Version: 2.3
|
2
2
|
Name: pysportbot
|
3
|
-
Version: 0.0.
|
4
|
-
Summary:
|
5
|
-
Home-page: https://github.com/jbeirer/resasports-bot
|
3
|
+
Version: 0.0.8
|
4
|
+
Summary: A python-based bot for automatic resasports slot booking
|
6
5
|
Author: Joshua Falco Beirer
|
7
6
|
Author-email: jbeirer@cern.ch
|
8
|
-
Requires-Python: >=3.9,<3.
|
7
|
+
Requires-Python: >=3.9,<3.14
|
9
8
|
Classifier: Programming Language :: Python :: 3
|
10
9
|
Classifier: Programming Language :: Python :: 3.9
|
11
10
|
Classifier: Programming Language :: Python :: 3.10
|
12
11
|
Classifier: Programming Language :: Python :: 3.11
|
13
12
|
Classifier: Programming Language :: Python :: 3.12
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
14
14
|
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
|
-
Project-URL: Documentation, https://jbeirer.github.io/resasports-bot/
|
19
|
-
Project-URL: Repository, https://github.com/jbeirer/resasports-bot
|
20
18
|
Description-Content-Type: text/markdown
|
21
19
|
|
22
20
|
# No queues. Just gains.
|
@@ -32,10 +30,10 @@ Description-Content-Type: text/markdown
|
|
32
30
|
[](https://jbeirer.github.io/resasports-bot/)
|
33
31
|
[](https://github.com/jbeirer/resasports-bot/blob/main/CODE_OF_CONDUCT.md)
|
34
32
|
|
35
|
-
|
33
|
+
PySportBot empowers you to programmatically book fitness classes at any sports center that uses the [Resasports](https://social.resasports.com/en/) booking management software.
|
36
34
|
|
37
|
-
##
|
38
|
-
```
|
35
|
+
## Install
|
36
|
+
```bash
|
39
37
|
pip install pysportbot
|
40
38
|
```
|
41
39
|
|
@@ -71,7 +69,7 @@ python -m pysportbot.service --config config.json
|
|
71
69
|
```
|
72
70
|
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:
|
73
71
|
|
74
|
-
|
72
|
+
### 1. Book an upcoming class now
|
75
73
|
|
76
74
|
Let's say you would like to book Yoga next Monday at 18:00:00, then your `config.json` would look like:
|
77
75
|
|
@@ -91,7 +89,7 @@ Let's say you would like to book Yoga next Monday at 18:00:00, then your `config
|
|
91
89
|
]
|
92
90
|
}
|
93
91
|
```
|
94
|
-
|
92
|
+
### 2. Book an upcoming class on a specific day and time
|
95
93
|
|
96
94
|
Let's say you would like to book Yoga next Monday at 18:00:00, but the execution of the booking should only happen on Friday at 07:30:00 then your `config.json` would look like:
|
97
95
|
|
@@ -112,19 +110,23 @@ Let's say you would like to book Yoga next Monday at 18:00:00, but the execution
|
|
112
110
|
}
|
113
111
|
```
|
114
112
|
|
113
|
+
**Note:** By default, pysportbot will be attempt to execute *N* bookings in parallel, where *N* is the number of available cores on your machine.
|
114
|
+
|
115
115
|
The service also provides various other options that can be inspected with
|
116
116
|
|
117
117
|
```bash
|
118
118
|
python -m pysportbot.service --help
|
119
119
|
```
|
120
120
|
Currently supported options include
|
121
|
-
1.
|
122
|
-
2. ```--retry-
|
123
|
-
3. ```--
|
124
|
-
4. ```--
|
121
|
+
1. ```--booking-delay``` sets a global delay in seconds before booking execution [default: 0]
|
122
|
+
2. ```--retry-attempts``` sets the number of retries attempted in case a booking attempt fails [default: 3]
|
123
|
+
3. ```--retry-delay``` sets the delay in seconds between booking retries [default: 5]
|
124
|
+
4. ```--time-zone``` sets the time zone for the service [default: Europe/Madrid]
|
125
|
+
5. ```--log-level``` sets the log-level of the service [default: INFO]
|
126
|
+
6. ```--max-threads``` limits the number of used threads for parallel bookings [default: -1]
|
125
127
|
|
126
128
|
## LICENSE
|
127
129
|
|
128
130
|
pysportbot is free of use and open-source. All versions are
|
129
|
-
published under the [MIT License](https://github.com/jbeirer/
|
131
|
+
published under the [MIT License](https://github.com/jbeirer/resasports-bot/blob/main/LICENSE).
|
130
132
|
|
@@ -11,10 +11,10 @@
|
|
11
11
|
[](https://jbeirer.github.io/resasports-bot/)
|
12
12
|
[](https://github.com/jbeirer/resasports-bot/blob/main/CODE_OF_CONDUCT.md)
|
13
13
|
|
14
|
-
|
14
|
+
PySportBot empowers you to programmatically book fitness classes at any sports center that uses the [Resasports](https://social.resasports.com/en/) booking management software.
|
15
15
|
|
16
|
-
##
|
17
|
-
```
|
16
|
+
## Install
|
17
|
+
```bash
|
18
18
|
pip install pysportbot
|
19
19
|
```
|
20
20
|
|
@@ -50,7 +50,7 @@ python -m pysportbot.service --config config.json
|
|
50
50
|
```
|
51
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
|
|
55
55
|
Let's say you would like to book Yoga next Monday at 18:00:00, then your `config.json` would look like:
|
56
56
|
|
@@ -70,7 +70,7 @@ Let's say you would like to book Yoga next Monday at 18:00:00, then your `config
|
|
70
70
|
]
|
71
71
|
}
|
72
72
|
```
|
73
|
-
|
73
|
+
### 2. Book an upcoming class on a specific day and time
|
74
74
|
|
75
75
|
Let's say you would like to book Yoga next Monday at 18:00:00, but the execution of the booking should only happen on Friday at 07:30:00 then your `config.json` would look like:
|
76
76
|
|
@@ -91,18 +91,22 @@ Let's say you would like to book Yoga next Monday at 18:00:00, but the execution
|
|
91
91
|
}
|
92
92
|
```
|
93
93
|
|
94
|
+
**Note:** By default, pysportbot will be attempt to execute *N* bookings in parallel, where *N* is the number of available cores on your machine.
|
95
|
+
|
94
96
|
The service also provides various other options that can be inspected with
|
95
97
|
|
96
98
|
```bash
|
97
99
|
python -m pysportbot.service --help
|
98
100
|
```
|
99
101
|
Currently supported options include
|
100
|
-
1.
|
101
|
-
2. ```--retry-
|
102
|
-
3. ```--
|
103
|
-
4. ```--
|
102
|
+
1. ```--booking-delay``` sets a global delay in seconds before booking execution [default: 0]
|
103
|
+
2. ```--retry-attempts``` sets the number of retries attempted in case a booking attempt fails [default: 3]
|
104
|
+
3. ```--retry-delay``` sets the delay in seconds between booking retries [default: 5]
|
105
|
+
4. ```--time-zone``` sets the time zone for the service [default: Europe/Madrid]
|
106
|
+
5. ```--log-level``` sets the log-level of the service [default: INFO]
|
107
|
+
6. ```--max-threads``` limits the number of used threads for parallel bookings [default: -1]
|
104
108
|
|
105
109
|
## LICENSE
|
106
110
|
|
107
111
|
pysportbot is free of use and open-source. All versions are
|
108
|
-
published under the [MIT License](https://github.com/jbeirer/
|
112
|
+
published under the [MIT License](https://github.com/jbeirer/resasports-bot/blob/main/LICENSE).
|
@@ -1,17 +1,20 @@
|
|
1
|
-
[
|
1
|
+
[project]
|
2
2
|
name = "pysportbot"
|
3
|
-
version = "v0.0.
|
4
|
-
description = "
|
5
|
-
authors = [
|
3
|
+
version = "v0.0.8"
|
4
|
+
description = "A python-based bot for automatic resasports slot booking"
|
5
|
+
authors = [
|
6
|
+
{ name = "Joshua Falco Beirer", email = "jbeirer@cern.ch" }
|
7
|
+
]
|
6
8
|
repository = "https://github.com/jbeirer/resasports-bot"
|
7
9
|
documentation = "https://jbeirer.github.io/resasports-bot/"
|
8
10
|
readme = "README.md"
|
9
11
|
packages = [
|
10
|
-
{include = "pysportbot"}
|
12
|
+
{ include = "pysportbot" }
|
11
13
|
]
|
14
|
+
dynamic = ["requires-python", "dependencies"]
|
12
15
|
|
13
16
|
[tool.poetry.dependencies]
|
14
|
-
python = ">=3.9,<3.
|
17
|
+
python = ">=3.9,<3.14"
|
15
18
|
requests = "^2.32.3"
|
16
19
|
beautifulsoup4 = "^4.12.3"
|
17
20
|
pandas = "^2.2.3"
|
@@ -18,7 +18,7 @@ class SportBot:
|
|
18
18
|
"""Unified interface for interacting with the booking system."""
|
19
19
|
|
20
20
|
def __init__(self, log_level: str = "INFO", print_centres: bool = False, time_zone: str = "Europe/Madrid") -> None:
|
21
|
-
setup_logger(log_level)
|
21
|
+
setup_logger(log_level, timezone=time_zone)
|
22
22
|
self._logger = logging.getLogger("SportBot")
|
23
23
|
self._logger.info("Initializing SportBot...")
|
24
24
|
self._logger.info(f"Log level: {log_level}")
|
@@ -83,6 +83,8 @@ class SportBot:
|
|
83
83
|
return df.head(limit) if limit else df
|
84
84
|
|
85
85
|
def book(self, activity: str, start_time: str) -> None:
|
86
|
+
|
87
|
+
self._logger.debug(f"Attempting to book class '{activity}' on {start_time}")
|
86
88
|
if not self._is_logged_in:
|
87
89
|
self._logger.error(ErrorMessages.not_logged_in())
|
88
90
|
raise PermissionError(ErrorMessages.not_logged_in())
|
@@ -98,9 +100,18 @@ class SportBot:
|
|
98
100
|
self._logger.error(error_msg)
|
99
101
|
raise IndexError(error_msg)
|
100
102
|
|
101
|
-
|
103
|
+
slot_id = matching_slot.iloc[0]["id_activity_calendar"]
|
104
|
+
|
105
|
+
try:
|
106
|
+
self._bookings.book(slot_id)
|
107
|
+
self._logger.info(f"Successfully booked class '{activity}' on {start_time}")
|
108
|
+
except ValueError:
|
109
|
+
self._logger.exception(f"Failed to book class '{activity}' on {start_time}")
|
110
|
+
raise
|
102
111
|
|
103
112
|
def cancel(self, activity: str, start_time: str) -> None:
|
113
|
+
|
114
|
+
self._logger.debug(f"Attempting to cancel class '{activity}' on {start_time}")
|
104
115
|
if not self._is_logged_in:
|
105
116
|
self._logger.error(ErrorMessages.not_logged_in())
|
106
117
|
raise PermissionError(ErrorMessages.not_logged_in())
|
@@ -116,4 +127,10 @@ class SportBot:
|
|
116
127
|
self._logger.error(error_msg)
|
117
128
|
raise IndexError(error_msg)
|
118
129
|
|
119
|
-
|
130
|
+
slot_id = matching_slot.iloc[0]["id_activity_calendar"]
|
131
|
+
try:
|
132
|
+
self._bookings.cancel(slot_id)
|
133
|
+
self._logger.info(f"Successfully cancelled class '{activity}' on {start_time}")
|
134
|
+
except ValueError:
|
135
|
+
self._logger.exception(f"Failed to cancel class '{activity}' on {start_time}")
|
136
|
+
raise
|
@@ -56,7 +56,7 @@ class Activities:
|
|
56
56
|
ValueError: If the specified activity is not found.
|
57
57
|
RuntimeError: If slots cannot be fetched.
|
58
58
|
"""
|
59
|
-
logger.info(f"Fetching
|
59
|
+
logger.info(f"Fetching available slots for '{activity_name}' on {day}...")
|
60
60
|
|
61
61
|
# Check if the activity exists
|
62
62
|
activity_match = df_activities[df_activities["name_activity"] == activity_name]
|
@@ -27,7 +27,7 @@ class Bookings:
|
|
27
27
|
ValueError: If the slot is already booked or unavailable.
|
28
28
|
RuntimeError: If an unknown error occurs during booking.
|
29
29
|
"""
|
30
|
-
logger.
|
30
|
+
logger.debug(f"Attempting to book slot {slot_id}...")
|
31
31
|
|
32
32
|
# Payload for booking
|
33
33
|
payload = {
|
@@ -46,7 +46,7 @@ class Bookings:
|
|
46
46
|
|
47
47
|
# Handle response
|
48
48
|
if response_json["error"] == 0:
|
49
|
-
logger.
|
49
|
+
logger.debug(f"Successfully booked slot {slot_id}.")
|
50
50
|
elif response_json["error"] == 5:
|
51
51
|
logger.warning(f"Slot {slot_id} is already booked.")
|
52
52
|
raise ValueError(ErrorMessages.slot_already_booked())
|
@@ -70,7 +70,7 @@ class Bookings:
|
|
70
70
|
Raises:
|
71
71
|
ValueError: If the cancellation fails.
|
72
72
|
"""
|
73
|
-
logger.
|
73
|
+
logger.debug(f"Attempting to cancel slot {slot_id}...")
|
74
74
|
|
75
75
|
# Payload for cancellation
|
76
76
|
payload = {"id_activity_calendar": slot_id}
|
@@ -81,7 +81,7 @@ class Bookings:
|
|
81
81
|
|
82
82
|
# Handle response
|
83
83
|
if response_json["success"]:
|
84
|
-
logger.
|
84
|
+
logger.debug(f"Successfully cancelled slot {slot_id}.")
|
85
85
|
else:
|
86
86
|
logger.warning(f"Slot {slot_id} was not booked.")
|
87
87
|
raise ValueError(ErrorMessages.cancellation_failed())
|
@@ -10,11 +10,17 @@ 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("--booking-delay", type=int, default=
|
13
|
+
parser.add_argument("--booking-delay", type=int, default=0, help="Global booking delay in seconds before booking.")
|
14
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=
|
15
|
+
parser.add_argument("--retry-delay", type=int, default=5, help="Delay in seconds between retries for bookings.")
|
16
16
|
parser.add_argument("--time-zone", type=str, default="Europe/Madrid", help="Timezone for the service.")
|
17
17
|
parser.add_argument("--log-level", type=str, default="INFO", help="Logging level for the service.")
|
18
|
+
parser.add_argument(
|
19
|
+
"--max-threads",
|
20
|
+
type=int,
|
21
|
+
default=-1,
|
22
|
+
help="Maxium number of threads to use for booking. -1 defaults to all available cores.",
|
23
|
+
)
|
18
24
|
args = parser.parse_args()
|
19
25
|
|
20
26
|
config: Dict[str, Any] = load_config(args.config)
|
@@ -25,6 +31,7 @@ def main() -> None:
|
|
25
31
|
retry_delay=args.retry_delay,
|
26
32
|
time_zone=args.time_zone,
|
27
33
|
log_level=args.log_level,
|
34
|
+
max_threads=args.max_threads,
|
28
35
|
)
|
29
36
|
|
30
37
|
|
@@ -1,7 +1,7 @@
|
|
1
1
|
import time
|
2
2
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
3
3
|
from datetime import datetime
|
4
|
-
from typing import Any, Dict
|
4
|
+
from typing import Any, Dict
|
5
5
|
|
6
6
|
import pytz
|
7
7
|
|
@@ -44,7 +44,6 @@ def attempt_booking(
|
|
44
44
|
activity: str,
|
45
45
|
class_day: str,
|
46
46
|
class_time: str,
|
47
|
-
booking_delay: int,
|
48
47
|
retry_attempts: int = 1,
|
49
48
|
retry_delay: int = 0,
|
50
49
|
time_zone: str = "Europe/Madrid",
|
@@ -57,7 +56,6 @@ def attempt_booking(
|
|
57
56
|
activity (str): Activity name.
|
58
57
|
class_day (str): Day of the class.
|
59
58
|
class_time (str): Time of the class.
|
60
|
-
booking_delay (int): Delay before attempting booking.
|
61
59
|
retry_attempts (int): Number of retry attempts.
|
62
60
|
retry_delay (int): Delay between retries.
|
63
61
|
time_zone (str): Time zone for execution.
|
@@ -66,7 +64,6 @@ def attempt_booking(
|
|
66
64
|
booking_date = calculate_class_day(class_day, time_zone).strftime("%Y-%m-%d")
|
67
65
|
|
68
66
|
try:
|
69
|
-
logger.info(f"Fetching available slots for '{activity}' on {booking_date}.")
|
70
67
|
available_slots = bot.daily_slots(activity=activity, day=booking_date)
|
71
68
|
|
72
69
|
matching_slots = available_slots[available_slots["start_timestamp"] == f"{booking_date} {class_time}"]
|
@@ -89,18 +86,16 @@ def attempt_booking(
|
|
89
86
|
logger.info(f"Retrying in {retry_delay} seconds...")
|
90
87
|
time.sleep(retry_delay)
|
91
88
|
else:
|
92
|
-
# If the booking attempt succeeds, log and exit
|
93
|
-
logger.info(f"Successfully booked '{activity}' at {slot_id}.")
|
94
89
|
return
|
95
90
|
|
96
91
|
# If all attempts fail, log an error
|
97
|
-
|
92
|
+
# Do not raise an exception to allow other bookings to proceed
|
93
|
+
logger.error(f"Failed to book '{activity}' at {class_time} on {booking_date} after {retry_attempts} attempts.")
|
98
94
|
|
99
95
|
|
100
96
|
def schedule_bookings(
|
101
97
|
bot: SportBot,
|
102
|
-
|
103
|
-
booking_execution: str,
|
98
|
+
config: Dict[str, Any],
|
104
99
|
booking_delay: int,
|
105
100
|
retry_attempts: int,
|
106
101
|
retry_delay: int,
|
@@ -121,16 +116,25 @@ def schedule_bookings(
|
|
121
116
|
max_threads (int): Maximum number of threads to use.
|
122
117
|
"""
|
123
118
|
# Log planned bookings
|
124
|
-
for cls in classes:
|
119
|
+
for cls in config["classes"]:
|
125
120
|
logger.info(f"Scheduled to book '{cls['activity']}' next {cls['class_day']} at {cls['class_time']}.")
|
126
121
|
|
127
122
|
# Wait globally before starting bookings
|
128
|
-
wait_for_execution(booking_execution, time_zone)
|
123
|
+
wait_for_execution(config["booking_execution"], time_zone)
|
129
124
|
|
130
125
|
# Global booking delay
|
131
126
|
logger.info(f"Waiting {booking_delay} seconds before attempting booking.")
|
132
127
|
time.sleep(booking_delay)
|
133
128
|
|
129
|
+
# Re-authenticate before booking
|
130
|
+
logger.debug("Re-authenticating before booking.")
|
131
|
+
try:
|
132
|
+
bot.login(config["email"], config["password"], config["centre"])
|
133
|
+
except Exception:
|
134
|
+
logger.exception("Re-authentication failed before booking execution.")
|
135
|
+
raise
|
136
|
+
|
137
|
+
# Submit bookings in parallel
|
134
138
|
with ThreadPoolExecutor(max_workers=max_threads) as executor:
|
135
139
|
future_to_class = {
|
136
140
|
executor.submit(
|
@@ -139,12 +143,11 @@ def schedule_bookings(
|
|
139
143
|
cls["activity"],
|
140
144
|
cls["class_day"],
|
141
145
|
cls["class_time"],
|
142
|
-
booking_delay,
|
143
146
|
retry_attempts,
|
144
147
|
retry_delay,
|
145
148
|
time_zone,
|
146
149
|
): cls
|
147
|
-
for cls in classes
|
150
|
+
for cls in config["classes"]
|
148
151
|
}
|
149
152
|
|
150
153
|
for future in as_completed(future_to_class):
|
@@ -152,6 +155,5 @@ def schedule_bookings(
|
|
152
155
|
activity, class_time = cls["activity"], cls["class_time"]
|
153
156
|
try:
|
154
157
|
future.result()
|
155
|
-
logger.info(f"Booking for '{activity}' at {class_time} completed successfully.")
|
156
158
|
except Exception:
|
157
159
|
logger.exception(f"Booking for '{activity}' at {class_time} failed.")
|
@@ -1,9 +1,9 @@
|
|
1
|
-
import os
|
2
1
|
from typing import Any, Dict
|
3
2
|
|
4
3
|
from pysportbot import SportBot
|
5
4
|
from pysportbot.service.booking import schedule_bookings
|
6
5
|
from pysportbot.service.config_validator import validate_activities, validate_config
|
6
|
+
from pysportbot.service.threading import get_n_threads
|
7
7
|
from pysportbot.utils.logger import get_logger
|
8
8
|
|
9
9
|
|
@@ -14,6 +14,7 @@ def run_service(
|
|
14
14
|
retry_delay: int,
|
15
15
|
time_zone: str = "Europe/Madrid",
|
16
16
|
log_level: str = "INFO",
|
17
|
+
max_threads: int = -1,
|
17
18
|
) -> None:
|
18
19
|
"""
|
19
20
|
Run the booking service with the given configuration.
|
@@ -34,21 +35,22 @@ def run_service(
|
|
34
35
|
validate_config(config)
|
35
36
|
|
36
37
|
# Initialize the SportBot and authenticate
|
38
|
+
# Note: will re-authenticate before booking execution
|
39
|
+
# to ensure the session is still valid
|
37
40
|
bot = SportBot(log_level=log_level, time_zone=time_zone)
|
38
41
|
bot.login(config["email"], config["password"], config["centre"])
|
39
42
|
|
40
43
|
# Validate activities in the configuration
|
41
44
|
validate_activities(bot, config)
|
42
45
|
|
43
|
-
# Determine the number of threads
|
44
|
-
|
45
|
-
|
46
|
+
# Determine the number of threads, where threads -1 defaults to all available cores
|
47
|
+
requested_bookings = len(config["classes"])
|
48
|
+
max_threads = get_n_threads(max_threads, requested_bookings)
|
46
49
|
|
47
50
|
# Schedule bookings in parallel
|
48
51
|
schedule_bookings(
|
49
52
|
bot,
|
50
|
-
config
|
51
|
-
config["booking_execution"],
|
53
|
+
config,
|
52
54
|
booking_delay,
|
53
55
|
retry_attempts,
|
54
56
|
retry_delay,
|
@@ -0,0 +1,51 @@
|
|
1
|
+
import os
|
2
|
+
|
3
|
+
from pysportbot.utils.logger import get_logger
|
4
|
+
|
5
|
+
logger = get_logger(__name__)
|
6
|
+
|
7
|
+
|
8
|
+
def get_n_threads(max_user_threads: int, requested_bookings: int) -> int:
|
9
|
+
"""
|
10
|
+
Determine the number of threads to use based on user input and system resources.
|
11
|
+
|
12
|
+
Args:
|
13
|
+
max_user_threads (int): Maximum number of threads requested by the user (-1 for auto-detect).
|
14
|
+
requested_bookings (int): Number of bookings to process.
|
15
|
+
|
16
|
+
Returns:
|
17
|
+
int: The maximum number of threads to use.
|
18
|
+
|
19
|
+
Raises:
|
20
|
+
ValueError: If max_user_threads is 0.
|
21
|
+
"""
|
22
|
+
logger.debug(f"Maximum number of user-requested threads: {max_user_threads}")
|
23
|
+
logger.debug(f"Requested bookings: {requested_bookings}")
|
24
|
+
|
25
|
+
available_threads: int = os.cpu_count() or 1 # Fallback to 1 if os.cpu_count() is None
|
26
|
+
logger.debug(f"Available threads: {available_threads}")
|
27
|
+
|
28
|
+
if max_user_threads == 0:
|
29
|
+
logger.error("The 'max_user_threads' argument cannot be 0.")
|
30
|
+
raise ValueError("The 'max_user_threads' argument cannot be 0.")
|
31
|
+
|
32
|
+
if max_user_threads > available_threads:
|
33
|
+
logger.warning(
|
34
|
+
f"User-requested threads ({max_user_threads}) exceed available threads ({available_threads}). "
|
35
|
+
f"Limiting to {available_threads} threads."
|
36
|
+
)
|
37
|
+
|
38
|
+
if requested_bookings <= 0:
|
39
|
+
logger.warning("No bookings requested. Returning 0 threads.")
|
40
|
+
return 0 # No threads needed if there are no bookings
|
41
|
+
|
42
|
+
# If max_user_threads is -1, use the lesser of available threads and requested bookings
|
43
|
+
if max_user_threads == -1:
|
44
|
+
max_threads: int = min(available_threads, requested_bookings)
|
45
|
+
else:
|
46
|
+
# Use the lesser of max_user_threads, available threads, and requested bookings
|
47
|
+
max_threads = min(max_user_threads, available_threads, requested_bookings)
|
48
|
+
|
49
|
+
logger.info(f"Using up to {max_threads} threads for booking {requested_bookings} activities.")
|
50
|
+
|
51
|
+
return max_threads
|
@@ -9,7 +9,9 @@ from .errors import ErrorMessages
|
|
9
9
|
|
10
10
|
|
11
11
|
class ColorFormatter(logging.Formatter):
|
12
|
-
"""Custom formatter to add color-coded log levels and thread information
|
12
|
+
"""Custom formatter to add color-coded log levels and thread information,
|
13
|
+
while aligning log levels in the output as `[INFO] message`.
|
14
|
+
"""
|
13
15
|
|
14
16
|
COLORS: ClassVar[dict[str, str]] = {
|
15
17
|
"DEBUG": "\033[94m", # Blue
|
@@ -31,7 +33,9 @@ class ColorFormatter(logging.Formatter):
|
|
31
33
|
"\033[35m", # Purple
|
32
34
|
]
|
33
35
|
|
34
|
-
|
36
|
+
# The longest built-in level is WARNING = 7 letters => "[WARNING]" is 0 characters
|
37
|
+
# so let's set this to 9 to align them nicely.
|
38
|
+
_MAX_BRACKET_LEN = 9
|
35
39
|
|
36
40
|
def __init__(self, fmt: str, datefmt: str, tz: pytz.BaseTzInfo, include_threads: bool = False) -> None:
|
37
41
|
"""
|
@@ -41,47 +45,46 @@ class ColorFormatter(logging.Formatter):
|
|
41
45
|
fmt (str): The log message format.
|
42
46
|
datefmt (str): The date format.
|
43
47
|
tz (pytz.BaseTzInfo): The timezone for log timestamps.
|
44
|
-
include_threads (bool): Whether to include thread
|
48
|
+
include_threads (bool): Whether to include thread info in logs.
|
45
49
|
"""
|
46
50
|
super().__init__(fmt, datefmt)
|
47
51
|
self.timezone = tz
|
48
52
|
self.include_threads = include_threads
|
49
|
-
self.thread_colors = {} # Initialize
|
53
|
+
self.thread_colors: dict[str, str] = {} # Initialize empty dictionary
|
50
54
|
|
51
55
|
def formatTime(self, record: logging.LogRecord, datefmt: Optional[str] = None) -> str:
|
52
56
|
"""
|
53
57
|
Override to format the time in the desired timezone.
|
54
|
-
|
55
|
-
Args:
|
56
|
-
record (logging.LogRecord): The log record.
|
57
|
-
datefmt (Optional[str]): The date format.
|
58
|
-
|
59
|
-
Returns:
|
60
|
-
str: The formatted timestamp.
|
61
58
|
"""
|
62
59
|
record_time = datetime.fromtimestamp(record.created, self.timezone)
|
63
60
|
return record_time.strftime(datefmt or self.default_time_format)
|
64
61
|
|
65
62
|
def format(self, record: logging.LogRecord) -> str:
|
66
63
|
"""
|
67
|
-
Format the log record with color-coded log levels
|
64
|
+
Format the log record with color-coded (and bracketed) log levels, plus optional thread info.
|
65
|
+
Example final output: "[INFO] message".
|
66
|
+
"""
|
67
|
+
raw_level_name = record.levelname
|
68
68
|
|
69
|
-
|
70
|
-
|
69
|
+
# Build the bracketed level, e.g. "[INFO]"
|
70
|
+
bracketed_level = f"[{raw_level_name}]"
|
71
71
|
|
72
|
-
|
73
|
-
|
74
|
-
"""
|
75
|
-
color = self.COLORS.get(record.levelname, self.COLORS["RESET"])
|
76
|
-
record.levelname = f"{color}{record.levelname}{self.COLORS['RESET']}"
|
72
|
+
# Pad it to a fixed width (e.g. 10) so shorter levels also line up
|
73
|
+
padded_bracketed_level = f"{bracketed_level:<{self._MAX_BRACKET_LEN}}"
|
77
74
|
|
75
|
+
# Colorize the padded bracketed string
|
76
|
+
color = self.COLORS.get(raw_level_name, self.COLORS["RESET"])
|
77
|
+
colored_bracketed = f"{color}{padded_bracketed_level}{self.COLORS['RESET']}"
|
78
|
+
|
79
|
+
# We'll store this in a custom attribute
|
80
|
+
record.colored_bracketed_level = colored_bracketed
|
81
|
+
|
82
|
+
# Handle the thread info if requested
|
78
83
|
if self.include_threads:
|
79
84
|
thread_name = threading.current_thread().name
|
80
85
|
if thread_name == "MainThread":
|
81
|
-
# Skip adding thread info for the main thread
|
82
86
|
record.thread_info = ""
|
83
87
|
else:
|
84
|
-
# Map thread names to simplified format (Thread 0, Thread 1, etc.)
|
85
88
|
if thread_name not in self.thread_colors:
|
86
89
|
color_index = len(self.thread_colors) % len(self.THREAD_COLORS)
|
87
90
|
self.thread_colors[thread_name] = self.THREAD_COLORS[color_index]
|
@@ -101,25 +104,24 @@ def setup_logger(level: str = "INFO", timezone: str = "Europe/Madrid") -> None:
|
|
101
104
|
|
102
105
|
Args:
|
103
106
|
level (str): The desired logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
|
104
|
-
timezone (str): The desired timezone for log timestamps (e.g., Europe/Madrid).
|
107
|
+
timezone (str): The desired timezone for log timestamps (e.g., 'Europe/Madrid').
|
105
108
|
"""
|
106
109
|
root_logger = logging.getLogger()
|
107
110
|
root_logger.setLevel(logging._nameToLevel[level.upper()])
|
108
111
|
|
109
|
-
|
112
|
+
# Prevent adding multiple handlers if already set up
|
113
|
+
if not root_logger.hasHandlers():
|
110
114
|
handler = logging.StreamHandler()
|
111
115
|
handler.setLevel(logging._nameToLevel[level.upper()])
|
116
|
+
|
112
117
|
tz = pytz.timezone(timezone)
|
113
118
|
|
114
|
-
#
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
include_threads=True,
|
120
|
-
)
|
119
|
+
# IMPORTANT: Note the %(colored_bracketed_level)s placeholder
|
120
|
+
fmt = "[%(asctime)s] %(colored_bracketed_level)s %(thread_info)s%(message)s"
|
121
|
+
|
122
|
+
formatter = ColorFormatter(fmt=fmt, datefmt="%Y-%m-%d %H:%M:%S", tz=tz, include_threads=True)
|
123
|
+
handler.setFormatter(formatter)
|
121
124
|
|
122
|
-
handler.setFormatter(thread_formatter)
|
123
125
|
root_logger.addHandler(handler)
|
124
126
|
|
125
127
|
|
@@ -143,11 +145,5 @@ def set_log_level(level: str) -> None:
|
|
143
145
|
def get_logger(name: str) -> logging.Logger:
|
144
146
|
"""
|
145
147
|
Retrieve a logger by name.
|
146
|
-
|
147
|
-
Args:
|
148
|
-
name (str): The name of the logger.
|
149
|
-
|
150
|
-
Returns:
|
151
|
-
logging.Logger: The logger instance.
|
152
148
|
"""
|
153
149
|
return logging.getLogger(name)
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|