pysportbot 0.0.1__py3-none-any.whl

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