From 5cdfc5e852f4e9daa34bde21c9e77ddcbc7d88fc Mon Sep 17 00:00:00 2001 From: John Donaldson Date: Fri, 12 Jul 2019 20:37:38 +0100 Subject: [PATCH 1/8] Added basic validator --- devel/validate.py | 127 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 devel/validate.py diff --git a/devel/validate.py b/devel/validate.py new file mode 100644 index 0000000..758e3fa --- /dev/null +++ b/devel/validate.py @@ -0,0 +1,127 @@ +#!/usr/bin/python3 + +import logging + +import moth + +DEFAULT_REQUIRED_FIELDS = ["answers", "authors", "summary"] + +LOGGER = logging.getLogger(__name__) + + +class MothValidationError(Exception): + + pass + + +class MothValidator: + + def __init__(self, fields): + self.required_fields = fields + self.results = {} + + def validate(self, categorydir): + 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] + + 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_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) + + + def check_fields(self, puzzle): + for field in self.required_fields: + if not hasattr(puzzle, field): + raise MothValidationError("Missing field %s" % (field,)) + + def check_has_answers(self, puzzle): + if len(puzzle.answers) == 0: + raise MothValidationError("No answers provided") + + def check_has_authors(self, puzzle): + if len(puzzle.authors) == 0: + raise MothValidationError("No authors provided") + + def check_has_summary(self, puzzle): + if puzzle.summary is None: + raise MothValidationError("Summary has not been provided") + + def check_has_body(self, puzzle): + 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) + + # Leaving this as a placeholder until KSAs are formally supported + def check_ksa_format(self, puzzle): + if hasattr(puzzle, "ksa"): + for ksa in puzzle.ksa: + if not ksa.startswith("K"): + raise MothValidationError("Unrecognized KSA format") + +def output_json(data): + 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"])) + + + +if __name__ == "__main__": + 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", "csv"], default="text", help="Output format (default: text)") + 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) + + if args.output_format == "text": + output_text(validator.results) + elif args.output_format == "json": + output_json(validator.results) From ccf9461d28f3ef92df435098fcc06661aaf6bd56 Mon Sep 17 00:00:00 2001 From: John Donaldson Date: Wed, 17 Jul 2019 23:40:28 +0100 Subject: [PATCH 2/8] Fixed linting issues with validator Fleshed out validator output --- devel/validate.py | 101 +++++++++++++++++++++++++++++++++------------- 1 file changed, 73 insertions(+), 28 deletions(-) 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() From 7701e98a2da3cb1598ef68d9150856a529feaf8e Mon Sep 17 00:00:00 2001 From: John Donaldson Date: Wed, 17 Jul 2019 23:48:43 +0100 Subject: [PATCH 3/8] Better handle some MOTH-related errors --- devel/validate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/devel/validate.py b/devel/validate.py index 7ac2f82..60f2288 100644 --- a/devel/validate.py +++ b/devel/validate.py @@ -31,7 +31,11 @@ class MothValidator: 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) + try: + category = moth.Category(categorydir, 0) + except NotADirectoryError: + return + LOGGER.debug("Found %d puzzles in %s", len(category.pointvals()), categorydir) self.results["category"][categorydir] = { From ecb20713aa64c35cf7a9a804715c110244205205 Mon Sep 17 00:00:00 2001 From: John Donaldson Date: Thu, 18 Jul 2019 18:40:47 +0100 Subject: [PATCH 4/8] Adding unique answer validator --- devel/validate.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/devel/validate.py b/devel/validate.py index 60f2288..3f94e5b 100644 --- a/devel/validate.py +++ b/devel/validate.py @@ -79,6 +79,22 @@ class MothValidator: 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""" From 5d954b2f580e47d8083628f402756b7c313782e4 Mon Sep 17 00:00:00 2001 From: John Donaldson Date: Thu, 18 Jul 2019 18:48:16 +0100 Subject: [PATCH 5/8] Adding validator to detect duplicate authors --- devel/validate.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/devel/validate.py b/devel/validate.py index 3f94e5b..150f0d3 100644 --- a/devel/validate.py +++ b/devel/validate.py @@ -94,13 +94,27 @@ class MothValidator: 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""" From 34ee51bf8487e889e910f15a0c2ecf9b4503193f Mon Sep 17 00:00:00 2001 From: John Donaldson Date: Wed, 12 Jun 2019 18:59:33 +0100 Subject: [PATCH 6/8] Add better logging for raised exceptions --- devel/devel-server.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/devel/devel-server.py b/devel/devel-server.py index f81c0cc..3bc2e29 100755 --- a/devel/devel-server.py +++ b/devel/devel-server.py @@ -150,7 +150,8 @@ class MothRequestHandler(http.server.SimpleHTTPRequestHandler): try: catdir = self.server.args["puzzles_dir"].joinpath(category) mb = mothballer.package(category, catdir, self.seed) - except: + except Exception as ex: + logger.exception(ex) self.send_response(200) self.send_header("Content-Type", "text/html; charset=\"utf-8\"") self.end_headers() From c73abb0c61d4704fefe5738cddddb23e3c6aa895 Mon Sep 17 00:00:00 2001 From: John Donaldson Date: Wed, 12 Jun 2019 19:20:30 +0100 Subject: [PATCH 7/8] And, of course, I spelled it wrong . . . --- devel/devel-server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devel/devel-server.py b/devel/devel-server.py index 3bc2e29..7f35e33 100755 --- a/devel/devel-server.py +++ b/devel/devel-server.py @@ -151,7 +151,7 @@ class MothRequestHandler(http.server.SimpleHTTPRequestHandler): catdir = self.server.args["puzzles_dir"].joinpath(category) mb = mothballer.package(category, catdir, self.seed) except Exception as ex: - logger.exception(ex) + logging.exception(ex) self.send_response(200) self.send_header("Content-Type", "text/html; charset=\"utf-8\"") self.end_headers() From eb19015acafafd4261d02422966b29cdf6d5c563 Mon Sep 17 00:00:00 2001 From: John Donaldson Date: Wed, 10 Jul 2019 23:33:45 +0100 Subject: [PATCH 8/8] Fixing offsets in hexdump output --- devel/moth.py | 1 + 1 file changed, 1 insertion(+) diff --git a/devel/moth.py b/devel/moth.py index 6d04aa2..612d371 100644 --- a/devel/moth.py +++ b/devel/moth.py @@ -235,6 +235,7 @@ class Puzzle: self.body.write('
')
         for hexes, chars in out:
             if chars == lastchars:
+                offset += len(chars)
                 if not elided:
                     self.body.write('*\n')
                     elided = True