From 4aad492396222b152e5305d2db1178849c5cc3e2 Mon Sep 17 00:00:00 2001 From: John Donaldson Date: Sat, 6 Jul 2019 00:53:46 +0100 Subject: [PATCH 01/15] Adding YAML support to Moth files --- Dockerfile.moth-devel | 3 +- devel/moth.py | 119 ++++++++++++++++++++++++++++-------------- 2 files changed, 82 insertions(+), 40 deletions(-) diff --git a/Dockerfile.moth-devel b/Dockerfile.moth-devel index 6e8466a..a667a8e 100644 --- a/Dockerfile.moth-devel +++ b/Dockerfile.moth-devel @@ -9,7 +9,8 @@ RUN apk --no-cache add \ && \ pip3 install \ scapy==2.4.2 \ - pillow==5.4.1 + pillow==5.4.1 \ + PyYAML==5.1.1 COPY devel /app/ diff --git a/devel/moth.py b/devel/moth.py index 6d04aa2..c74d8ee 100644 --- a/devel/moth.py +++ b/devel/moth.py @@ -13,6 +13,7 @@ import random import string import tempfile import shlex +import yaml messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' @@ -91,47 +92,87 @@ class Puzzle: def read_stream(self, stream): header = True + line = "" + if stream.read(3) == "---": + header = "yaml" + else: + header = "moth" + + stream.seek(0) + + if header == "yaml": + self.read_yaml_header(stream) + elif header == "moth": + self.read_moth_header(stream) + for line in stream: - if header: - line = line.strip() - if not line: - header = False - continue - key, val = line.split(':', 1) - key = key.lower() - val = val.strip() - if key == 'author': - self.authors.append(val) - elif key == 'summary': - self.summary = val - elif key == 'answer': - self.answers.append(val) - elif key == 'pattern': - self.pattern = val - elif key == 'hint': - self.hint = val - elif key == 'name': - pass - elif key == 'file': - parts = shlex.split(val) - name = parts[0] - hidden = False - stream = open(name, 'rb') - try: - name = parts[1] - hidden = (parts[2].lower() == "hidden") - except IndexError: - pass - self.files[name] = PuzzleFile(stream, name, not hidden) - elif key == 'script': - stream = open(val, 'rb') - # Make sure this shows up in the header block of the HTML output. - self.files[val] = PuzzleFile(stream, val, visible=False) - self.scripts.append(val) - else: - raise ValueError("Unrecognized header field: {}".format(key)) + self.body.write(line) + + def read_yaml_header(self, stream): + contents = "" + header = False + for line in stream: + if line.strip() == "---" and header: # Handle last line + break + elif line.strip() == "---": # Handle first line + header = True + continue else: - self.body.write(line) + contents += line + + config = yaml.safe_load(contents) + for key, value in config.items(): + key = key.lower() + self.handle_header_key(key, value) + + + def read_moth_header(self, stream): + for line in stream: + line = line.strip() + if not line: + break + + key, val = line.split(':', 1) + key = key.lower() + val = val.strip() + self.handle_header_key(key, val) + + def handle_header_key(self, key, val): + if key == 'author': + self.authors.append(val) + elif key == 'summary': + self.summary = val + elif key == 'answer': + self.answers.append(val) + elif key == "answers": + for answer in val: + answer = str(answer) + self.answers.append(answer) + elif key == 'pattern': + self.pattern = val + elif key == 'hint': + self.hint = val + elif key == 'name': + pass + elif key == 'file': + parts = shlex.split(val) + name = parts[0] + hidden = False + stream = open(name, 'rb') + try: + name = parts[1] + hidden = (parts[2].lower() == "hidden") + except IndexError: + pass + self.files[name] = PuzzleFile(stream, name, not hidden) + elif key == 'script': + stream = open(val, 'rb') + # Make sure this shows up in the header block of the HTML output. + self.files[val] = PuzzleFile(stream, val, visible=False) + self.scripts.append(val) + else: + raise ValueError("Unrecognized header field: {}".format(key)) + def read_directory(self, path): try: From 214b37dfdb575d062453180541a7e72ea79bece6 Mon Sep 17 00:00:00 2001 From: John Donaldson Date: Tue, 9 Jul 2019 18:59:57 +0100 Subject: [PATCH 02/15] Force answers to be provided as strings --- devel/moth.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/devel/moth.py b/devel/moth.py index c74d8ee..890f2f2 100644 --- a/devel/moth.py +++ b/devel/moth.py @@ -143,10 +143,13 @@ class Puzzle: elif key == 'summary': self.summary = val elif key == 'answer': + if not isinstance(val, str): + raise ValueError("Answers must be strings, got %s, instead" % (type(val),)) self.answers.append(val) elif key == "answers": for answer in val: - answer = str(answer) + if not isinstance(answer, str): + raise ValueError("Answers must be strings, got %s, instead" % (type(answer),)) self.answers.append(answer) elif key == 'pattern': self.pattern = val From 6ec68a333ae18afe8bda6b08b0edd2afd9fe5f28 Mon Sep 17 00:00:00 2001 From: John Donaldson Date: Tue, 9 Jul 2019 17:36:01 +0100 Subject: [PATCH 03/15] Adding stubs for NICE objectives --- devel/moth.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/devel/moth.py b/devel/moth.py index 612d371..60db4c1 100644 --- a/devel/moth.py +++ b/devel/moth.py @@ -80,6 +80,10 @@ class Puzzle: self.hint = None self.files = {} self.body = io.StringIO() + self.objective = None # Text describing the expected learning outcome from solving this puzzle + self.success = None # TODO + self.solution = None # Text describing how to solve the puzzle + self.ksa = [] # A list of references to related NICE KSAs (e.g. K0058, . . .) self.logs = [] self.randseed = category_seed * self.points self.rand = random.Random(self.randseed) @@ -128,6 +132,14 @@ class Puzzle: # Make sure this shows up in the header block of the HTML output. self.files[val] = PuzzleFile(stream, val, visible=False) self.scripts.append(val) + elif key == "objective": + self.objective = val + elif key == "success": + self.success = val + elif key == "solution": + self.solution = val + elif key == "ksa": + self.solution = val else: raise ValueError("Unrecognized header field: {}".format(key)) else: @@ -278,6 +290,10 @@ class Puzzle: 'scripts': self.scripts, 'pattern': self.pattern, 'body': self.html_body(), + 'objective': self.objective, + 'success': self.success, + 'solution': self.solution, + 'ksa': self.ksa, } def hashes(self): From 87981f4e626365267b36f9c529e5c11da984d65c Mon Sep 17 00:00:00 2001 From: John Donaldson Date: Tue, 9 Jul 2019 18:41:51 +0100 Subject: [PATCH 04/15] Adding some better descriptive text for NICE objective fields --- devel/moth.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/devel/moth.py b/devel/moth.py index 60db4c1..687dcb4 100644 --- a/devel/moth.py +++ b/devel/moth.py @@ -80,10 +80,13 @@ class Puzzle: self.hint = None self.files = {} self.body = io.StringIO() - self.objective = None # Text describing the expected learning outcome from solving this puzzle - self.success = None # TODO + + # NIST NICE objective content + self.objective = None # Text describing the expected learning outcome from solving this puzzle, *why* are you solving this puzzle + self.success = None # Text describing criteria for different levels of success, e.g. {"Acceptable": "Did OK", "Mastery": "Did even better"} self.solution = None # Text describing how to solve the puzzle self.ksa = [] # A list of references to related NICE KSAs (e.g. K0058, . . .) + self.logs = [] self.randseed = category_seed * self.points self.rand = random.Random(self.randseed) From c0cb4d6e02120c5c5f2f3e2967d8154ddfe64809 Mon Sep 17 00:00:00 2001 From: John Donaldson Date: Fri, 12 Jul 2019 17:18:25 +0100 Subject: [PATCH 05/15] Actually store things in the KSA field --- devel/moth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devel/moth.py b/devel/moth.py index 687dcb4..a1dd7de 100644 --- a/devel/moth.py +++ b/devel/moth.py @@ -142,7 +142,7 @@ class Puzzle: elif key == "solution": self.solution = val elif key == "ksa": - self.solution = val + self.ksa.append(val) else: raise ValueError("Unrecognized header field: {}".format(key)) else: From 5f90d5981d19d67d4dfc53544ad04d0fc0a5303d Mon Sep 17 00:00:00 2001 From: Donaldson Date: Fri, 2 Aug 2019 13:02:14 -0500 Subject: [PATCH 06/15] Standardize ksa vs ksas naming convention --- devel/moth.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/devel/moth.py b/devel/moth.py index a1dd7de..580ceb6 100644 --- a/devel/moth.py +++ b/devel/moth.py @@ -85,7 +85,7 @@ class Puzzle: self.objective = None # Text describing the expected learning outcome from solving this puzzle, *why* are you solving this puzzle self.success = None # Text describing criteria for different levels of success, e.g. {"Acceptable": "Did OK", "Mastery": "Did even better"} self.solution = None # Text describing how to solve the puzzle - self.ksa = [] # A list of references to related NICE KSAs (e.g. K0058, . . .) + self.ksas = [] # A list of references to related NICE KSAs (e.g. K0058, . . .) self.logs = [] self.randseed = category_seed * self.points @@ -142,7 +142,7 @@ class Puzzle: elif key == "solution": self.solution = val elif key == "ksa": - self.ksa.append(val) + self.ksas.append(val) else: raise ValueError("Unrecognized header field: {}".format(key)) else: @@ -296,7 +296,7 @@ class Puzzle: 'objective': self.objective, 'success': self.success, 'solution': self.solution, - 'ksa': self.ksa, + 'ksas': self.ksas, } def hashes(self): From c109901578318034052f86a0a91c976bc6669275 Mon Sep 17 00:00:00 2001 From: Donaldson Date: Fri, 2 Aug 2019 17:24:45 -0500 Subject: [PATCH 07/15] Better handling of success dictionary --- devel/moth.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/devel/moth.py b/devel/moth.py index 580ceb6..e71914e 100644 --- a/devel/moth.py +++ b/devel/moth.py @@ -1,6 +1,7 @@ #!/usr/bin/python3 import argparse +from collections import UserDict import contextlib import glob import hashlib @@ -59,6 +60,34 @@ class PuzzleFile: self.name = name self.visible = visible +class PuzzleSuccess(dict): + """Puzzle success objectives + + :param acceptable: Learning outcome from acceptable knowledge of the subject matter + :param mastery: Learning outcome from mastery of the subject matter + """ + + valid_fields = ["acceptable", "mastery"] + + def __init__(self, **kwargs): + super(PuzzleSuccess, self).__init__() + for key in self.valid_fields: + self[key] = None + for key, value in kwargs.items(): + if key in self.valid_fields: + self[key] = value + + def __getattr__(self, attr): + if attr in self.valid_fields: + return self[attr] + raise AttributeError("'%s' object has no attribute '%s'" % (type(self).__name__, attr)) + + def __setattr__(self, attr, value): + if attr in self.valid_fields: + self[attr] = value + else: + raise AttributeError() + class Puzzle: def __init__(self, category_seed, points): @@ -83,7 +112,7 @@ class Puzzle: # NIST NICE objective content self.objective = None # Text describing the expected learning outcome from solving this puzzle, *why* are you solving this puzzle - self.success = None # Text describing criteria for different levels of success, e.g. {"Acceptable": "Did OK", "Mastery": "Did even better"} + self.success = PuzzleSuccess() # Text describing criteria for different levels of success, e.g. {"Acceptable": "Did OK", "Mastery": "Did even better"} self.solution = None # Text describing how to solve the puzzle self.ksas = [] # A list of references to related NICE KSAs (e.g. K0058, . . .) From f650f20aa74094cc1b2d4aaa086fc39bf4bfa5dd Mon Sep 17 00:00:00 2001 From: Donaldson Date: Fri, 2 Aug 2019 18:25:44 -0500 Subject: [PATCH 08/15] Removing unused dependency --- devel/moth.py | 1 - 1 file changed, 1 deletion(-) diff --git a/devel/moth.py b/devel/moth.py index e71914e..adf27fe 100644 --- a/devel/moth.py +++ b/devel/moth.py @@ -1,7 +1,6 @@ #!/usr/bin/python3 import argparse -from collections import UserDict import contextlib import glob import hashlib From 24f2b6cd6772086102c65d7318e8168933cfdffd Mon Sep 17 00:00:00 2001 From: Donaldson Date: Fri, 2 Aug 2019 18:26:43 -0500 Subject: [PATCH 09/15] Handling exceptions appropriately --- devel/moth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devel/moth.py b/devel/moth.py index adf27fe..ecf16c4 100644 --- a/devel/moth.py +++ b/devel/moth.py @@ -85,7 +85,7 @@ class PuzzleSuccess(dict): if attr in self.valid_fields: self[attr] = value else: - raise AttributeError() + raise AttributeError("'%s' object has no attribute '%s'" % (type(self).__name__, attr)) class Puzzle: From 03d1182e73ffc740d8ac4decdf89c8194d445cf4 Mon Sep 17 00:00:00 2001 From: John Donaldson Date: Tue, 13 Aug 2019 21:25:11 +0100 Subject: [PATCH 10/15] Fixing how RFC822 parser picks up objectives --- devel/moth.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/devel/moth.py b/devel/moth.py index ecf16c4..6a52715 100644 --- a/devel/moth.py +++ b/devel/moth.py @@ -167,6 +167,10 @@ class Puzzle: self.objective = val elif key == "success": self.success = val + elif key == "success.acceptable": + self.success.acceptable = val + elif key == "success.mastery": + self.success.mastery = val elif key == "solution": self.solution = val elif key == "ksa": From e7da829c0cd35923de3ea6603ba8e6a3c551c49b Mon Sep 17 00:00:00 2001 From: John Donaldson Date: Tue, 13 Aug 2019 21:24:28 +0100 Subject: [PATCH 11/15] Don't know why this was added. Breaks things --- devel/devel-server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/devel/devel-server.py b/devel/devel-server.py index 7f35e33..acde401 100755 --- a/devel/devel-server.py +++ b/devel/devel-server.py @@ -210,7 +210,6 @@ sessionStorage.setItem("id", "devel-server") self.end_headers() self.wfile.write(body.encode('utf-8')) endpoints.append((r"/", handle_index)) - endpoints.append((r"/{ignored}", handle_index)) def handle_theme_file(self): From acef542f66c986c7b4f36f28765d42faf61e6b6d Mon Sep 17 00:00:00 2001 From: John Donaldson Date: Wed, 14 Aug 2019 23:44:12 +0100 Subject: [PATCH 12/15] Updating KSA validator --- devel/validate.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/devel/validate.py b/devel/validate.py index 150f0d3..e959b23 100644 --- a/devel/validate.py +++ b/devel/validate.py @@ -5,6 +5,7 @@ import logging import os import os.path +import re import moth @@ -132,14 +133,16 @@ class MothValidator: 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""" + + ksa_re = re.compile("^[KSA]\d{4}$") + if hasattr(puzzle, "ksa"): for ksa in puzzle.ksa: - if not ksa.startswith("K"): - raise MothValidationError("Unrecognized KSA format") + if ksa_re.match(ksa) is None: + raise MothValidationError("Unrecognized KSA format (%s)" % (ksa,)) def output_json(data): From a99bc15c88413103543a5ffd4a4bd6e006510adf Mon Sep 17 00:00:00 2001 From: John Donaldson Date: Wed, 14 Aug 2019 23:55:09 +0100 Subject: [PATCH 13/15] Force success dictionary keys to be lower-case --- devel/moth.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/devel/moth.py b/devel/moth.py index 76ed787..1875bd7 100644 --- a/devel/moth.py +++ b/devel/moth.py @@ -210,7 +210,8 @@ class Puzzle: elif key == "objective": self.objective = val elif key == "success": - self.success = val + # Force success dictionary keys to be lower-case + self.success = dict((x.lower(), y) for x,y in val.items()) elif key == "success.acceptable": self.success.acceptable = val elif key == "success.mastery": From 470e01b4375b8383aec6e2034cdcd71275ba0275 Mon Sep 17 00:00:00 2001 From: John Donaldson Date: Wed, 14 Aug 2019 23:55:37 +0100 Subject: [PATCH 14/15] Adding objectives validator --- devel/validate.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/devel/validate.py b/devel/validate.py index e959b23..404d196 100644 --- a/devel/validate.py +++ b/devel/validate.py @@ -144,6 +144,24 @@ class MothValidator: if ksa_re.match(ksa) is None: raise MothValidationError("Unrecognized KSA format (%s)" % (ksa,)) + @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))) + def output_json(data): """Output results in JSON format""" From 51124ec80b2cbac408699ba5622b18c40b960924 Mon Sep 17 00:00:00 2001 From: John Donaldson Date: Thu, 15 Aug 2019 00:07:22 +0100 Subject: [PATCH 15/15] Do better checking for fields that may be empty or unset --- devel/validate.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/devel/validate.py b/devel/validate.py index 404d196..d73dd89 100644 --- a/devel/validate.py +++ b/devel/validate.py @@ -71,7 +71,9 @@ class MothValidator: def check_fields(self, puzzle): """Check if the puzzle has the requested fields""" for field in self.required_fields: - if not hasattr(puzzle, field): + if not hasattr(puzzle, field) or \ + getattr(puzzle,field) is None or \ + getattr(puzzle,field) == "": raise MothValidationError("Missing field %s" % (field,)) @staticmethod