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.
Files changed (24) hide show
  1. {pysportbot-0.0.9 → pysportbot-0.0.11}/PKG-INFO +2 -3
  2. {pysportbot-0.0.9 → pysportbot-0.0.11}/pyproject.toml +12 -8
  3. {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/__init__.py +28 -10
  4. {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/service/__main__.py +2 -2
  5. {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/service/booking.py +48 -47
  6. pysportbot-0.0.11/pysportbot/service/config_loader.py +7 -0
  7. {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/service/config_validator.py +3 -3
  8. {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/service/service.py +2 -2
  9. {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/utils/errors.py +5 -0
  10. {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/utils/logger.py +2 -2
  11. pysportbot-0.0.9/pysportbot/service/config_loader.py +0 -7
  12. {pysportbot-0.0.9 → pysportbot-0.0.11}/LICENSE +0 -0
  13. {pysportbot-0.0.9 → pysportbot-0.0.11}/README.md +0 -0
  14. {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/activities.py +0 -0
  15. {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/authenticator.py +0 -0
  16. {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/bookings.py +0 -0
  17. {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/centres.py +0 -0
  18. {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/endpoints.py +0 -0
  19. {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/service/__init__.py +0 -0
  20. {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/service/scheduling.py +0 -0
  21. {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/service/threading.py +0 -0
  22. {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/session.py +0 -0
  23. {pysportbot-0.0.9 → pysportbot-0.0.11}/pysportbot/utils/__init__.py +0 -0
  24. {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.9
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.9,<3.14
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.9"
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.9,<3.14"
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.21.2"
27
- mypy = "^1.14.0"
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>=1.9.1"]
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 = ['py37']
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 = "py37"
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: Optional[Authenticator] = None
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 # Ensure state is False on failure
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
- slot_id = matching_slot.iloc[0]["id_activity_calendar"]
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.exception(f"Failed to book class '{activity}' on {start_time}")
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.exception(f"Failed to cancel class '{activity}' on {start_time}")
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, Dict
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: Dict[str, Any] = load_config(args.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, Dict
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
- available_slots = bot.daily_slots(activity=activity, day=booking_date)
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: Dict[str, Any],
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
- # Wait globally before starting bookings
123
- wait_for_execution(config["booking_execution"], time_zone)
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.exception(f"Booking for '{activity}' at {class_time} failed.")
160
+ logger.error(f"Booking for '{activity}' at {class_time} failed.")
@@ -0,0 +1,7 @@
1
+ import json
2
+ from typing import Any, cast
3
+
4
+
5
+ def load_config(config_path: str) -> dict[str, Any]:
6
+ with open(config_path) as f:
7
+ return cast(dict[str, Any], json.load(f))
@@ -1,5 +1,5 @@
1
1
  from datetime import datetime
2
- from typing import Any, Dict
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: Dict[str, Any]) -> None:
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: Dict[str, Any]) -> None:
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, Dict
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: Dict[str, Any],
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, Optional
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: Optional[str] = None) -> str:
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
  """
@@ -1,7 +0,0 @@
1
- import json
2
- from typing import Any, Dict, cast
3
-
4
-
5
- def load_config(config_path: str) -> Dict[str, Any]:
6
- with open(config_path) as f:
7
- return cast(Dict[str, Any], json.load(f))
File without changes
File without changes