diff --git a/package-puzzles b/package-puzzles
index bf0eb71..f730a17 100755
--- a/package-puzzles
+++ b/package-puzzles
@@ -95,7 +95,16 @@ if __name__ == '__main__':
for points in sorted(puzzles_dict):
puzzle = puzzles_dict[points]
puzzledir = os.path.join(categoryname, 'content', mapping[points])
- puzzlejson = puzzle.publish()
+ puzzledict = {
+ 'author': puzzle.author,
+ 'hashes': puzzle.hashes(),
+ 'files': [f.name for f in puzzle.files if f.visible],
+ 'body': puzzle.html_body(),
+ }
+ secretsdict = {
+ 'summary': puzzle.summary,
+ 'answers': puzzle.answers,
+ }
# write associated files
assoc_files = []
diff --git a/tools/devel-server.py b/tools/devel-server.py
index 7fd8f33..770f5f2 100755
--- a/tools/devel-server.py
+++ b/tools/devel-server.py
@@ -1,12 +1,14 @@
#!/usr/bin/env python3
-import cgi
import glob
+import html
import http.server
+import io
import mistune
import moth
import os
import pathlib
+import shutil
import socketserver
import sys
import traceback
@@ -15,8 +17,9 @@ try:
from http.server import HTTPStatus
except ImportError:
class HTTPStatus:
- NOT_FOUND = 404
- OK = 200
+ OK = (200, 'OK', 'Request fulfilled, document follows')
+ NOT_FOUND = (404, 'Not Found', 'Nothing matches the given URI')
+ INTERNAL_SERVER_ERROR = (500, 'Internal Server Error', 'Server got itself in trouble')
# XXX: This will eventually cause a problem. Do something more clever here.
seed = 1
@@ -58,12 +61,14 @@ class MothHandler(http.server.SimpleHTTPRequestHandler):
except:
tbtype, value, tb = sys.exc_info()
tblist = traceback.format_tb(tb, None) + traceback.format_exception_only(tbtype, value)
- page = ("# Traceback (most recent call last)\n" +
- " " +
- " ".join(tblist[:-1]) +
- tblist[-1])
- self.serve_md(page)
-
+ payload = ("Traceback (most recent call last)\n" +
+ "".join(tblist[:-1]) +
+ tblist[-1]).encode('utf-8')
+ self.send_response(HTTPStatus.INTERNAL_SERVER_ERROR)
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
+ self.send_header("Content-Length", payload)
+ self.end_headers()
+ self.wfile.write(payload)
def do_GET(self):
if self.path == "/":
@@ -71,7 +76,7 @@ class MothHandler(http.server.SimpleHTTPRequestHandler):
elif self.path.startswith("/puzzles"):
self.serve_puzzles()
elif self.path.startswith("/files"):
- self.serve_file()
+ self.serve_file(self.translate_path(self.path))
else:
self.send_error(HTTPStatus.NOT_FOUND, "File not found")
@@ -81,7 +86,7 @@ class MothHandler(http.server.SimpleHTTPRequestHandler):
return super().translate_path(path)
def serve_front(self):
- page = """
+ body = """
MOTH Development Server Front Page
====================
@@ -96,127 +101,117 @@ There's stuff you can do here:
If you use this development server to run a contest,
you are a fool.
"""
- self.serve_md(page)
+ payload = mdpage(body).encode('utf-8')
+ self.send_response(HTTPStatus.OK)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", len(payload))
+ self.end_headers()
+ self.wfile.write(payload)
def serve_puzzles(self):
- body = []
+ body = io.StringIO()
path = self.path.rstrip('/')
parts = path.split("/")
- #raise ValueError(parts)
- if len(parts) < 3:
- # List all categories
- body.append("# Puzzle Categories")
- for i in glob.glob(os.path.join("puzzles", "*", "")):
- body.append("* [{}](/{})".format(i, i))
- self.serve_md('\n'.join(body))
- return
+ title = None
+ cat = None
- fpath = os.path.join("puzzles", parts[2])
- cat = moth.Category(fpath, seed)
- if len(parts) == 3:
- # List all point values in a category
- body.append("# Puzzles in category `{}`".format(parts[2]))
- for points in cat.pointvals:
- body.append("* [puzzles/{cat}/{points}](/puzzles/{cat}/{points}/)".format(cat=parts[2], points=points))
- self.serve_md('\n'.join(body))
- return
-
- pzl = cat.puzzle(int(parts[3]))
- if len(parts) == 4:
- body.append("# {} puzzle {}".format(parts[2], parts[3]))
- body.append("* Author: `{}`".format(pzl['author']))
- body.append("* Summary: `{}`".format(pzl['summary']))
- body.append('')
- body.append("## Body")
- body.append(pzl.body)
- body.append("## Answers")
- for a in pzl['answer']:
- body.append("* `{}`".format(a))
- body.append("")
- body.append("## Files")
- for pzl_file in pzl['files']:
- body.append("* [puzzles/{cat}/{points}/{filename}]({filename})"
- .format(cat=parts[2], points=pzl.points, filename=pzl_file))
-
- if len(pzl.logs) > 0:
- body.extend(["", "## Logs"])
- body.append("* [Full Log File](_logs)"
- .format(cat=parts[2], points=pzl.points))
- body.extend(["", "### Logs Head"])
- for log in pzl.logs[:10]:
- body.append("* `{}`".format(log))
- body.extend(["", "### Logs Tail"])
- for log in pzl.logs[-10:]:
- body.append("* `{}`".format(log))
- self.serve_md('\n'.join(body))
- return
- elif len(parts) == 5:
- if parts[4] == '_logs':
- self.serve_puzzle_logs(pzl.logs)
- else:
- try:
- self.serve_puzzle_file(pzl['files'][parts[4]])
- except KeyError:
- self.send_error(HTTPStatus.NOT_FOUND, "File not found")
- return
- else:
- body.append("# Not Implemented Yet")
- self.serve_md('\n'.join(body))
-
- def serve_puzzle_logs(self, logs):
- """Serve a PuzzleFile object."""
- self.send_response(HTTPStatus.OK)
- self.send_header("Content-type", "text/plain; charset=utf-8")
- self.end_headers()
- for log in logs:
- self.wfile.write(log.encode('ascii'))
- self.wfile.write(b"\n")
-
- CHUNK_SIZE = 4096
- def serve_puzzle_file(self, file):
- """Serve a PuzzleFile object."""
- self.send_response(HTTPStatus.OK)
- self.send_header("Content-type", "application/octet-stream")
- self.send_header('Content-Disposition', 'attachment; filename="{}"'.format(file.name))
- if file.path is not None:
- fs = os.stat(file.path)
- self.send_header("Last-Modified", self.date_time_string(fs.st_mtime))
-
- # We're using application/octet stream, so we can send the raw bytes.
- self.end_headers()
- chunk = file.handle.read(self.CHUNK_SIZE)
- while chunk:
- self.wfile.write(chunk)
- chunk = file.handle.read(self.CHUNK_SIZE)
-
- def serve_file(self):
- if self.path.endswith(".md"):
- self.serve_md()
- else:
- super().do_GET()
-
- def serve_md(self, text=None):
- fspathstr = self.translate_path(self.path)
- fspath = pathlib.Path(fspathstr)
- if not text:
- try:
- text = fspath.read_text()
- except OSError:
- self.send_error(HTTPStatus.NOT_FOUND, "File not found")
- return None
- content = mdpage(text)
-
- self.send_response(HTTPStatus.OK)
-
- self.send_header("Content-type", "text/html; charset=utf-8")
- self.send_header("Content-Length", len(content))
try:
- fs = fspath.stat()
- self.send_header("Last-Modified", self.date_time_string(fs.st_mtime))
+ fpath = os.path.join("puzzles", parts[2])
+ cat = moth.Category(path, seed)
+ puzzle = cat.puzzle(int(parts[3]))
except:
pass
+
+ if not cat:
+ title = "Puzzle Categories"
+ body.write("
")
+ for i in glob.glob(os.path.join("puzzles", "*", "")):
+ body.write('- {}
'.format(i, i))
+ body.write("
")
+ elif not puzzle:
+ # List all point values in a category
+ title = "Puzzles in category `{}`".format(parts[2])
+ body.write("")
+ for points in cat.pointvals:
+ body.write('- puzzles/{cat}{points}
'.format(cat=parts[2], points=points))
+ body.write("
")
+ if len(parts) == 4:
+ # Serve up a puzzle
+ title = "{} puzzle {}".format(parts[2], parts[3])
+ body.write("Author
{}
".format(puzzle.author))
+ body.write("Summary
{}
".format(puzzle.summary))
+ body.write("Body
")
+ body.write(puzzle.html_body())
+ body.write("Answers
")
+ body.write("")
+ for a in puzzle.answers:
+ body.write("{}
".format(html.escape(a)))
+ body.write("
")
+ body.write("Files
")
+ body.write("")
+ for f in puzzle.files:
+ body.write('- {filename}
'
+ .format(cat=parts[2], points=puzzle.points, filename=f.name))
+ body.write("
")
+ body.write("Debug Log
")
+ body.write('')
+ for l in puzzle.logs:
+ body.write("- {}
".format(html.escape(l)))
+ body.write("
")
+ elif len(parts) == 5:
+ # Serve up a puzzle file
+ try:
+ pfile = puzzle.files[parts[4]]
+ except KeyError:
+ self.send_error(HTTPStatus.NOT_FOUND, "File not found")
+ return
+ ctype = self.guess_type(pfile.name)
+ self.send_response(HTTPStatus.OK)
+ self.send_header("Content-Type", ctype)
+ self.end_headers()
+ shutil.copyfileobj(pfile.stream, self.wfile)
+ return
+
+ payload = page(title, body.getvalue()).encode('utf-8')
+ self.send_response(HTTPStatus.OK)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", len(payload))
self.end_headers()
- self.wfile.write(content.encode('utf-8'))
+ self.wfile.write(payload)
+
+ def serve_file(self, path):
+ lastmod = None
+ fspath = pathlib.Path(path)
+
+ if fspath.is_dir():
+ ctype = "text/html; charset=utf-8"
+ payload = self.list_directory(path)
+ # it sends headers but not body
+ shutil.copyfileobj(payload, self.wfile)
+ else:
+ ctype = self.guess_type(path)
+ try:
+ payload = fspath.read_bytes()
+ except OSError:
+ self.send_error(HTTPStatus.NOT_FOUND, "File not found")
+ return
+ if fspath.endswith(".md"):
+ ctype = "text/html; charset=utf-8"
+ content = mdpage(payload.decode('utf-8'))
+ payload = content.encode('utf-8')
+ try:
+ fs = fspath.stat()
+ lastmod = self.date_time_string(fs.st_mtime)
+ except:
+ pass
+
+ self.send_response(HTTPStatus.OK)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", len(payload))
+ if lastmod:
+ self.send_header("Last-Modified", lastmod)
+ self.end_headers()
+ self.wfile.write(payload)
def run(address=('localhost', 8080)):
diff --git a/tools/moth.py b/tools/moth.py
index ee45635..bf9281a 100644
--- a/tools/moth.py
+++ b/tools/moth.py
@@ -1,10 +1,11 @@
#!/usr/bin/python3
import argparse
-from collections import defaultdict, namedtuple
+import contextlib
import glob
import hashlib
-from importlib.machinery import SourceFileLoader
+import io
+import importlib.machinery
import mistune
import os
import random
@@ -18,10 +19,22 @@ def djb2hash(buf):
h = ((h * 33) + c) & 0xffffffff
return h
-# We use a named tuple rather than a full class, because any random name generation has
-# to be done with Puzzle's random number generator, and it's cleaner to not pass that around.
-PuzzleFile = namedtuple('PuzzleFile', ['path', 'handle', 'name', 'visible'])
-PuzzleFile.__doc__ = """A file associated with a puzzle.
+@contextlib.contextmanager
+def pushd(newdir):
+ curdir = os.getcwd()
+ os.chdir(newdir)
+ try:
+ yield
+ finally:
+ os.chdir(curdir)
+
+# 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.
+
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
@@ -31,185 +44,92 @@ PuzzleFile.__doc__ = """A file associated with a puzzle.
the file is still expected to be accessible, but it's path must be known
(or figured out) to retrieve it."""
+ def __init__(self, stream, name, visible=True):
+ self.stream = stream
+ self.name = name
+ self.visible = visible
+
class Puzzle:
-
- KNOWN_KEYS = [
- 'answer',
- 'author',
- 'file',
- 'hidden',
- 'name'
- 'resource',
- 'summary'
- ]
- REQUIRED_KEYS = [
- 'author',
- 'answer',
- ]
- SINGULAR_KEYS = [
- 'name'
- ]
-
- # 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'))]
-
- def __init__(self, category_seed, path=None, points=None, category=None):
+ def __init__(self, category_seed, points):
"""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 path: An optional path to a puzzle directory. The point value for the puzzle is taken
- from the puzzle directories name (it must be an integer greater than zero).
- Within this directory, we expect:
- (optional) A puzzle.moth file in RFC2822 format. The puzzle will get its attributes
- from the headers, and the body will be the puzzle description in
- Markdown format.
- (optional) A puzzle.py file. This is expected to have a callable called make
- that takes a single positional argument (this puzzle object).
- This callable can then do whatever it needs to with this object.
- :param points: The point value of the puzzle. Mutually exclusive with path.
- If neither of the above are given, the point value for the puzzle will have to
- be set at instantiation.
-
- For puzzle attributes, this class acts like a dictionary that in most cases assigns
- always returns a list. Certain keys, however behave differently:
- - Keys in Puzzle.SINGULAR_KEYS can only have one value, and writing to these overwrites
- that value.
- - The keys 'hidden', 'file', and 'resource' all create a new PuzzleFile object that
- gets added under the 'files' key.
- - The 'answer' also adds a new hash under the the 'hash' key.
+ :param points: The point value of the puzzle.
"""
super().__init__()
- assert any([
- points is None and path is not None,
- points is not None and path is None,
- points is not None and category is not None]), \
- "Either points or path must be set, but not both."
-
- self._dict = defaultdict(lambda: [])
- if path is not None and os.path.isdir(path):
- self.puzzle_dir = path
- else:
- self.puzzle_dir = None
- self.message = bytes(random.choice(messageChars) for i in range(20))
- self.body = ''
-
- # This defaults to a dict, not a list like most things
- self._dict['files'] = {}
-
- # A list of temporary files we've created that will need to be deleted.
- self._temp_files = []
- if path is not None:
- if not os.path.isdir(path):
- raise ValueError("No such directory: {}".format(path))
-
- pathname = os.path.split(path)[-1]
- try:
- self.points = int(pathname)
- except ValueError:
- raise ValueError("Directory name must be a point value: {}".format(path))
- elif points is not None:
- self.points = points
-
- self._seed = category_seed * self.points
- self.rand = random.Random(self._seed)
-
- self._logs = []
-
- if path is not None:
- files = os.listdir(path)
-
- if 'puzzle.moth' in files:
- self._read_config(open(os.path.join(path, 'puzzle.moth')))
-
- if 'puzzle.py' in files:
- # Good Lord this is dangerous as fuck.
- loader = SourceFileLoader('puzzle_mod', os.path.join(path, 'puzzle.py'))
- puzzle_mod = loader.load_module()
- if hasattr(puzzle_mod, 'make'):
- self.body = '# `puzzle.body` was not set by the `make` function'
- puzzle_mod.make(self)
- else:
- self.body = '# `puzzle.py` does not define a `make` function'
- elif category is not None and points is not None:
- category.make(self, points)
-
-
- def cleanup(self):
- """Cleanup any outstanding temporary files."""
- for path in self._temp_files:
- if os.path.exists(path):
- try:
- os.unlink(path)
- except OSError:
- pass
+ self.points = points
+ self.author = None
+ self.summary = None
+ self.answers = []
+ self.files = {}
+ self.body = io.StringIO()
+ self.logs = []
+ self.randseed = category_seed * self.points
+ self.rand = random.Random(self.randseed)
def log(self, msg):
"""Add a new log message to this puzzle."""
- self._logs.append(msg)
+ self.logs.append(msg)
- @property
- def logs(self):
- """Get all the log messages, as strings."""
-
- _logs = []
- for log in self._logs:
- if type(log) is bytes:
- log = log.decode('utf-8')
- elif type(log) is not str:
- log = str(log)
-
- _logs.append(log)
-
- return _logs
-
- def _read_config(self, stream):
- """Read a configuration file (ISO 2822)"""
- body = []
+ def read_stream(self, stream):
header = True
for line in stream:
if header:
line = line.strip()
- if not line.strip():
+ if not line:
header = False
continue
key, val = line.split(':', 1)
- val = val.strip()
- self[key] = val
+ key = key.lower()
+ if key == 'author':
+ self.author = val
+ elif key == 'summary':
+ self.summary = val
+ elif key == 'answer':
+ self.answers.append(val)
+ elif key == 'file':
+ parts = val.split()
+ name = parts[0]
+ hidden = False
+ stream = open(name, 'rb')
+ try:
+ name = parts[1]
+ hidden = parts[2]
+ except IndexError:
+ pass
+ self.files[name] = PuzzleFile(stream, name, not hidden)
+ else:
+ raise ValueError("Unrecognized header field: {}".format(key))
else:
- body.append(line)
- self.body = ''.join(body)
+ self.body.write(line)
+
+ def read_directory(self, path):
+ try:
+ fn = os.path.join(path, "puzzle.py")
+ loader = importlib.machinery.SourceFileLoader('puzzle_mod', fn)
+ puzzle_mod = loader.load_module()
+ except FileNotFoundError:
+ puzzle_mod = None
+
+ if puzzle_mod:
+ with pushd(path):
+ puzzle_mod.make(self)
+ else:
+ with open(os.path.join(path, 'puzzle.moth')) as f:
+ self.read_stream(f)
def random_hash(self):
- """Create a random hash from our number generator suitable for use as a filename."""
- return hashlib.sha1(str(self.rand.random()).encode('ascii')).digest()
-
- def _puzzle_file(self, path, name, visible=True):
- """Make a puzzle file instance for the given file. To add files as you would in the config
- file (to 'file', 'hidden', or 'resource', simply assign to that keyword in the object:
- puzzle['file'] = 'some_file.txt'
- puzzle['hidden'] = 'some_hidden_file.txt'
- puzzle['resource'] = 'some_file_in_the_category_resource_directory_omg_long_name.txt'
- :param path: The path to the file
- :param name: The name of the file. If set to None, the published file will have
- a random hash as a name and have visible set to False.
- :return:
- """
-
- # Make sure it actually exists.
- if not os.path.exists(path):
- raise ValueError("Included file {} does not exist.".format(path))
-
- file = open(path, 'rb')
-
- return PuzzleFile(path=path, handle=file, name=name, visible=visible)
+ """Create a file basename (no extension) with our number generator."""
+ return ''.join(self.random.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.
+
: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.
@@ -219,64 +139,9 @@ class Puzzle:
if name is None:
name = self.random_hash()
- file = tempfile.NamedTemporaryFile(mode='w+b', delete=False)
- file_read = open(file.name, 'rb')
-
- self._dict['files'][name] = PuzzleFile(path=file.name, handle=file_read,
- name=name, visible=visible)
-
- return file
-
- def make_handle_file(self, handle, name, visible=True):
- """Add a file to the puzzle from a file handle.
- :param handle: A file object or equivalent.
- :param name: The name of the file in the final puzzle.
- :param visible: Whether or not it's visible.
- :return: None
- """
-
- def __setitem__(self, key, value):
- """Set a value for this puzzle, as if it were set in the config file. Most values default
- being added to a list. Files (regardless of type) go in a dict under ['files']. Keys
- in Puzzle.SINGULAR_KEYS are single values that get overwritten with subsequent assignments.
- Only keys in Puzzle.KNOWN_KEYS are accepted.
- :param key:
- :param value:
- :return:
- """
-
- key = key.lower()
-
- if key in ('file', 'resource', 'hidden') and self.puzzle_dir is None:
- raise KeyError("Cannot set a puzzle file for single file puzzles.")
-
- if key == 'answer':
- # Handle adding answers to the puzzle
- self._dict['hash'].append(djb2hash(value.encode('utf8')))
- self._dict['answer'].append(value)
- elif key == 'file':
- # Handle adding files to the puzzle
- path = os.path.join(self.puzzle_dir, 'files', value)
- self._dict['files'][value] = self._puzzle_file(path, value)
- elif key == 'resource':
- # Handle adding category files to the puzzle
- path = os.path.join(self.puzzle_dir, '../res', value)
- self._dict['files'].append(self._puzzle_file(path, value))
- elif key == 'hidden':
- # Handle adding secret, 'hidden' files to the puzzle.
- path = os.path.join(self.puzzle_dir, 'files', value)
- name = self.random_hash()
- self._dict['files'].append(self._puzzle_file(path, name, visible=False))
- elif key in self.SINGULAR_KEYS:
- # These keys can only have one value
- self._dict[key] = value
- elif key in self.KNOWN_KEYS:
- self._dict[key].append(value)
- else:
- raise KeyError("Invalid Attribute: {}".format(key))
-
- def __getitem__(self, item):
- return self._dict[item.lower()]
+ stream = tempfile.TemporaryFile()
+ self.files[name] = PuzzleFile(stream, name, visible)
+ return stream
def make_answer(self, word_count, sep=' '):
"""Generate and return a new answer. It's automatically added to the puzzle answer list.
@@ -286,47 +151,20 @@ class Puzzle:
"""
answer = sep.join(self.rand.sample(self.ANSWER_WORDS, word_count))
- self['answer'] = answer
-
+ self.answers.append(answer)
return answer
- def htmlify(self):
+ 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.body)
+ return mistune.markdown(self.get_body())
- def publish(self):
- obj = {
- 'author': self['author'],
- 'hashes': self['hashes'],
- 'body': self.htmlify(),
- }
- return obj
+ def hashes(self):
+ "Return a list of answer hashes"
- def secrets(self):
- obj = {
- 'answers': self['answers'],
- 'summary': self['summary'],
- }
- return obj
-
-if __name__ == '__main__':
- parser = argparse.ArgumentParser(description='Build a puzzle category')
- parser.add_argument('puzzledir', nargs='+', help='Directory of puzzle source')
- args = parser.parse_args()
-
- for puzzledir in args.puzzledir:
- puzzles = {}
- secrets = {}
- for puzzlePath in glob.glob(os.path.join(puzzledir, "*.moth")):
- filename = os.path.basename(puzzlePath)
- points, ext = os.path.splitext(filename)
- points = int(points)
- puzzle = Puzzle(puzzlePath, "test")
- puzzles[points] = puzzle
-
- for points in sorted(puzzles):
- puzzle = puzzles[points]
- print(puzzle.secrets())
+ return [djbhash(a) for a in self.answers]
class Category:
@@ -358,11 +196,12 @@ class Category:
self.pointvals.sort()
def puzzle(self, points):
- if self.catmod is not None and points in self.catmod.points:
- return Puzzle(self.seed, points=points, category=self.catmod)
- else:
- path = os.path.join(self.path, str(points))
- return Puzzle(self.seed, path=path)
+ puzzle = Puzzle(self.seed, points)
+ path = os.path.join(self.path, str(points))
+ if self.catmod:
+ self.catmod.make(p, points)
+ puzzle.read_directory(path)
+ return puzzle
def puzzles(self):
for points in self.pointvals: