Fixing lint issues

This commit is contained in:
Donaldson 2020-10-15 17:22:34 -05:00
parent a75d7fc3be
commit 473c640586
4 changed files with 273 additions and 221 deletions

View File

@ -0,0 +1,2 @@
[report]
show_missing = True

View File

@ -2,3 +2,6 @@ build/
dist/ dist/
moth.egg-info moth.egg-info
__pycache__/ __pycache__/
.coverage
.eggs/
.tox/

View File

@ -1 +1,3 @@
from .moth import * """This module contains a basic library for building Python-based MOTH puzzles"""
from .moth import Puzzle, PuzzleFile, PuzzleSuccess # NOQA

View File

@ -1,38 +1,41 @@
#!/usr/bin/python3 """Basic library for building Python-based MOTH puzzles/categories"""
import argparse
import contextlib import contextlib
import copy import copy
import glob
import hashlib import hashlib
import html import html
import io import io
import importlib.machinery import importlib.machinery
import json
import logging import logging
import os import os
import pathlib
import random import random
import shutil
import string import string
import sys import sys
import tempfile import tempfile
import shlex import types
import yaml
from . import mistune
messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
SEED = os.getenv("SEED", "0")
def sha256hash(buf):
"""Calculate a SHA256 hash
:param buf: A bytes object containg the data to hash
:returns: SHA256 hash digest as a hex string
"""
return hashlib.sha256(buf.encode("utf-8")).hexdigest()
def djb2hash(str):
h = 5381
for c in str.encode("utf-8"):
h = ((h * 33) + c) & 0xffffffff
return h
@contextlib.contextmanager @contextlib.contextmanager
def pushd(newdir): def pushd(newdir):
"""Context manager for limiting context to individual puzzles/categories"""
newdir = str(newdir)
curdir = os.getcwd() curdir = os.getcwd()
LOGGER.debug("Attempting to chdir from %s to %s" % (curdir, newdir)) LOGGER.debug("Attempting to chdir from %s to %s", curdir, newdir)
os.chdir(newdir) os.chdir(newdir)
# Force a copy of the old path, instead of just a reference # Force a copy of the old path, instead of just a reference
@ -50,24 +53,28 @@ def pushd(newdir):
to_remove.append(module) to_remove.append(module)
for module in to_remove: for module in to_remove:
del(sys.modules[module]) del sys.modules[module]
sys.path = old_path sys.path = old_path
LOGGER.debug("Changing directory back from %s to %s" % (newdir, curdir)) LOGGER.debug("Changing directory back from %s to %s",
newdir, curdir)
os.chdir(curdir) os.chdir(curdir)
def loadmod(name, path): def loadmod(name, path):
abspath = os.path.abspath(path) """Load a specified puzzle module
:param name: Name to load the module as
:param path: Path of the module to load
"""
abspath = str(path.resolve())
loader = importlib.machinery.SourceFileLoader(name, abspath) loader = importlib.machinery.SourceFileLoader(name, abspath)
return loader.load_module() mod = types.ModuleType(loader.name)
return loader.exec_module(mod)
# Get a big list of clean words for our answer file. class PuzzleFile: # pylint: disable=too-few-public-methods
ANSWER_WORDS = [w.strip() for w in open(os.path.join(os.path.dirname(__file__),
'answer_words.txt'))]
class PuzzleFile:
"""A file associated with a puzzle. """A file associated with a puzzle.
path: The path to the original input file. May be None (when this is created from a file handle path: The path to the original input file. May be None (when this is created from a file handle
@ -84,7 +91,9 @@ class PuzzleFile:
self.name = name self.name = name
self.visible = visible self.visible = visible
class PuzzleSuccess(dict): class PuzzleSuccess(dict):
"""Puzzle success objectives """Puzzle success objectives
:param acceptable: Learning outcome from acceptable knowledge of the subject matter :param acceptable: Learning outcome from acceptable knowledge of the subject matter
@ -94,7 +103,7 @@ class PuzzleSuccess(dict):
valid_fields = ["acceptable", "mastery"] valid_fields = ["acceptable", "mastery"]
def __init__(self, **kwargs): def __init__(self, **kwargs):
super(PuzzleSuccess, self).__init__() super().__init__()
for key in self.valid_fields: for key in self.valid_fields:
self[key] = None self[key] = None
for key, value in kwargs.items(): for key, value in kwargs.items():
@ -104,16 +113,25 @@ class PuzzleSuccess(dict):
def __getattr__(self, attr): def __getattr__(self, attr):
if attr in self.valid_fields: if attr in self.valid_fields:
return self[attr] return self[attr]
raise AttributeError("'%s' object has no attribute '%s'" % (type(self).__name__, attr)) raise AttributeError(
"'%s' object has no attribute '%s'" % (type(self).__name__, attr))
def __setattr__(self, attr, value): def __setattr__(self, attr, value):
if attr in self.valid_fields: if attr in self.valid_fields:
self[attr] = value self[attr] = value
else: else:
raise AttributeError("'%s' object has no attribute '%s'" % (type(self).__name__, attr)) raise AttributeError(
"'%s' object has no attribute '%s'" % (type(self).__name__, attr))
class Puzzle: class Puzzle: # pylint: disable=too-many-instance-attributes
"""A MOTH Puzzle.
:param category_seed: A byte string to use as a seed for random numbers for this puzzle.
It is combined with the puzzle points.
:param points: The point value of the puzzle.
"""
def __init__(self, category_seed, points): def __init__(self, category_seed, points):
"""A MOTH Puzzle. """A MOTH Puzzle.
@ -124,13 +142,17 @@ class Puzzle:
super().__init__() super().__init__()
self.points = points self._source_format = "py"
self.markup = None
self.points = points or 0
self.summary = None self.summary = None
self.authors = [] self.authors = []
self.answers = [] self.answers = []
self.scripts = [] self.scripts = []
self.pattern = None self.pattern = None
self.hint = None self.hints = []
self.files = {} self.files = {}
self.body = io.StringIO() self.body = io.StringIO()
@ -141,140 +163,37 @@ class Puzzle:
self.ksas = [] # 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.logs = []
self.randseed = category_seed * self.points self.randseed = "%s %d" % (category_seed, self.points)
self.rand = random.Random(self.randseed) self.rand = random.Random(self.randseed)
def set_markup(self, markup):
"""Set the markup function to convert body to HTML.
Normally this would be something like mistune.markdown.
"""
self.markup = markup
def open(self, filename):
"""Return a local file stream
:param filename: A string representing the "name" of the puzzle file
:returns: A file-like object
"""
return self.files[filename].stream
def log(self, *vals): def log(self, *vals):
"""Add a new log message to this puzzle.""" """Add a new log message to this puzzle.
:param *vals: One or more str-able objects representing a log entry
"""
msg = ' '.join(str(v) for v in vals) msg = ' '.join(str(v) for v in vals)
self.logs.append(msg) self.logs.append(msg)
def read_stream(self, stream):
header = True
line = ""
if stream.read(3) == "---":
header = "yaml"
else:
header = "moth"
stream.seek(0)
if header == "yaml":
LOGGER.info("Puzzle is YAML-formatted")
self.read_yaml_header(stream)
elif header == "moth":
LOGGER.info("Puzzle is MOTH-formatted")
self.read_moth_header(stream)
for line in stream:
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:
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):
LOGGER.debug("Handling key: %s, value: %s", key, val)
if key == 'author':
self.authors.append(val)
elif key == 'authors':
if not isinstance(val, list):
raise ValueError("Authors must be a list, got %s, instead" & (type(val),))
self.authors = list(val)
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:
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
elif key == 'hint':
self.hint = val
elif key == 'name':
pass
elif key == 'file':
parts = shlex.split(val)
name = parts[0]
hidden = False
LOGGER.debug("Attempting to open %s", os.path.abspath(name))
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)
elif key == "objective":
self.objective = val
elif key == "success":
# 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":
self.success.mastery = val
elif key == "solution":
self.solution = val
elif key == "ksas":
if not isinstance(val, list):
raise ValueError("KSAs must be a list, got %s, instead" & (type(val),))
self.ksas = val
elif key == "ksa":
self.ksas.append(val)
else:
raise ValueError("Unrecognized header field: {}".format(key))
def read_directory(self, path):
try:
puzzle_mod = loadmod("puzzle", os.path.join(path, "puzzle.py"))
except FileNotFoundError:
puzzle_mod = None
with pushd(path):
if puzzle_mod:
puzzle_mod.make(self)
else:
with open('puzzle.moth') as f:
self.read_stream(f)
def random_hash(self): def random_hash(self):
"""Create a file basename (no extension) with our number generator.""" """Create a file basename (no extension) with our number generator.
:returns: A string containing a pseuedo-random filename
"""
return ''.join(self.rand.choice(string.ascii_lowercase) for i in range(8)) return ''.join(self.rand.choice(string.ascii_lowercase) for i in range(8))
def make_temp_file(self, name=None, visible=True): def make_temp_file(self, name=None, visible=True):
@ -284,25 +203,50 @@ class Puzzle:
:param name: The name of the file for links within the puzzle. If this is None, a name :param name: The name of the file for links within the puzzle. If this is None, a name
will be generated for you. will be generated for you.
:param visible: Whether or not the file will be visible to the user. :param visible: Whether or not the file will be visible to the user.
:return: A file object for writing :returns: A file object for writing
""" """
stream = tempfile.TemporaryFile() stream = tempfile.TemporaryFile()
self.add_stream(stream, name, visible) self.add_stream(stream, name, visible)
return stream return stream
def add_script_stream(self, stream, name):
"""Adds a stream as a script which should appear in the header block of HTML output
:param stream: A file-like object containing the body of the script
:param name: A string containing the name of the script
"""
self.files[name] = PuzzleFile(stream, name, visible=False)
self.scripts.append(name)
def add_stream(self, stream, name=None, visible=True): def add_stream(self, stream, name=None, visible=True):
"""Add a file-like object to a puzzle
:param stream: A file-like object containing the "file" contents
:param name (optional): The name of the file, if not provided, a random name is generated
:param visble (optional): A boolean specifying whether the file should appear in the puzzle's public listing. True by default
"""
if name is None: if name is None:
name = self.random_hash() name = self.random_hash()
self.files[name] = PuzzleFile(stream, name, visible) self.files[name] = PuzzleFile(stream, name, visible)
def add_file(self, filename, visible=True): def add_file(self, filename, visible=True):
fd = open(filename, 'rb') """Add a file by name to a puzzle
:param filename: A string containing the name of the file to include
:param visble (optional): A boolean specifying whether the file should appear in the puzzle's public listing. True by default
"""
fd = open(filename, 'rb') # pylint: disable=invalid-name
name = os.path.basename(filename) name = os.path.basename(filename)
self.add_stream(fd, name=name, visible=visible) self.add_stream(fd, name=name, visible=visible)
def randword(self): def randword(self):
"""Return a randomly-chosen word""" """Return a randomly-chosen word
:returns: A string containing a randomly-chosen word
"""
return self.rand.choice(ANSWER_WORDS) return self.rand.choice(ANSWER_WORDS)
@ -324,7 +268,7 @@ class Puzzle:
' !"#$%&\'()*+,-./' ' !"#$%&\'()*+,-./'
'0123456789:;<=>?' '0123456789:;<=>?'
'@ABCDEFGHIJKLMNO' '@ABCDEFGHIJKLMNO'
'PQRSTUVWXYZ[\]^_' 'PQRSTUVWXYZ[\\]^_'
'`abcdefghijklmno' '`abcdefghijklmno'
'pqrstuvwxyz{|}~·' 'pqrstuvwxyz{|}~·'
'················' '················'
@ -338,21 +282,27 @@ class Puzzle:
) )
def hexdump(self, buf, charset=hexdump_stdch, gap=('<EFBFBD>', '')): def hexdump(self, buf, charset=hexdump_stdch, gap=('<EFBFBD>', '')):
"""Write a hex dump of data to the puzzle body
:param buf: Buffer of bytes to dump
:param charset: Character set to use while dumping hex-equivalents. Default to ASCII
:param gap: Length-2 tuple containing character to use to represent unprintable characters
"""
hexes, chars = [], [] hexes, chars = [], []
out = [] out = []
for b in buf: for buf_byte in buf:
if len(chars) == 16: if len(chars) == 16:
out.append((hexes, chars)) out.append((hexes, chars))
hexes, chars = [], [] hexes, chars = [], []
if b is None: if buf_byte is None:
h, c = gap hex_char, char = gap
else: else:
h = '{:02x}'.format(b) hex_char = '{:02x}'.format(buf_byte)
c = charset[b] char = charset[buf_byte]
chars.append(c) chars.append(char)
hexes.append(h) hexes.append(hex_char)
out.append((hexes, chars)) out.append((hexes, chars))
@ -384,73 +334,168 @@ class Puzzle:
self.body.write('{:08x}\n'.format(offset)) self.body.write('{:08x}\n'.format(offset))
self.body.write('</pre>') self.body.write('</pre>')
def get_authors(self):
return self.authors or [self.author]
def get_body(self): def get_body(self):
"""Get the body of the puzzle
:return: The body of the puzzle
"""
return self.body.getvalue() return self.body.getvalue()
def html_body(self): def html_body(self):
"""Format and return the markdown for the puzzle body.""" """Format and return the markdown for the puzzle body.
return mistune.markdown(self.get_body(), escape=False)
def package(self, answers=False): :return: The rendered body of the puzzle
"""Return a dict packaging of the puzzle.""" """
files = [fn for fn,f in self.files.items() if f.visible] body = self.get_body()
if self.markup:
body = self.markup(body)
return body
def package(self):
"""Return a dict packaging of the puzzle.
:param answers: Whether or not to include answers in the results, defaults to False
:return: Dict representation of the puzzle
"""
attachments = [fn for fn, f in self.files.items()]
return { return {
'authors': self.get_authors(), 'Pre': {
'hashes': self.hashes(), 'Authors': self.authors,
'files': files, 'Attachments': attachments,
'scripts': self.scripts, 'Scripts': self.scripts,
'pattern': self.pattern, 'Body': self.html_body(),
'body': self.html_body(), 'AnswerHashes': self.hashes(),
'objective': self.objective, 'AnswerPattern': self.pattern,
'success': self.success, },
'solution': self.solution, 'Post': {
'ksas': self.ksas, 'Objective': self.objective,
'Success': self.success,
'KSAs': self.ksas,
},
'Debug': {
'Log': self.logs,
'Hints': self.hints,
'Summary': self.summary,
},
'Answers': self.answers,
} }
def hashes(self): def hashes(self):
"Return a list of answer hashes" """Return a list of answer hashes
return [djb2hash(a) for a in self.answers] :return: List of answer hashes
"""
return [sha256hash(a) for a in self.answers]
class Category: def v3markup(buf):
def __init__(self, path, seed): """Get a markdown handler compatible with MOTHv3, using Mistune
self.path = path
self.seed = seed
self.catmod = None
try: :param buf: A string containing valid Markdown
self.catmod = loadmod('category', str(os.path.join(str(path), 'category.py')))
except FileNotFoundError:
self.catmod = None
def pointvals(self): :returns: A string containing rendered Markdown
if self.catmod: """
with pushd(self.path):
pointvals = self.catmod.pointvals() import mistune # pylint: disable=import-outside-toplevel
return mistune.markdown(buf, escape=False)
def mkpuzzle(make, args, points=None):
"""Handle a puzzle request
:param args: An array of string arguments
:param points: The requested point value for a puzzle
"""
puzzle = Puzzle(SEED, points)
puzzle.markup = v3markup
if points:
rpuzzle = make(puzzle, points)
else: else:
pointvals = [] rpuzzle = make(puzzle)
for fpath in glob.glob(str(os.path.join(str(self.path), "[0-9]*"))): if rpuzzle:
pn = os.path.basename(fpath) puzzle = rpuzzle
points = int(pn)
pointvals.append(points)
return sorted(pointvals)
def puzzle(self, points): if len(args) < 1:
puzzle = Puzzle(self.seed, points) raise RuntimeError(
path = str(os.path.join(str(self.path), str(points))) "Must provide an action: puzzle, file FILENAME, or answer ANSWER")
if self.catmod:
with pushd(self.path): if args[0] == "puzzle":
self.catmod.make(points, puzzle) json.dump(puzzle.package(), sys.stdout)
elif args[0] == "file":
fp = puzzle.open(args[1]) # pylint: disable=invalid-name
shutil.copyfileobj(fp, sys.stdout.buffer)
elif args[0] == "answer":
if args[1] in puzzle.answers:
print("correct")
else: else:
with pushd(str(self.path)): print("wrong")
puzzle.read_directory(path) else:
return puzzle raise RuntimeError("Unsupported action: %s" % (args[0],))
def __iter__(self):
for points in self.pointvals(): def mkcategory(make, pointvals, args):
yield self.puzzle(points) """Build a category of puzzle
:param pointvals: a function returning the list of valid point values
:param args: An array of string arguments
"""
if not pointvals:
raise RuntimeError("No pointvals function provided for category mode")
pointval = 0
if len(args) >= 2:
pointval = int(args[1])
if pointval not in pointvals:
raise RuntimeError(
"Requested point value is not in this category's pointvals")
if len(args) < 1:
raise RuntimeError(
"Must provide an action: inventory, puzzle POINTS, file POINTS FILENAME, or answer POINTS ANSWER")
if args[0] == "inventory":
json.dump(sorted(pointvals), sys.stdout)
elif args[0] in ("puzzle", "file", "answer"):
mkpuzzle(make, [args[0]] + args[2:], pointval)
else:
raise RuntimeError("Unsupported action: %s" % (args[0],))
def main(make, pointvals=None):
"""main: Guess what to do based on how we were invoked.
For transpiled puzzles, this will do the right thing.
Uses the MOTHv3 devel-server API:
Puzzle API:
make(puzzle, None)
Accepts a Puzzle object, and fills it in. The second value is always None.
Category API:
make(puzzle, points)
Accepts a Puzzle object, and fills it in. points contains the number of points we are looking at.
"""
random.seed(os.getenv("SEED", "0"))
myself = pathlib.Path(sys.argv[0])
if myself.name == "mkpuzzle":
mkpuzzle(make, sys.argv[1:])
elif myself.name == "mkcategory":
mkcategory(make, pointvals, sys.argv[1:])
else:
raise NotImplementedError(
"Can't auto-detect action for executable named %s" % (myself.name,))
# Words for generating answers.
ANSWER_WORDS = [w.strip() for w in open(os.path.join(os.path.dirname(__file__),
'answer_words.txt'))]