#!/usr/bin/python3 """A validator for MOTH puzzles""" import logging import os import os.path import moth # pylint: disable=len-as-condition, line-too-long DEFAULT_REQUIRED_FIELDS = ["answers", "authors", "summary"] LOGGER = logging.getLogger(__name__) class MothValidationError(Exception): """An exception for encapsulating MOTH puzzle validation errors""" class MothValidator: """A class which validates MOTH categories""" def __init__(self, fields): self.required_fields = fields self.results = {"category": {}, "checks": []} def validate(self, categorydir, only_errors=False): """Run validation checks against a category""" LOGGER.debug("Loading category from %s", categorydir) try: category = moth.Category(categorydir, 0) except NotADirectoryError: return LOGGER.debug("Found %d puzzles in %s", len(category.pointvals()), categorydir) 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) for puzzle in category: LOGGER.info("Processing %s: %s", categorydir, puzzle.points) curr_category["puzzles"][puzzle.points] = {} curr_puzzle = curr_category["puzzles"][puzzle.points] 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: curr_puzzle["failures"].append(str(ex)) if only_errors and len(curr_puzzle["failures"]) == 0: del curr_category["puzzles"][puzzle.points] def check_fields(self, puzzle): """Check if the puzzle has the requested fields""" for field in self.required_fields: if not hasattr(puzzle, field): raise MothValidationError("Missing field %s" % (field,)) @staticmethod def check_has_answers(puzzle): """Check if the puzle has answers defined""" if len(puzzle.answers) == 0: raise MothValidationError("No answers provided") @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)) @staticmethod def check_has_authors(puzzle): """Check if the puzzle has authors defined""" if len(puzzle.authors) == 0: raise MothValidationError("No authors provided") @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)) @staticmethod def check_has_summary(puzzle): """Check if the puzzle has a summary""" if puzzle.summary is None: raise MothValidationError("Summary has not been provided") @staticmethod def check_has_body(puzzle): """Check if the puzzle has a body defined""" 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") puzzle.body.seek(old_pos) # Leaving this as a placeholder until KSAs are formally supported @staticmethod def check_ksa_format(puzzle): """Check if KSAs are properly formatted""" if hasattr(puzzle, "ksa"): for ksa in puzzle.ksa: if not ksa.startswith("K"): raise MothValidationError("Unrecognized KSA format") def output_json(data): """Output results in JSON format""" import json print(json.dumps(data)) def output_text(data): """Output results in a text-based tabular format""" 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"]))]) 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 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)) 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") 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) validator.validate(category, only_errors=args.only_errors) if args.output_format == "text": output_text(validator.results) elif args.output_format == "json": output_json(validator.results) if __name__ == "__main__": main()