From 8f86bb03f8dcf871061f21c3d921d43596800f59 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Sat, 22 Oct 2016 16:35:55 +0000 Subject: [PATCH] First pass cleanup, still broken --- package-puzzles | 11 +- tools/devel-server.py | 241 ++++++++++++++-------------- tools/moth.py | 353 ++++++++++++------------------------------ 3 files changed, 224 insertions(+), 381 deletions(-) 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("") + elif not puzzle: + # List all point values in a category + title = "Puzzles in category `{}`".format(parts[2]) + 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("") + body.write("

Files

") + body.write("") + body.write("

Debug Log

") + 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: