Merge pull request #16 from dirtbags/neale

Thanks for your feedback, everybody
This commit is contained in:
Neale Pickett 2016-11-17 14:25:19 -07:00 committed by GitHub
commit 2c365f0a62
11 changed files with 265 additions and 385 deletions

View File

@ -7,13 +7,14 @@ which in the past has been called
"Project 2", "Project 2",
"HACK", "HACK",
"Queen Of The Hill", "Queen Of The Hill",
and "Cyber FIRE". "Cyber Spark",
and "Cyber Fire".
Information about these events is at Information about these events is at
http://dirtbags.net/contest/ http://dirtbags.net/contest/
This software serves up puzzles in a manner similar to Jeopardy. This software serves up puzzles in a manner similar to Jeopardy.
It also track scores, It also tracks scores,
and comes with a JavaScript-based scoreboard to display team rankings. and comes with a JavaScript-based scoreboard to display team rankings.
@ -21,7 +22,7 @@ How everything works
--------------------------- ---------------------------
This section wound up being pretty long. This section wound up being pretty long.
Please check out [the overview](doc/overview.md) Please check out [the overview](docs/overview.md)
for details. for details.
@ -34,6 +35,9 @@ Getting Started Developing
Then point a web browser at http://localhost:8080/ Then point a web browser at http://localhost:8080/
and start hacking on things in your `puzzles` directory. and start hacking on things in your `puzzles` directory.
More on how the devel sever works in
[the devel server documentation](docs/devel-server.md)
Running A Production Server Running A Production Server
==================== ====================

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -95,7 +95,16 @@ if __name__ == '__main__':
for points in sorted(puzzles_dict): for points in sorted(puzzles_dict):
puzzle = puzzles_dict[points] puzzle = puzzles_dict[points]
puzzledir = os.path.join(categoryname, 'content', mapping[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 # write associated files
assoc_files = [] assoc_files = []

View File

@ -1,12 +1,14 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import cgi
import glob import glob
import html
import http.server import http.server
import io
import mistune import mistune
import moth import moth
import os import os
import pathlib import pathlib
import shutil
import socketserver import socketserver
import sys import sys
import traceback import traceback
@ -15,8 +17,9 @@ try:
from http.server import HTTPStatus from http.server import HTTPStatus
except ImportError: except ImportError:
class HTTPStatus: class HTTPStatus:
NOT_FOUND = 404 OK = (200, 'OK', 'Request fulfilled, document follows')
OK = 200 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. # XXX: This will eventually cause a problem. Do something more clever here.
seed = 1 seed = 1
@ -26,15 +29,16 @@ def page(title, body):
return """<!DOCTYPE html> return """<!DOCTYPE html>
<html> <html>
<head> <head>
<title>{}</title> <title>{title}</title>
<link rel="stylesheet" href="/files/src/www/res/style.css"> <link rel="stylesheet" href="/files/src/www/res/style.css">
</head> </head>
<body> <body>
<h1>{title}</h1>
<div id="preview" class="terminal"> <div id="preview" class="terminal">
{} {body}
</div> </div>
</body> </body>
</html>""".format(title, body) </html>""".format(title=title, body=body)
def mdpage(body): def mdpage(body):
@ -58,12 +62,14 @@ class MothHandler(http.server.SimpleHTTPRequestHandler):
except: except:
tbtype, value, tb = sys.exc_info() tbtype, value, tb = sys.exc_info()
tblist = traceback.format_tb(tb, None) + traceback.format_exception_only(tbtype, value) tblist = traceback.format_tb(tb, None) + traceback.format_exception_only(tbtype, value)
page = ("# Traceback (most recent call last)\n" + payload = ("Traceback (most recent call last)\n" +
" " + "".join(tblist[:-1]) +
" ".join(tblist[:-1]) + tblist[-1]).encode('utf-8')
tblist[-1]) self.send_response(HTTPStatus.INTERNAL_SERVER_ERROR)
self.serve_md(page) 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): def do_GET(self):
if self.path == "/": if self.path == "/":
@ -71,7 +77,7 @@ class MothHandler(http.server.SimpleHTTPRequestHandler):
elif self.path.startswith("/puzzles"): elif self.path.startswith("/puzzles"):
self.serve_puzzles() self.serve_puzzles()
elif self.path.startswith("/files"): elif self.path.startswith("/files"):
self.serve_file() self.serve_file(self.translate_path(self.path))
else: else:
self.send_error(HTTPStatus.NOT_FOUND, "File not found") self.send_error(HTTPStatus.NOT_FOUND, "File not found")
@ -81,7 +87,7 @@ class MothHandler(http.server.SimpleHTTPRequestHandler):
return super().translate_path(path) return super().translate_path(path)
def serve_front(self): def serve_front(self):
page = """ body = """
MOTH Development Server Front Page MOTH Development Server Front Page
==================== ====================
@ -90,133 +96,131 @@ There's stuff you can do here:
* [Available puzzles](/puzzles) * [Available puzzles](/puzzles)
* [Raw filesystem view](/files/) * [Raw filesystem view](/files/)
* [Documentation](/files/doc/) * [Documentation](/files/docs/)
* [Instructions](/files/doc/devel-server.md) for using this server * [Instructions](/files/docs/devel-server.md) for using this server
If you use this development server to run a contest, If you use this development server to run a contest,
you are a fool. 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): def serve_puzzles(self):
body = [] body = io.StringIO()
path = self.path.rstrip('/') path = self.path.rstrip('/')
parts = path.split("/") parts = path.split("/")
#raise ValueError(parts) title = None
if len(parts) < 3: fpath = None
# List all categories points = None
body.append("# Puzzle Categories") cat = None
for i in glob.glob(os.path.join("puzzles", "*", "")): puzzle = None
body.append("* [{}](/{})".format(i, i))
self.serve_md('\n'.join(body))
return
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: try:
fs = fspath.stat() fpath = os.path.join("puzzles", parts[2])
self.send_header("Last-Modified", self.date_time_string(fs.st_mtime)) points = int(parts[3])
except: except:
pass pass
if fpath:
cat = moth.Category(fpath, seed)
if points:
puzzle = cat.puzzle(points)
if not cat:
title = "Puzzle Categories"
body.write("<ul>")
for i in sorted(glob.glob(os.path.join("puzzles", "*", ""))):
body.write('<li><a href="{}">{}</a></li>'.format(i, i))
body.write("</ul>")
elif not puzzle:
# List all point values in a category
title = "Puzzles in category `{}`".format(parts[2])
body.write("<ul>")
for points in cat.pointvals:
body.write('<li><a href="/puzzles/{cat}/{points}">puzzles/{cat}/{points}</a></li>'.format(cat=parts[2], points=points))
body.write("</ul>")
elif len(parts) == 4:
# Serve up a puzzle
title = "{} puzzle {}".format(parts[2], parts[3])
body.write("<h2>Body</h2>")
body.write(puzzle.html_body())
body.write("<h2>Files</h2>")
body.write("<ul>")
for name in puzzle.files:
body.write('<li><a href="/puzzles/{cat}/{points}/{filename}">{filename}</a></li>'
.format(cat=parts[2], points=puzzle.points, filename=name))
body.write("</ul>")
body.write("<h2>Answers</h2>")
body.write("<ul>")
assert puzzle.answers, 'No answers defined'
for a in puzzle.answers:
body.write("<li><code>{}</code></li>".format(html.escape(a)))
body.write("</ul>")
body.write("<h2>Author</h2><p>{}</p>".format(puzzle.author))
body.write("<h2>Summary</h2><p>{}</p>".format(puzzle.summary))
body.write("<h2>Debug Log</h2>")
body.write('<ul class="log">')
for l in puzzle.logs:
body.write("<li>{}</li>".format(html.escape(l)))
body.write("</ul>")
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.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 path.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)): def run(address=('localhost', 8080)):

View File

@ -1,13 +1,15 @@
#!/usr/bin/python3 #!/usr/bin/python3
import argparse import argparse
from collections import defaultdict, namedtuple import contextlib
import glob import glob
import hashlib import hashlib
from importlib.machinery import SourceFileLoader import io
import importlib.machinery
import mistune import mistune
import os import os
import random import random
import string
import tempfile import tempfile
messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
@ -18,10 +20,22 @@ def djb2hash(buf):
h = ((h * 33) + c) & 0xffffffff h = ((h * 33) + c) & 0xffffffff
return h return h
# We use a named tuple rather than a full class, because any random name generation has @contextlib.contextmanager
# to be done with Puzzle's random number generator, and it's cleaner to not pass that around. def pushd(newdir):
PuzzleFile = namedtuple('PuzzleFile', ['path', 'handle', 'name', 'visible']) curdir = os.getcwd()
PuzzleFile.__doc__ = """A file associated with a puzzle. 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 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. and there is no original input.
handle: A File-like object set to read the file from. You should be able to read straight handle: A File-like object set to read the file from. You should be able to read straight
@ -31,246 +45,106 @@ PuzzleFile.__doc__ = """A file associated with a puzzle.
the file is still expected to be accessible, but it's path must be known the file is still expected to be accessible, but it's path must be known
(or figured out) to retrieve it.""" (or figured out) to retrieve it."""
def __init__(self, stream, name, visible=True):
self.stream = stream
self.name = name
self.visible = visible
class Puzzle: class Puzzle:
def __init__(self, category_seed, points):
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):
"""A MOTH Puzzle. """A MOTH Puzzle.
:param category_seed: A byte string to use as a seed for random numbers for this 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. It is combined with the puzzle points.
:param path: An optional path to a puzzle directory. The point value for the puzzle is taken :param points: The point value of the puzzle.
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.
""" """
super().__init__() super().__init__()
if (points is None and path is None) or (points is not None and path is not None): self.points = points
raise ValueError("Either points or path must be set, but not both.") self.author = None
self.summary = None
self._dict = defaultdict(lambda: []) self.answers = []
if os.path.isdir(path): self.files = {}
self.puzzle_dir = path self.body = io.StringIO()
else: self.logs = []
self.puzzle_dir = None self.randseed = category_seed * self.points
self.message = bytes(random.choice(messageChars) for i in range(20)) self.rand = random.Random(self.randseed)
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'
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
def log(self, msg): def log(self, msg):
"""Add a new log message to this puzzle.""" """Add a new log message to this puzzle."""
self._logs.append(msg) self.logs.append(msg)
@property def read_stream(self, stream):
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 = []
header = True header = True
for line in stream: for line in stream:
if header: if header:
line = line.strip() line = line.strip()
if not line.strip(): if not line:
header = False header = False
continue continue
key, val = line.split(':', 1) key, val = line.split(':', 1)
val = val.strip() key = key.lower()
self[key] = val 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: else:
body.append(line) self.body.write(line)
self.body = ''.join(body)
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): def random_hash(self):
"""Create a random hash from our number generator suitable for use as a filename.""" """Create a file basename (no extension) with our number generator."""
return hashlib.sha1(str(self.rand.random()).encode('ascii')).digest() return ''.join(self.rand.choice(string.ascii_lowercase) for i in range(8))
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)
def make_temp_file(self, name=None, visible=True): def make_temp_file(self, name=None, visible=True):
"""Get a file object for adding dynamically generated data to the puzzle. When you're """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. 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 :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 :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: if name is None:
name = self.random_hash() name = self.random_hash()
self.files[name] = PuzzleFile(stream, name, visible)
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()]
def make_answer(self, word_count, sep=' '): def make_answer(self, word_count, sep=' '):
"""Generate and return a new answer. It's automatically added to the puzzle answer list. """Generate and return a new answer. It's automatically added to the puzzle answer list.
@ -279,48 +153,21 @@ class Puzzle:
:returns: The answer string :returns: The answer string
""" """
answer = sep.join(self.rand.sample(self.ANSWER_WORDS, word_count)) answer = sep.join(self.rand.sample(ANSWER_WORDS, word_count))
self['answer'] = answer self.answers.append(answer)
return 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.""" """Format and return the markdown for the puzzle body."""
return mistune.markdown(self.body) return mistune.markdown(self.get_body())
def publish(self): def hashes(self):
obj = { "Return a list of answer hashes"
'author': self['author'],
'hashes': self['hashes'],
'body': self.htmlify(),
}
return obj
def secrets(self): return [djbhash(a) for a in self.answers]
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())
class Category: class Category:
@ -328,15 +175,31 @@ class Category:
self.path = path self.path = path
self.seed = seed self.seed = seed
self.pointvals = [] self.pointvals = []
for fpath in glob.glob(os.path.join(path, "[0-9]*")): self.catmod = None
pn = os.path.basename(fpath)
points = int(pn) if os.path.exists(os.path.join(path, 'category.py')):
self.pointvals.append(points) self.catmod = importlib.machinery.SourceFileLoader(
'catmod',
os.path.join(path, 'category.py')).load_module()
self.pointvals.extend(self.catmod.points)
self.pointvals = self.catmod.points[:]
else:
for fpath in glob.glob(os.path.join(path, "[0-9]*")):
pn = os.path.basename(fpath)
points = int(pn)
self.pointvals.append(points)
self.pointvals.sort() self.pointvals.sort()
def puzzle(self, points): def puzzle(self, points):
puzzle = Puzzle(self.seed, points)
path = os.path.join(self.path, str(points)) path = os.path.join(self.path, str(points))
return Puzzle(self.seed, path) if self.catmod:
with pushd(self.path):
self.catmod.make(points, puzzle)
else:
puzzle.read_directory(path)
return puzzle
def puzzles(self): def puzzles(self):
for points in self.pointvals: for points in self.pointvals: