diff --git a/devel/validate.py b/devel/validate.py index 758e3fa..7ac2f82 100644 --- a/devel/validate.py +++ b/devel/validate.py @@ -1,9 +1,15 @@ #!/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__) @@ -11,91 +17,125 @@ LOGGER = logging.getLogger(__name__) class MothValidationError(Exception): - pass + """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 = {} + self.results = {"category": {}, "checks": []} - def validate(self, categorydir): + def validate(self, categorydir, only_errors=False): + """Run validation checks against a category""" LOGGER.debug("Loading category from %s", categorydir) category = moth.Category(categorydir, 0) LOGGER.debug("Found %d puzzles in %s", len(category.pointvals()), categorydir) - self.results[categorydir] = {} - curr_category = self.results[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[puzzle.points] = {} - curr_puzzle = curr_category[puzzle.points] - curr_puzzle["checks"] = [] + 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) - curr_puzzle["checks"].append(check_function_name) - try: check_function(puzzle) except MothValidationError as ex: - curr_puzzle["failures"].append(ex) - LOGGER.exception(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,)) - def check_has_answers(self, puzzle): + @staticmethod + def check_has_answers(puzzle): + """Check if the puzle has answers defined""" if len(puzzle.answers) == 0: raise MothValidationError("No answers provided") - def check_has_authors(self, puzzle): + @staticmethod + def check_has_authors(puzzle): + """Check if the puzzle has authors defined""" if len(puzzle.authors) == 0: raise MothValidationError("No authors provided") - def check_has_summary(self, puzzle): + @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") - def check_has_body(self, puzzle): + @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") - else: - puzzle.body.seek(old_pos) + + puzzle.body.seek(old_pos) # Leaving this as a placeholder until KSAs are formally supported - def check_ksa_format(self, puzzle): + @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): - for category, cat_data in data.items(): - print("= %s =" % (category,)) - print("| Points | Checks | Errors |") - for points, puzzle_data in cat_data.items(): - print("| %d | %s | %s |" % (points, ", ".join(puzzle_data["checks"]), puzzle_data["failures"])) - + """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"]]))) -if __name__ == "__main__": +def main(): + """Main function""" + # pylint: disable=invalid-name import argparse LOGGER.addHandler(logging.StreamHandler()) @@ -104,7 +144,8 @@ if __name__ == "__main__": 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", "csv"], default="text", help="Output format (default: text)") + 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() @@ -119,9 +160,13 @@ if __name__ == "__main__": for category in args.category: LOGGER.info("Validating %s", category) - validator.validate(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()