Feat(fortune): ci add special event checker (#50)
* Feat(fortune): ci add special event checker * Chore(Fortune): Move all scripts from `dev` to `scripts` and remove outdated scripts
This commit was merged in pull request #50.
This commit is contained in:
380
scripts/check-events.py
Normal file
380
scripts/check-events.py
Normal file
@@ -0,0 +1,380 @@
|
||||
#!/bin/python3
|
||||
|
||||
import logging
|
||||
import collections
|
||||
import datetime
|
||||
import argparse
|
||||
import enum
|
||||
import json
|
||||
|
||||
class DateType(enum.Enum):
|
||||
CUSTOM = "custom"
|
||||
STATIC = "static"
|
||||
CYCLICAL = "cyclical"
|
||||
|
||||
def __str__(self):
|
||||
return self.name.lower()
|
||||
|
||||
def __repr__(self):
|
||||
return str(self)
|
||||
|
||||
@staticmethod
|
||||
def argparse(s):
|
||||
try:
|
||||
return DateType[s.upper()]
|
||||
except KeyError:
|
||||
return s
|
||||
|
||||
|
||||
args_parser = argparse.ArgumentParser(description="special events checker")
|
||||
args_parser.add_argument("path", type=str, help="event json file path")
|
||||
args_parser.add_argument(
|
||||
"type",
|
||||
type=DateType.argparse,
|
||||
choices=[t for t in DateType],
|
||||
help="event date type",
|
||||
)
|
||||
|
||||
args = args_parser.parse_args()
|
||||
|
||||
special_events: dict[str, list[dict]] = {}
|
||||
|
||||
try:
|
||||
with open(args.path) as f:
|
||||
special_events = json.loads(f.read())
|
||||
except json.JSONDecodeError:
|
||||
print(f"`{args.path}` json syntax error.")
|
||||
exit(-1)
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"`{args.path}` not found.")
|
||||
print("Please contact developer to solve this problem.")
|
||||
exit(-1)
|
||||
|
||||
if not isinstance(special_events, dict):
|
||||
print("`special_events` should be a dict")
|
||||
exit(-1)
|
||||
|
||||
if "special_events" not in special_events:
|
||||
print(f"`special_events` not found in `{args.path}`.")
|
||||
exit(-1)
|
||||
|
||||
if not isinstance(special_events["special_events"], list):
|
||||
print(f"`special_events` in `{args.path}` should be a list.")
|
||||
exit(-1)
|
||||
|
||||
MIN_STATUS_INDEX = 0
|
||||
MAX_STATUS_INDEX = 7
|
||||
DAYSPERMONTH = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||
|
||||
errors: dict[int, list[str]] = collections.defaultdict(list)
|
||||
|
||||
|
||||
def is_leap_year(year: int) -> bool:
|
||||
"""Determines whether a given year is a leap year.
|
||||
|
||||
Args:
|
||||
year (int): The year to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the year is a leap year, False otherwise.
|
||||
"""
|
||||
|
||||
if year % 400 == 0:
|
||||
return True
|
||||
if year % 100 == 0:
|
||||
return False
|
||||
if year % 4 == 0:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def validate_number(event_idx: int, value, min: int, max: int, field_name: str) -> int | None:
|
||||
"""Validates whether a given value is an integer within a specified range.
|
||||
|
||||
Args:
|
||||
event_idx (int): The index of the event for associating validation errors.
|
||||
value (Any): The value to validate.
|
||||
min (int): The minimum acceptable value (inclusive).
|
||||
max (int): The maximum acceptable value (inclusive).
|
||||
field_name (str): The name of the field being validated, used in error messages.
|
||||
|
||||
Returns:
|
||||
int | None: The validated integer value if it is within the range, otherwise None.
|
||||
|
||||
Raises:
|
||||
ValueError: If `value` cannot be converted to an integer.
|
||||
|
||||
Validation Rules:
|
||||
- If `value` cannot be converted to an integer, an error is recorded and None is returned.
|
||||
- If `value` is outside the range defined by `min` and `max`, an error is recorded and None is returned.
|
||||
"""
|
||||
|
||||
try:
|
||||
value = int(value)
|
||||
except ValueError:
|
||||
errors[event_idx].append(f"`{field_name}` should be between {min} and {max}")
|
||||
return None
|
||||
|
||||
if value < min or value > max:
|
||||
errors[event_idx].append(f"`{field_name}` should be between {min} and {max}")
|
||||
return None
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def require_field_check(
|
||||
obj: dict, event_idx: int, fields: list[tuple[str, type]], required_field: str = ""
|
||||
) -> bool:
|
||||
"""
|
||||
Validates the presence and types of required fields in a given object.
|
||||
|
||||
Args:
|
||||
obj (dict): The object (dictionary) to validate.
|
||||
event_idx (int): The index of the event for associating validation errors.
|
||||
fields (list[tuple[str, type]]): A list of tuples where each tuple contains a field name and its expected type.
|
||||
required_field (str, optional): An optional prefix for error messages to indicate a higher-level required field. Defaults to "".
|
||||
|
||||
Returns:
|
||||
bool: True if all required fields are present and have the correct types, otherwise False.
|
||||
|
||||
Validation Rules:
|
||||
- If a required field is missing, an error message is recorded.
|
||||
- If a field is present but its type does not match the expected type, an error message is recorded.
|
||||
- The `required_field` parameter, if provided, is prepended to error messages for context.
|
||||
"""
|
||||
|
||||
error_found = False
|
||||
for field_name, field_type in fields:
|
||||
if field_name not in obj:
|
||||
error_found = True
|
||||
msg = ""
|
||||
if required_field != "":
|
||||
msg = f"`{required_field}` "
|
||||
|
||||
msg += f"missing `{field_name}`."
|
||||
errors[event_idx].append(msg)
|
||||
|
||||
elif not isinstance(obj[field_name], field_type):
|
||||
error_found = True
|
||||
errors[event_idx].append(f"`{field_name}` should be a `{field_type}` type.")
|
||||
|
||||
if error_found:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
event_names = set()
|
||||
event_dates = set()
|
||||
|
||||
|
||||
def check_structure(event: dict, idx: int):
|
||||
if not require_field_check(
|
||||
event,
|
||||
idx,
|
||||
[
|
||||
("event", str),
|
||||
("triggerDate", dict),
|
||||
("status_index", str),
|
||||
("goodFortunes", dict),
|
||||
("badFortunes", dict),
|
||||
],
|
||||
):
|
||||
return False
|
||||
|
||||
event_name: str = event["event"]
|
||||
if event_name.strip() == "":
|
||||
errors[idx].append("event name should not empty.")
|
||||
return
|
||||
|
||||
if event_name in event_names:
|
||||
errors[idx].append(f"event `{event_name}` already exists.")
|
||||
|
||||
validate_number(
|
||||
idx, event["status_index"], MIN_STATUS_INDEX, MAX_STATUS_INDEX, "status_index"
|
||||
)
|
||||
|
||||
if require_field_check(
|
||||
event["goodFortunes"],
|
||||
idx,
|
||||
[
|
||||
("l_1_event", str),
|
||||
("l_1_desc", str),
|
||||
("l_2_event", str),
|
||||
("l_2_desc", str),
|
||||
],
|
||||
"goodFortunes"
|
||||
):
|
||||
if bool(event["goodFortunes"]["l_1_event"]) ^ bool(event["goodFortunes"]["l_1_desc"]):
|
||||
# Check for inconsistency: XOR is used to ensure both l_1_event and l_1_desc
|
||||
# are either both provided or both missing. If only one is provided, log an error.
|
||||
errors[idx].append("First good fortune is incomplete.")
|
||||
|
||||
if bool(event["goodFortunes"]["l_2_event"]) ^ bool(event["goodFortunes"]["l_2_desc"]):
|
||||
# Check for inconsistency: XOR is used to ensure both l_2_event and l_2_desc
|
||||
# are either both provided or both missing. If only one is provided, log an error.
|
||||
errors[idx].append("Second good fortune is incomplete.")
|
||||
|
||||
if require_field_check(
|
||||
event["badFortunes"],
|
||||
idx,
|
||||
[
|
||||
("r_1_event", str),
|
||||
("r_1_desc", str),
|
||||
("r_2_event", str),
|
||||
("r_2_desc", str),
|
||||
],
|
||||
"badFortunes"
|
||||
):
|
||||
if bool(event["badFortunes"]["r_1_event"]) ^ bool(event["badFortunes"]["r_1_desc"]):
|
||||
# Check for inconsistency: XOR is used to ensure both r_1_event and r_1_desc
|
||||
# are either both provided or both missing. If only one is provided, log an error.
|
||||
errors[idx].append("First bad fortune is incomplete.")
|
||||
|
||||
if bool(event["badFortunes"]["r_2_event"]) ^ bool(event["badFortunes"]["r_2_desc"]):
|
||||
# Check for inconsistency: XOR is used to ensure both r_2_event and r_2_desc
|
||||
# are either both provided or both missing. If only one is provided, log an error.
|
||||
errors[idx].append("Second bad fortune is incomplete.")
|
||||
|
||||
event_names.add(event_name)
|
||||
|
||||
return True
|
||||
|
||||
def check_static_date(event: dict, idx: int):
|
||||
trigger_date: dict = event["triggerDate"]
|
||||
corrected = require_field_check(
|
||||
trigger_date,
|
||||
idx,
|
||||
[
|
||||
("month", str),
|
||||
("date", str),
|
||||
],
|
||||
"triggerDate",
|
||||
)
|
||||
|
||||
event_name: str = event["event"]
|
||||
if "year" in trigger_date:
|
||||
errors[idx].append(
|
||||
f"this event `{event_name}` should be placed in `custom_special.json`."
|
||||
)
|
||||
|
||||
if "week" in trigger_date or "weekday" in trigger_date:
|
||||
errors[idx].append(
|
||||
f"this event `{event_name}` should be placed in `cyclical_special.json`."
|
||||
)
|
||||
|
||||
if not corrected:
|
||||
return
|
||||
|
||||
month = validate_number(idx, trigger_date["month"], 1, 12, "triggerDate.month")
|
||||
if month is not None:
|
||||
validate_number(
|
||||
idx, trigger_date["date"], 1, DAYSPERMONTH[month], "triggerDate.date"
|
||||
)
|
||||
|
||||
|
||||
def check_cyclical_date(event: dict, idx: int):
|
||||
trigger_date: dict = event["triggerDate"]
|
||||
corrected = require_field_check(
|
||||
trigger_date,
|
||||
idx,
|
||||
[
|
||||
("month", str),
|
||||
("week", str),
|
||||
("weekday", str),
|
||||
],
|
||||
"triggerDate",
|
||||
)
|
||||
|
||||
event_name: str = event["event"]
|
||||
if "year" in trigger_date:
|
||||
errors[idx].append(
|
||||
f"this event `{event_name}` should be placed in `custom_special.json`."
|
||||
)
|
||||
|
||||
elif "date" in trigger_date:
|
||||
errors[idx].append(
|
||||
f"this event `{event_name}` should be placed in `static_special.json`."
|
||||
)
|
||||
|
||||
if not corrected:
|
||||
return
|
||||
|
||||
validate_number(idx, trigger_date["month"], 1, 12, "triggerDate.month")
|
||||
validate_number(idx, trigger_date["week"], 1, 5, "triggerDate.week")
|
||||
validate_number(idx, trigger_date["weekday"], 1, 7, "triggerDate.weekday")
|
||||
|
||||
|
||||
def check_custom_date(event: dict, idx: int):
|
||||
trigger_date: dict = event["triggerDate"]
|
||||
corrected = require_field_check(
|
||||
trigger_date,
|
||||
idx,
|
||||
[
|
||||
("year", str),
|
||||
("month", str),
|
||||
("date", str),
|
||||
],
|
||||
"triggerDate",
|
||||
)
|
||||
|
||||
event_name: str = event["event"]
|
||||
if "week" in trigger_date or "weekday" in trigger_date:
|
||||
errors[idx].append(
|
||||
f"this event `{event_name}` should be placed in `cyclical_special.json`.",
|
||||
)
|
||||
|
||||
elif "year" not in trigger_date:
|
||||
errors[idx].append(
|
||||
f"this event `{event_name}` should be placed in `static_special.json`."
|
||||
)
|
||||
|
||||
if not corrected:
|
||||
return
|
||||
|
||||
year = validate_number(
|
||||
idx,
|
||||
trigger_date["year"],
|
||||
datetime.datetime.min.year,
|
||||
datetime.datetime.max.year,
|
||||
"triggerDate.year",
|
||||
)
|
||||
month = validate_number(idx, trigger_date["month"], 1, 12, "triggerDate.month")
|
||||
|
||||
if year is None or month is None:
|
||||
return
|
||||
|
||||
days = DAYSPERMONTH[month]
|
||||
if month == 2 and is_leap_year(year):
|
||||
days += 1 # 29
|
||||
|
||||
date = validate_number(idx, trigger_date["date"], 1, days, "triggerDate.date")
|
||||
if date is None:
|
||||
return
|
||||
|
||||
date_str = f"{year}/{month}/{date}"
|
||||
if date_str in event_dates:
|
||||
errors[idx].append(f"The date `{date_str}` of `{event_name}` is repeated.")
|
||||
|
||||
event_dates.add(date_str)
|
||||
|
||||
|
||||
date_checker = {
|
||||
DateType.CUSTOM: check_custom_date,
|
||||
DateType.STATIC: check_static_date,
|
||||
DateType.CYCLICAL: check_cyclical_date,
|
||||
}
|
||||
check_triggerdate = date_checker[args.type]
|
||||
|
||||
for idx, event in enumerate(special_events["special_events"]):
|
||||
if check_structure(event, idx):
|
||||
check_triggerdate(event, idx)
|
||||
|
||||
if errors:
|
||||
logging.error(args.path)
|
||||
for idx, error_msgs in errors.items():
|
||||
logging.error(json.dumps(special_events["special_events"][idx], indent=4))
|
||||
for msg in error_msgs:
|
||||
logging.error(msg)
|
||||
exit(-1)
|
||||
Reference in New Issue
Block a user