pysportbot 0.0.9__tar.gz → 0.0.11__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.9 → pysportbot-0.0.11}/PKG-INFO +2 -3
- {pysportbot-0.0.9 → pysportbot-0.0.11}/pyproject.toml +12 -8
- {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/__init__.py +28 -10
- {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/service/__main__.py +2 -2
- {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/service/booking.py +48 -47
- pysportbot-0.0.11/pysportbot/service/config_loader.py +7 -0
- {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/service/config_validator.py +3 -3
- {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/service/service.py +2 -2
- {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/utils/errors.py +5 -0
- {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/utils/logger.py +2 -2
- pysportbot-0.0.9/pysportbot/service/config_loader.py +0 -7
- {pysportbot-0.0.9 → pysportbot-0.0.11}/LICENSE +0 -0
- {pysportbot-0.0.9 → pysportbot-0.0.11}/README.md +0 -0
- {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/activities.py +0 -0
- {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/authenticator.py +0 -0
- {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/bookings.py +0 -0
- {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/centres.py +0 -0
- {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/endpoints.py +0 -0
- {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/service/__init__.py +0 -0
- {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/service/scheduling.py +0 -0
- {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/service/threading.py +0 -0
- {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/session.py +0 -0
- {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/utils/__init__.py +0 -0
- {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/utils/time.py +0 -0
@@ -1,12 +1,11 @@
|
|
1
1
|
Metadata-Version: 2.3
|
2
2
|
Name: pysportbot
|
3
|
-
Version: 0.0.
|
3
|
+
Version: 0.0.11
|
4
4
|
Summary: A python-based bot for automatic resasports slot booking
|
5
5
|
Author: Joshua Falco Beirer
|
6
6
|
Author-email: jbeirer@cern.ch
|
7
|
-
Requires-Python: >=3.
|
7
|
+
Requires-Python: >=3.10,<3.14
|
8
8
|
Classifier: Programming Language :: Python :: 3
|
9
|
-
Classifier: Programming Language :: Python :: 3.9
|
10
9
|
Classifier: Programming Language :: Python :: 3.10
|
11
10
|
Classifier: Programming Language :: Python :: 3.11
|
12
11
|
Classifier: Programming Language :: Python :: 3.12
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[project]
|
2
2
|
name = "pysportbot"
|
3
|
-
version = "v0.0.
|
3
|
+
version = "v0.0.11"
|
4
4
|
description = "A python-based bot for automatic resasports slot booking"
|
5
5
|
authors = [
|
6
6
|
{ name = "Joshua Falco Beirer", email = "jbeirer@cern.ch" }
|
@@ -14,7 +14,7 @@ packages = [
|
|
14
14
|
dynamic = ["requires-python", "dependencies"]
|
15
15
|
|
16
16
|
[tool.poetry.dependencies]
|
17
|
-
python = ">=3.
|
17
|
+
python = ">=3.10,<3.14"
|
18
18
|
requests = "^2.32.3"
|
19
19
|
beautifulsoup4 = "^4.12.3"
|
20
20
|
pandas = "^2.2.3"
|
@@ -23,8 +23,8 @@ pytz = "^2024.2"
|
|
23
23
|
[tool.poetry.group.dev.dependencies]
|
24
24
|
pytest = "^8.3.4"
|
25
25
|
pytest-cov = "^6.0.0"
|
26
|
-
deptry = "^0.
|
27
|
-
mypy = "^1.14.
|
26
|
+
deptry = "^0.22.0"
|
27
|
+
mypy = "^1.14.1"
|
28
28
|
pre-commit = "^4.0.1"
|
29
29
|
tox = "^4.23.2"
|
30
30
|
ipykernel = "^6.29.5"
|
@@ -37,12 +37,12 @@ mkdocs-material = "^9.5.49"
|
|
37
37
|
mkdocstrings = {extras = ["python"], version = "^0.27.0"}
|
38
38
|
|
39
39
|
[build-system]
|
40
|
-
requires = ["poetry-core>=
|
40
|
+
requires = ["poetry-core>=2.0.1"]
|
41
41
|
build-backend = "poetry.core.masonry.api"
|
42
42
|
|
43
43
|
[tool.black]
|
44
44
|
line-length = 120
|
45
|
-
target-version = ['
|
45
|
+
target-version = ['py313']
|
46
46
|
preview = true
|
47
47
|
|
48
48
|
[tool.mypy]
|
@@ -65,7 +65,7 @@ filterwarnings = [
|
|
65
65
|
]
|
66
66
|
|
67
67
|
[tool.ruff]
|
68
|
-
target-version = "
|
68
|
+
target-version = "py313"
|
69
69
|
line-length = 120
|
70
70
|
fix = true
|
71
71
|
lint.select = [
|
@@ -108,7 +108,11 @@ lint.ignore = [
|
|
108
108
|
# Comparison to true should be 'if cond is true:'
|
109
109
|
"E712",
|
110
110
|
# Long exception message
|
111
|
-
"TRY003"
|
111
|
+
"TRY003",
|
112
|
+
# Module shadows a Python standard-library module
|
113
|
+
"A005",
|
114
|
+
# Use `logging.exception` instead of `logging.error`
|
115
|
+
"TRY400"
|
112
116
|
]
|
113
117
|
|
114
118
|
[tool.coverage.report]
|
@@ -1,7 +1,6 @@
|
|
1
1
|
# pysportbot/sportbot.py
|
2
2
|
|
3
3
|
import logging
|
4
|
-
from typing import Optional
|
5
4
|
|
6
5
|
from pandas import DataFrame
|
7
6
|
|
@@ -25,7 +24,7 @@ class SportBot:
|
|
25
24
|
self._logger.info(f"Time zone: {time_zone}")
|
26
25
|
self._centres = Centres(print_centres)
|
27
26
|
self._session: Session = Session()
|
28
|
-
self._auth:
|
27
|
+
self._auth: Authenticator | None = None
|
29
28
|
self._activities: Activities = Activities(self._session)
|
30
29
|
self._bookings: Bookings = Bookings(self._session)
|
31
30
|
self._df_activities: DataFrame | None = None
|
@@ -50,7 +49,7 @@ class SportBot:
|
|
50
49
|
self._is_logged_in = True
|
51
50
|
self._logger.info("Login successful!")
|
52
51
|
except Exception:
|
53
|
-
self._is_logged_in = False
|
52
|
+
self._is_logged_in = False
|
54
53
|
self._logger.exception(ErrorMessages.login_failed())
|
55
54
|
raise
|
56
55
|
|
@@ -84,7 +83,6 @@ class SportBot:
|
|
84
83
|
|
85
84
|
def book(self, activity: str, start_time: str) -> None:
|
86
85
|
|
87
|
-
self._logger.debug(f"Attempting to book class '{activity}' on {start_time}")
|
88
86
|
if not self._is_logged_in:
|
89
87
|
self._logger.error(ErrorMessages.not_logged_in())
|
90
88
|
raise PermissionError(ErrorMessages.not_logged_in())
|
@@ -93,21 +91,42 @@ class SportBot:
|
|
93
91
|
self._logger.error(ErrorMessages.no_activities_loaded())
|
94
92
|
raise ValueError(ErrorMessages.no_activities_loaded())
|
95
93
|
|
94
|
+
# Fetch the daily slots for the activity
|
96
95
|
slots = self.daily_slots(activity, start_time.split(" ")[0])
|
96
|
+
|
97
|
+
# Find the slot that matches the start time
|
97
98
|
matching_slot = slots[slots["start_timestamp"] == start_time]
|
99
|
+
|
100
|
+
# If no matching slot is found, raise an error
|
98
101
|
if matching_slot.empty:
|
99
102
|
error_msg = ErrorMessages.slot_not_found(activity, start_time)
|
100
103
|
self._logger.error(error_msg)
|
101
104
|
raise IndexError(error_msg)
|
102
105
|
|
103
|
-
|
104
|
-
|
106
|
+
# The targeted slot
|
107
|
+
target_slot = matching_slot.iloc[0]
|
108
|
+
# The unique slot ID
|
109
|
+
slot_id = target_slot["id_activity_calendar"]
|
110
|
+
# The total member capacity of the slot
|
111
|
+
slot_capacity = target_slot["capacity"]
|
112
|
+
# The number of members already inscribed in the slot
|
113
|
+
slot_n_inscribed = target_slot["n_inscribed"]
|
114
|
+
# Log slot capacity
|
115
|
+
self._logger.info(
|
116
|
+
f"Attempting to book class '{activity}' on {start_time} with ID {slot_id} (Slot capacity: {slot_n_inscribed}/{slot_capacity})"
|
117
|
+
)
|
118
|
+
|
119
|
+
# Check if the slot is already booked out
|
120
|
+
if slot_n_inscribed >= slot_capacity:
|
121
|
+
self._logger.error(f"Activity '{activity}' on {start_time} with ID {slot_id} is booked out...")
|
122
|
+
raise ValueError(ErrorMessages.slot_capacity_full())
|
123
|
+
|
124
|
+
# Attempt to book the slot
|
105
125
|
try:
|
106
126
|
self._bookings.book(slot_id)
|
107
127
|
self._logger.info(f"Successfully booked class '{activity}' on {start_time}")
|
108
128
|
except ValueError:
|
109
|
-
self._logger.
|
110
|
-
raise
|
129
|
+
self._logger.error(f"Failed to book class '{activity}' on {start_time}")
|
111
130
|
|
112
131
|
def cancel(self, activity: str, start_time: str) -> None:
|
113
132
|
|
@@ -132,5 +151,4 @@ class SportBot:
|
|
132
151
|
self._bookings.cancel(slot_id)
|
133
152
|
self._logger.info(f"Successfully cancelled class '{activity}' on {start_time}")
|
134
153
|
except ValueError:
|
135
|
-
self._logger.
|
136
|
-
raise
|
154
|
+
self._logger.error(f"Failed to cancel class '{activity}' on {start_time}")
|
@@ -1,7 +1,7 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
2
|
|
3
3
|
import argparse
|
4
|
-
from typing import Any
|
4
|
+
from typing import Any
|
5
5
|
|
6
6
|
from .config_loader import load_config
|
7
7
|
from .service import run_service
|
@@ -23,7 +23,7 @@ def main() -> None:
|
|
23
23
|
)
|
24
24
|
args = parser.parse_args()
|
25
25
|
|
26
|
-
config:
|
26
|
+
config: dict[str, Any] = load_config(args.config)
|
27
27
|
run_service(
|
28
28
|
config,
|
29
29
|
booking_delay=args.booking_delay,
|
@@ -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
|
4
|
+
from typing import Any
|
5
5
|
|
6
6
|
import pytz
|
7
7
|
|
@@ -14,31 +14,6 @@ from .scheduling import calculate_class_day, calculate_next_execution
|
|
14
14
|
logger = get_logger(__name__)
|
15
15
|
|
16
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
17
|
def attempt_booking(
|
43
18
|
bot: SportBot,
|
44
19
|
activity: str,
|
@@ -64,23 +39,19 @@ def attempt_booking(
|
|
64
39
|
booking_date = calculate_class_day(class_day, time_zone).strftime("%Y-%m-%d")
|
65
40
|
|
66
41
|
try:
|
67
|
-
|
68
|
-
|
69
|
-
matching_slots = available_slots[available_slots["start_timestamp"] == f"{booking_date} {class_time}"]
|
70
|
-
if matching_slots.empty:
|
71
|
-
_raise_no_matching_slots_error(activity, class_time, booking_date)
|
72
|
-
|
73
|
-
slot_id = matching_slots.iloc[0]["start_timestamp"]
|
74
|
-
logger.info(f"Attempting to book '{activity}' at {slot_id} (Attempt {attempt_num}/{retry_attempts}).")
|
75
|
-
bot.book(activity=activity, start_time=slot_id)
|
42
|
+
bot.book(activity=activity, start_time=f"{booking_date} {class_time}")
|
76
43
|
|
77
44
|
except Exception as e:
|
78
45
|
error_str = str(e)
|
79
46
|
logger.warning(f"Attempt {attempt_num} failed: {error_str}")
|
80
47
|
|
48
|
+
# Decide whether to retry based on the error message
|
81
49
|
if ErrorMessages.slot_already_booked() in error_str:
|
82
50
|
logger.warning("Slot already booked; skipping further retries.")
|
83
51
|
return
|
52
|
+
if ErrorMessages.slot_capacity_full() in error_str:
|
53
|
+
logger.warning("Slot capacity full; skipping further retries.")
|
54
|
+
return
|
84
55
|
|
85
56
|
if attempt_num < retry_attempts:
|
86
57
|
logger.info(f"Retrying in {retry_delay} seconds...")
|
@@ -95,7 +66,7 @@ def attempt_booking(
|
|
95
66
|
|
96
67
|
def schedule_bookings(
|
97
68
|
bot: SportBot,
|
98
|
-
config:
|
69
|
+
config: dict[str, Any],
|
99
70
|
booking_delay: int,
|
100
71
|
retry_attempts: int,
|
101
72
|
retry_delay: int,
|
@@ -119,21 +90,51 @@ def schedule_bookings(
|
|
119
90
|
for cls in config["classes"]:
|
120
91
|
logger.info(f"Scheduled to book '{cls['activity']}' next {cls['class_day']} at {cls['class_time']}.")
|
121
92
|
|
122
|
-
#
|
123
|
-
|
93
|
+
# Booking execution day and time
|
94
|
+
booking_execution = config["booking_execution"]
|
95
|
+
|
96
|
+
# Exact time when booking will be executed (modulo global booking delay)
|
97
|
+
execution_time = calculate_next_execution(booking_execution, time_zone)
|
98
|
+
|
99
|
+
# Get the time now
|
100
|
+
now = datetime.now(pytz.timezone(time_zone))
|
101
|
+
|
102
|
+
# Calculate the seconds until execution
|
103
|
+
time_until_execution = (execution_time - now).total_seconds()
|
104
|
+
|
105
|
+
if time_until_execution > 0:
|
106
|
+
|
107
|
+
logger.info(
|
108
|
+
f"Waiting {time_until_execution:.2f} seconds until global execution time: "
|
109
|
+
f"{execution_time.strftime('%Y-%m-%d %H:%M:%S %z')}."
|
110
|
+
)
|
111
|
+
# Re-authenticate 60 seconds before booking execution
|
112
|
+
reauth_time = time_until_execution - 60
|
113
|
+
|
114
|
+
if reauth_time <= 0:
|
115
|
+
logger.debug("Less than 60 seconds remain until execution; re-authenticating now.")
|
116
|
+
else:
|
117
|
+
logger.debug(f"Re-authenticating in {reauth_time:.2f} seconds.")
|
118
|
+
time.sleep(reauth_time)
|
119
|
+
|
120
|
+
# Re-authenticate before booking
|
121
|
+
logger.info("Re-authenticating before booking.")
|
122
|
+
try:
|
123
|
+
bot.login(config["email"], config["password"], config["centre"])
|
124
|
+
except Exception:
|
125
|
+
logger.warning("Re-authentication failed before booking execution.")
|
126
|
+
|
127
|
+
# Wait the remaining time until execution
|
128
|
+
now = datetime.now(pytz.timezone(time_zone))
|
129
|
+
remaining_time = (execution_time - now).total_seconds()
|
130
|
+
if remaining_time > 0:
|
131
|
+
logger.info(f"Waiting {remaining_time:.2f} seconds until booking execution.")
|
132
|
+
time.sleep(remaining_time)
|
124
133
|
|
125
134
|
# Global booking delay
|
126
135
|
logger.info(f"Waiting {booking_delay} seconds before attempting booking.")
|
127
136
|
time.sleep(booking_delay)
|
128
137
|
|
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
138
|
# Submit bookings in parallel
|
138
139
|
with ThreadPoolExecutor(max_workers=max_threads) as executor:
|
139
140
|
future_to_class = {
|
@@ -156,4 +157,4 @@ def schedule_bookings(
|
|
156
157
|
try:
|
157
158
|
future.result()
|
158
159
|
except Exception:
|
159
|
-
logger.
|
160
|
+
logger.error(f"Booking for '{activity}' at {class_time} failed.")
|
@@ -1,5 +1,5 @@
|
|
1
1
|
from datetime import datetime
|
2
|
-
from typing import Any
|
2
|
+
from typing import Any
|
3
3
|
|
4
4
|
from pysportbot import SportBot
|
5
5
|
from pysportbot.utils.errors import ErrorMessages
|
@@ -10,7 +10,7 @@ logger = get_logger(__name__)
|
|
10
10
|
DAY_MAP = {"monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3, "friday": 4, "saturday": 5, "sunday": 6}
|
11
11
|
|
12
12
|
|
13
|
-
def validate_config(config:
|
13
|
+
def validate_config(config: dict[str, Any]) -> None:
|
14
14
|
"""
|
15
15
|
Validate the overall configuration structure and values.
|
16
16
|
|
@@ -48,7 +48,7 @@ def validate_config(config: Dict[str, Any]) -> None:
|
|
48
48
|
raise ValueError(ErrorMessages.invalid_class_definition())
|
49
49
|
|
50
50
|
|
51
|
-
def validate_activities(bot: SportBot, config:
|
51
|
+
def validate_activities(bot: SportBot, config: dict[str, Any]) -> None:
|
52
52
|
"""
|
53
53
|
Validate that all activities specified in the configuration exist.
|
54
54
|
|
@@ -1,4 +1,4 @@
|
|
1
|
-
from typing import Any
|
1
|
+
from typing import Any
|
2
2
|
|
3
3
|
from pysportbot import SportBot
|
4
4
|
from pysportbot.service.booking import schedule_bookings
|
@@ -8,7 +8,7 @@ from pysportbot.utils.logger import get_logger
|
|
8
8
|
|
9
9
|
|
10
10
|
def run_service(
|
11
|
-
config:
|
11
|
+
config: dict[str, Any],
|
12
12
|
booking_delay: int,
|
13
13
|
retry_attempts: int,
|
14
14
|
retry_delay: int,
|
@@ -115,6 +115,11 @@ class ErrorMessages:
|
|
115
115
|
"""Return an error message for a slot that is unavailable."""
|
116
116
|
return "The slot is not available."
|
117
117
|
|
118
|
+
@staticmethod
|
119
|
+
def slot_capacity_full() -> str:
|
120
|
+
"""Return an error message for a slot that is full."""
|
121
|
+
return "The slot is full. Cannot book."
|
122
|
+
|
118
123
|
@staticmethod
|
119
124
|
def cancellation_failed() -> str:
|
120
125
|
"""Return an error message for a failed cancellation."""
|
@@ -1,7 +1,7 @@
|
|
1
1
|
import logging
|
2
2
|
import threading
|
3
3
|
from datetime import datetime
|
4
|
-
from typing import ClassVar
|
4
|
+
from typing import ClassVar
|
5
5
|
|
6
6
|
import pytz
|
7
7
|
|
@@ -52,7 +52,7 @@ class ColorFormatter(logging.Formatter):
|
|
52
52
|
self.include_threads = include_threads
|
53
53
|
self.thread_colors: dict[str, str] = {} # Initialize empty dictionary
|
54
54
|
|
55
|
-
def formatTime(self, record: logging.LogRecord, datefmt:
|
55
|
+
def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str:
|
56
56
|
"""
|
57
57
|
Override to format the time in the desired timezone.
|
58
58
|
"""
|
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
|
File without changes
|