diff --git a/devel/devel-server.py b/devel/devel-server.py index f81c0cc..7f35e33 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: + logging.exception(ex) self.send_response(200) self.send_header("Content-Type", "text/html; charset=\"utf-8\"") self.end_headers() diff --git a/devel/moth.py b/devel/moth.py index 890f2f2..92e2ecb 100644 --- a/devel/moth.py +++ b/devel/moth.py @@ -279,6 +279,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
diff --git a/devel/validate.py b/devel/validate.py
new file mode 100644
index 0000000..150f0d3
--- /dev/null
+++ b/devel/validate.py
@@ -0,0 +1,206 @@
+#!/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()