moth/devel/moth.py

370 lines
11 KiB
Python
Raw Normal View History

#!/usr/bin/python3
import argparse
2016-10-22 10:35:55 -06:00
import contextlib
import glob
import hashlib
import html
2016-10-22 10:35:55 -06:00
import io
import importlib.machinery
import mistune
import os
import random
import string
import tempfile
import shlex
2019-07-05 17:53:46 -06:00
import yaml
2019-08-14 07:00:21 -06:00
messageChars = string.ascii_letters.encode("utf-8")
def djb2hash(str):
h = 5381
for c in str.encode("utf-8"):
h = ((h * 33) + c) & 0xffffffff
return h
2016-10-22 10:35:55 -06:00
@contextlib.contextmanager
def pushd(newdir):
curdir = os.getcwd()
os.chdir(newdir)
try:
yield
finally:
os.chdir(curdir)
2017-02-02 11:34:57 -07:00
def loadmod(name, path):
abspath = os.path.abspath(path)
loader = importlib.machinery.SourceFileLoader(name, abspath)
return loader.load_module()
2016-10-22 10:35:55 -06:00
# Get a big list of clean words for our answer file.
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.
2016-10-18 13:11:53 -06:00
path: The path to the original input file. May be None (when this is created from a file handle
and there is no original input.
handle: A File-like object set to read the file from. You should be able to read straight
from it without having to seek to the beginning of the file.
name: The name of the output file.
visible: A boolean indicating whether this file should visible to the user. If False,
2019-08-14 07:00:21 -06:00
the file is still expected to be accessible, but its path must be known
2016-10-18 13:11:53 -06:00
(or figured out) to retrieve it."""
2016-10-22 10:35:55 -06:00
def __init__(self, stream, name, visible=True):
self.stream = stream
self.name = name
self.visible = visible
2016-10-22 10:35:55 -06:00
class Puzzle:
def __init__(self, category_seed, points):
"""A MOTH Puzzle.
2016-10-22 10:35:55 -06:00
2016-10-18 11:24:46 -06:00
:param category_seed: A byte string to use as a seed for random numbers for this puzzle.
It is combined with the puzzle points.
2016-10-22 10:35:55 -06:00
:param points: The point value of the puzzle.
"""
super().__init__()
2016-10-22 10:35:55 -06:00
self.points = points
self.summary = None
2017-01-30 12:13:02 -07:00
self.authors = []
2016-10-22 10:35:55 -06:00
self.answers = []
2017-10-23 10:01:11 -06:00
self.scripts = []
self.pattern = None
2018-10-02 19:21:54 -06:00
self.hint = None
2016-10-22 10:35:55 -06:00
self.files = {}
self.body = io.StringIO()
self.logs = []
self.randseed = category_seed * self.points
self.rand = random.Random(self.randseed)
def log(self, *vals):
"""Add a new log message to this puzzle."""
msg = ' '.join(str(v) for v in vals)
self.logs.append(msg)
2016-10-22 10:35:55 -06:00
def read_stream(self, stream):
2019-08-14 07:00:21 -06:00
header = io.StringIO()
body = io.StringIO()
eoh = None
parser = None
doing = header
for lineno, line in enumerate(stream):
sline = line.strip()
if lineno == 0:
if sline == "---":
eoh = "---"
parser = self.parse_yaml_header
continue
else:
eoh = ""
parser = self.parse_moth_header
if (doing is header) and (sline == eoh):
doing = body
2019-07-05 17:53:46 -06:00
continue
2019-08-14 07:00:21 -06:00
doing.write(line)
header.seek(0)
body.seek(0)
2019-07-05 17:53:46 -06:00
2019-08-14 07:00:21 -06:00
if not header:
raise RuntimeError("Empty header block")
parser(header)
self.body = body
def parse_yaml_header(self, stream):
config = yaml.safe_load(stream)
print(config)
2019-07-05 17:53:46 -06:00
for key, value in config.items():
key = key.lower()
self.handle_header_key(key, value)
2019-08-14 07:00:21 -06:00
def parse_moth_header(self, stream):
2019-07-05 17:53:46 -06:00
for line in stream:
2019-08-14 07:00:21 -06:00
sline = line.strip()
if not sline:
2019-07-05 17:53:46 -06:00
break
2019-08-14 07:00:21 -06:00
key, val = sline.split(':', 1)
2019-07-05 17:53:46 -06:00
key = key.lower()
val = val.strip()
self.handle_header_key(key, val)
2019-08-14 07:00:21 -06:00
2019-07-05 17:53:46 -06:00
def handle_header_key(self, key, val):
if key == 'author':
self.authors.append(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),))
2019-07-05 17:53:46 -06:00
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),))
2019-07-05 17:53:46 -06:00
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))
2016-10-22 10:35:55 -06:00
def read_directory(self, path):
try:
2017-02-02 11:34:57 -07:00
puzzle_mod = loadmod("puzzle", os.path.join(path, "puzzle.py"))
2016-10-22 10:35:55 -06:00
except FileNotFoundError:
puzzle_mod = None
with pushd(path):
if puzzle_mod:
2016-10-22 10:35:55 -06:00
puzzle_mod.make(self)
else:
with open('puzzle.moth') as f:
self.read_stream(f)
2016-10-22 10:35:55 -06:00
def random_hash(self):
"""Create a file basename (no extension) with our number generator."""
return ''.join(self.rand.choice(string.ascii_lowercase) for i in range(8))
def make_temp_file(self, name=None, visible=True):
"""Get a file object for adding dynamically generated data to the puzzle. When you're
done with this file, flush it, but don't close it.
2016-10-22 10:35:55 -06:00
:param name: The name of the file for links within the puzzle. If this is None, a name
will be generated for you.
:param visible: Whether or not the file will be visible to the user.
:return: A file object for writing
"""
stream = tempfile.TemporaryFile()
self.add_stream(stream, name, visible)
return stream
def add_stream(self, stream, name=None, visible=True):
if name is None:
name = self.random_hash()
2016-10-22 10:35:55 -06:00
self.files[name] = PuzzleFile(stream, name, visible)
def add_file(self, filename, visible=True):
fd = open(filename, 'rb')
name = os.path.basename(filename)
self.add_stream(fd, name=name, visible=visible)
def randword(self):
"""Return a randomly-chosen word"""
return self.rand.choice(ANSWER_WORDS)
def make_answer(self, word_count=4, sep=' '):
"""Generate and return a new answer. It's automatically added to the puzzle answer list.
:param int word_count: The number of words to include in the answer.
:param str|bytes sep: The word separator.
2016-10-18 09:51:33 -06:00
:returns: The answer string
"""
words = [self.randword() for i in range(word_count)]
answer = sep.join(words)
2016-10-22 10:35:55 -06:00
self.answers.append(answer)
return answer
2017-01-20 17:07:36 -07:00
hexdump_stdch = stdch = (
'················'
'················'
' !"#$%&\'()*+,-./'
'0123456789:;<=>?'
'@ABCDEFGHIJKLMNO'
'PQRSTUVWXYZ[\]^_'
'`abcdefghijklmno'
'pqrstuvwxyz{|}~·'
'················'
'················'
'················'
'················'
'················'
'················'
'················'
'················'
)
def hexdump(self, buf, charset=hexdump_stdch, gap=('<EFBFBD>', '')):
hexes, chars = [], []
out = []
for b in buf:
if len(chars) == 16:
out.append((hexes, chars))
hexes, chars = [], []
if b is None:
h, c = gap
else:
h = '{:02x}'.format(b)
c = charset[b]
chars.append(c)
hexes.append(h)
out.append((hexes, chars))
offset = 0
elided = False
lastchars = None
self.body.write('<pre>')
for hexes, chars in out:
if chars == lastchars:
2019-07-10 16:33:45 -06:00
offset += len(chars)
2017-01-20 17:07:36 -07:00
if not elided:
self.body.write('*\n')
elided = True
continue
lastchars = chars[:]
elided = False
pad = 16 - len(chars)
hexes += [' '] * pad
self.body.write('{:08x} '.format(offset))
self.body.write(' '.join(hexes[:8]))
self.body.write(' ')
self.body.write(' '.join(hexes[8:]))
self.body.write(' |')
self.body.write(html.escape(''.join(chars)))
2017-01-20 17:07:36 -07:00
self.body.write('|\n')
offset += len(chars)
self.body.write('{:08x}\n'.format(offset))
self.body.write('</pre>')
2017-01-30 12:13:02 -07:00
def get_authors(self):
return self.authors or [self.author]
2016-10-22 10:35:55 -06:00
def get_body(self):
return self.body.getvalue()
def html_body(self):
"""Format and return the markdown for the puzzle body."""
return mistune.markdown(self.get_body(), escape=False)
2018-10-02 19:21:54 -06:00
def package(self, answers=False):
"""Return a dict packaging of the puzzle."""
2018-10-02 19:21:54 -06:00
files = [fn for fn,f in self.files.items() if f.visible]
return {
'authors': self.authors,
'hashes': self.hashes(),
'files': files,
'scripts': self.scripts,
'pattern': self.pattern,
2018-10-02 19:21:54 -06:00
'body': self.html_body(),
}
2016-10-22 10:35:55 -06:00
def hashes(self):
"Return a list of answer hashes"
return [djb2hash(a) for a in self.answers]
class Category:
def __init__(self, path, seed):
self.path = path
self.seed = seed
self.catmod = None
2017-02-02 11:34:57 -07:00
try:
self.catmod = loadmod('category', os.path.join(path, 'category.py'))
except FileNotFoundError:
self.catmod = None
def pointvals(self):
if self.catmod:
with pushd(self.path):
pointvals = self.catmod.pointvals()
2016-10-24 21:09:04 -06:00
else:
2017-02-02 11:34:57 -07:00
pointvals = []
for fpath in glob.glob(os.path.join(self.path, "[0-9]*")):
2016-10-24 21:09:04 -06:00
pn = os.path.basename(fpath)
points = int(pn)
2017-02-02 11:34:57 -07:00
pointvals.append(points)
return sorted(pointvals)
def puzzle(self, points):
2016-10-22 10:35:55 -06:00
puzzle = Puzzle(self.seed, points)
path = os.path.join(self.path, str(points))
if self.catmod:
with pushd(self.path):
self.catmod.make(points, puzzle)
2016-10-24 21:09:04 -06:00
else:
puzzle.read_directory(path)
2016-10-22 10:35:55 -06:00
return puzzle
2017-01-05 16:50:41 -07:00
def __iter__(self):
2017-02-02 11:34:57 -07:00
for points in self.pointvals():
yield self.puzzle(points)