2019-07-12 13:37:38 -06:00
|
|
|
#!/usr/bin/python3
|
|
|
|
|
2019-07-17 16:40:28 -06:00
|
|
|
"""A validator for MOTH puzzles"""
|
|
|
|
|
2019-07-12 13:37:38 -06:00
|
|
|
import logging
|
2019-07-17 16:40:28 -06:00
|
|
|
import os
|
|
|
|
import os.path
|
2019-08-14 16:44:12 -06:00
|
|
|
import re
|
2019-07-12 13:37:38 -06:00
|
|
|
|
|
|
|
import moth
|
|
|
|
|
2019-07-17 16:40:28 -06:00
|
|
|
# pylint: disable=len-as-condition, line-too-long
|
|
|
|
|
2019-07-12 13:37:38 -06:00
|
|
|
DEFAULT_REQUIRED_FIELDS = ["answers", "authors", "summary"]
|
|
|
|
|
|
|
|
LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
class MothValidationError(Exception):
|
|
|
|
|
2019-07-17 16:40:28 -06:00
|
|
|
"""An exception for encapsulating MOTH puzzle validation errors"""
|
2019-07-12 13:37:38 -06:00
|
|
|
|
|
|
|
|
|
|
|
class MothValidator:
|
|
|
|
|
2019-07-17 16:40:28 -06:00
|
|
|
"""A class which validates MOTH categories"""
|
|
|
|
|
2019-07-12 13:37:38 -06:00
|
|
|
def __init__(self, fields):
|
|
|
|
self.required_fields = fields
|
2019-07-17 16:40:28 -06:00
|
|
|
self.results = {"category": {}, "checks": []}
|
2019-07-12 13:37:38 -06:00
|
|
|
|
2019-07-17 16:40:28 -06:00
|
|
|
def validate(self, categorydir, only_errors=False):
|
|
|
|
"""Run validation checks against a category"""
|
2019-07-12 13:37:38 -06:00
|
|
|
LOGGER.debug("Loading category from %s", categorydir)
|
2019-07-17 16:48:43 -06:00
|
|
|
try:
|
|
|
|
category = moth.Category(categorydir, 0)
|
|
|
|
except NotADirectoryError:
|
|
|
|
return
|
|
|
|
|
2019-07-12 13:37:38 -06:00
|
|
|
LOGGER.debug("Found %d puzzles in %s", len(category.pointvals()), categorydir)
|
|
|
|
|
2019-07-17 16:40:28 -06:00
|
|
|
self.results["category"][categorydir] = {
|
|
|
|
"puzzles": {},
|
|
|
|
"name": os.path.basename(categorydir.strip(os.sep)),
|
|
|
|
}
|
|
|
|
curr_category = self.results["category"][categorydir]
|
|
|
|
|
|
|
|
for check_function_name in [x for x in dir(self) if x.startswith("check_") and callable(getattr(self, x))]:
|
|
|
|
if check_function_name not in self.results["checks"]:
|
|
|
|
self.results["checks"].append(check_function_name)
|
2019-07-12 13:37:38 -06:00
|
|
|
|
|
|
|
for puzzle in category:
|
|
|
|
LOGGER.info("Processing %s: %s", categorydir, puzzle.points)
|
|
|
|
|
2019-07-17 16:40:28 -06:00
|
|
|
curr_category["puzzles"][puzzle.points] = {}
|
|
|
|
curr_puzzle = curr_category["puzzles"][puzzle.points]
|
2019-07-12 13:37:38 -06:00
|
|
|
curr_puzzle["failures"] = []
|
|
|
|
|
|
|
|
for check_function_name in [x for x in dir(self) if x.startswith("check_") and callable(getattr(self, x))]:
|
|
|
|
check_function = getattr(self, check_function_name)
|
|
|
|
LOGGER.debug("Running %s on %d", check_function_name, puzzle.points)
|
|
|
|
|
|
|
|
try:
|
|
|
|
check_function(puzzle)
|
|
|
|
except MothValidationError as ex:
|
2019-07-17 16:40:28 -06:00
|
|
|
curr_puzzle["failures"].append(str(ex))
|
2019-07-12 13:37:38 -06:00
|
|
|
|
2019-07-17 16:40:28 -06:00
|
|
|
if only_errors and len(curr_puzzle["failures"]) == 0:
|
|
|
|
del curr_category["puzzles"][puzzle.points]
|
2019-07-12 13:37:38 -06:00
|
|
|
|
|
|
|
def check_fields(self, puzzle):
|
2019-07-17 16:40:28 -06:00
|
|
|
"""Check if the puzzle has the requested fields"""
|
2019-07-12 13:37:38 -06:00
|
|
|
for field in self.required_fields:
|
2019-08-14 17:07:22 -06:00
|
|
|
if not hasattr(puzzle, field) or \
|
|
|
|
getattr(puzzle,field) is None or \
|
|
|
|
getattr(puzzle,field) == "":
|
2019-07-12 13:37:38 -06:00
|
|
|
raise MothValidationError("Missing field %s" % (field,))
|
|
|
|
|
2019-07-17 16:40:28 -06:00
|
|
|
@staticmethod
|
|
|
|
def check_has_answers(puzzle):
|
|
|
|
"""Check if the puzle has answers defined"""
|
2019-07-12 13:37:38 -06:00
|
|
|
if len(puzzle.answers) == 0:
|
|
|
|
raise MothValidationError("No answers provided")
|
|
|
|
|
2019-07-18 11:40:47 -06:00
|
|
|
@staticmethod
|
|
|
|
def check_unique_answers(puzzle):
|
|
|
|
"""Check if puzzle answers are unique"""
|
|
|
|
known_answers = []
|
|
|
|
duplicate_answers = []
|
|
|
|
|
|
|
|
for answer in puzzle.answers:
|
|
|
|
if answer not in known_answers:
|
|
|
|
known_answers.append(answer)
|
|
|
|
else:
|
|
|
|
duplicate_answers.append(answer)
|
|
|
|
|
|
|
|
if len(duplicate_answers) > 0:
|
|
|
|
raise MothValidationError("Duplicate answer(s) %s" % ", ".join(duplicate_answers))
|
|
|
|
|
2019-07-17 16:40:28 -06:00
|
|
|
@staticmethod
|
|
|
|
def check_has_authors(puzzle):
|
|
|
|
"""Check if the puzzle has authors defined"""
|
2019-07-12 13:37:38 -06:00
|
|
|
if len(puzzle.authors) == 0:
|
|
|
|
raise MothValidationError("No authors provided")
|
|
|
|
|
2019-07-18 11:48:16 -06:00
|
|
|
@staticmethod
|
|
|
|
def check_unique_authors(puzzle):
|
|
|
|
"""Check if puzzle authors are unique"""
|
|
|
|
known_authors = []
|
|
|
|
duplicate_authors = []
|
|
|
|
|
|
|
|
for author in puzzle.authors:
|
|
|
|
if author not in known_authors:
|
|
|
|
known_authors.append(author)
|
|
|
|
else:
|
|
|
|
duplicate_authors.append(author)
|
|
|
|
|
|
|
|
if len(duplicate_authors) > 0:
|
|
|
|
raise MothValidationError("Duplicate author(s) %s" % ", ".join(duplicate_authors))
|
|
|
|
|
2019-07-17 16:40:28 -06:00
|
|
|
@staticmethod
|
|
|
|
def check_has_summary(puzzle):
|
|
|
|
"""Check if the puzzle has a summary"""
|
2019-07-12 13:37:38 -06:00
|
|
|
if puzzle.summary is None:
|
|
|
|
raise MothValidationError("Summary has not been provided")
|
|
|
|
|
2019-07-17 16:40:28 -06:00
|
|
|
@staticmethod
|
|
|
|
def check_has_body(puzzle):
|
|
|
|
"""Check if the puzzle has a body defined"""
|
2019-07-12 13:37:38 -06:00
|
|
|
old_pos = puzzle.body.tell()
|
|
|
|
puzzle.body.seek(0)
|
|
|
|
if len(puzzle.body.read()) == 0:
|
|
|
|
puzzle.body.seek(old_pos)
|
|
|
|
raise MothValidationError("No body provided")
|
2019-07-17 16:40:28 -06:00
|
|
|
|
|
|
|
puzzle.body.seek(old_pos)
|
2019-07-12 13:37:38 -06:00
|
|
|
|
2019-07-17 16:40:28 -06:00
|
|
|
@staticmethod
|
|
|
|
def check_ksa_format(puzzle):
|
|
|
|
"""Check if KSAs are properly formatted"""
|
2019-08-14 16:44:12 -06:00
|
|
|
|
|
|
|
ksa_re = re.compile("^[KSA]\d{4}$")
|
|
|
|
|
2019-07-12 13:37:38 -06:00
|
|
|
if hasattr(puzzle, "ksa"):
|
|
|
|
for ksa in puzzle.ksa:
|
2019-08-14 16:44:12 -06:00
|
|
|
if ksa_re.match(ksa) is None:
|
|
|
|
raise MothValidationError("Unrecognized KSA format (%s)" % (ksa,))
|
2019-07-12 13:37:38 -06:00
|
|
|
|
2019-08-14 16:55:37 -06:00
|
|
|
@staticmethod
|
|
|
|
def check_success(puzzle):
|
|
|
|
"""Check if success criteria are defined"""
|
|
|
|
|
|
|
|
if not hasattr(puzzle, "success"):
|
|
|
|
raise MothValidationError("Success not defined")
|
|
|
|
|
|
|
|
criteria = ["acceptable", "mastery"]
|
|
|
|
missing_criteria = []
|
|
|
|
for criterion in criteria:
|
|
|
|
if criterion not in puzzle.success.keys() or \
|
|
|
|
puzzle.success[criterion] is None or \
|
|
|
|
len(puzzle.success[criterion]) == 0:
|
|
|
|
missing_criteria.append(criterion)
|
|
|
|
|
|
|
|
if len(missing_criteria) > 0:
|
|
|
|
raise MothValidationError("Missing success criteria (%s)" % (", ".join(missing_criteria)))
|
|
|
|
|
2019-07-17 16:40:28 -06:00
|
|
|
|
2019-07-12 13:37:38 -06:00
|
|
|
def output_json(data):
|
2019-07-17 16:40:28 -06:00
|
|
|
"""Output results in JSON format"""
|
2019-07-12 13:37:38 -06:00
|
|
|
import json
|
|
|
|
print(json.dumps(data))
|
|
|
|
|
2019-07-17 16:40:28 -06:00
|
|
|
|
2019-07-12 13:37:38 -06:00
|
|
|
def output_text(data):
|
2019-07-17 16:40:28 -06:00
|
|
|
"""Output results in a text-based tabular format"""
|
2019-07-12 13:37:38 -06:00
|
|
|
|
2019-07-17 16:40:28 -06:00
|
|
|
longest_category = max([len(y["name"]) for x, y in data["category"].items()])
|
|
|
|
longest_category = max([longest_category, len("Category")])
|
|
|
|
longest_failure = len("Failures")
|
|
|
|
for category_data in data["category"].values():
|
|
|
|
for points, puzzle_data in category_data["puzzles"].items():
|
|
|
|
longest_failure = max([longest_failure, len(", ".join(puzzle_data["failures"]))])
|
2019-07-12 13:37:38 -06:00
|
|
|
|
2019-07-17 16:40:28 -06:00
|
|
|
formatstr = "| %%%ds | %%6s | %%%ds |" % (longest_category, longest_failure)
|
|
|
|
headerfmt = formatstr % ("Category", "Points", "Failures")
|
|
|
|
|
|
|
|
print(headerfmt)
|
|
|
|
for cat_data in data["category"].values():
|
|
|
|
for points, puzzle_data in sorted(cat_data["puzzles"].items()):
|
|
|
|
print(formatstr % (cat_data["name"], points, ", ".join([str(x) for x in puzzle_data["failures"]])))
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
"""Main function"""
|
|
|
|
# pylint: disable=invalid-name
|
2019-07-12 13:37:38 -06:00
|
|
|
import argparse
|
|
|
|
|
|
|
|
LOGGER.addHandler(logging.StreamHandler())
|
|
|
|
|
|
|
|
parser = argparse.ArgumentParser(description="Validate MOTH puzzle field compliance")
|
|
|
|
parser.add_argument("category", nargs="+", help="Categories to validate")
|
|
|
|
parser.add_argument("-f", "--fields", help="Comma-separated list of fields to check for", default=",".join(DEFAULT_REQUIRED_FIELDS))
|
|
|
|
|
2019-07-17 16:40:28 -06:00
|
|
|
parser.add_argument("-o", "--output-format", choices=["text", "json"], default="text", help="Output format (default: text)")
|
|
|
|
parser.add_argument("-e", "--only-errors", action="store_true", default=False, help="Only output errors")
|
2019-07-12 13:37:38 -06:00
|
|
|
parser.add_argument("-v", "--verbose", action="count", default=0, help="Increase verbosity of output, repeat to increase")
|
|
|
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
if args.verbose == 1:
|
|
|
|
LOGGER.setLevel("INFO")
|
|
|
|
elif args.verbose > 1:
|
|
|
|
LOGGER.setLevel("DEBUG")
|
|
|
|
|
|
|
|
LOGGER.debug(args)
|
|
|
|
validator = MothValidator(args.fields.split(","))
|
|
|
|
|
|
|
|
for category in args.category:
|
|
|
|
LOGGER.info("Validating %s", category)
|
2019-07-17 16:40:28 -06:00
|
|
|
validator.validate(category, only_errors=args.only_errors)
|
2019-07-12 13:37:38 -06:00
|
|
|
|
|
|
|
if args.output_format == "text":
|
|
|
|
output_text(validator.results)
|
|
|
|
elif args.output_format == "json":
|
|
|
|
output_json(validator.results)
|
2019-07-17 16:40:28 -06:00
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
main()
|