Merge pull request #2 from pflarr/pflarr_edits

Pflarr edits
This commit is contained in:
pflarr 2016-10-18 20:12:50 -06:00 committed by GitHub
commit a4f92e7856
7 changed files with 4455 additions and 52 deletions

1
.gitignore vendored
View File

@ -2,6 +2,7 @@
*# *#
*.pyc *.pyc
*.o *.o
.idea
./bin/ ./bin/
build/ build/
cache/ cache/

3
CREDITS.md Normal file
View File

@ -0,0 +1,3 @@
Neale Pickett
Patrick Avery
Shannon Steinfadt

4096
answer_words.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
#!/usr/bin/python3 #!/usr/bin/env python3
import cgi
import glob import glob
import http.server import http.server
import mistune import mistune
@ -7,8 +8,19 @@ import os
import pathlib import pathlib
import puzzles import puzzles
import socketserver import socketserver
import sys
import traceback
try:
from http.server import HTTPStatus
except ImportError:
class HTTPStatus:
NOT_FOUND = 404
OK = 200
# XXX: This will eventually cause a problem. Do something more clever here.
seed = 1
HTTPStatus = http.server.HTTPStatus
def page(title, body): def page(title, body):
return """<!DOCTYPE html> return """<!DOCTYPE html>
@ -24,6 +36,7 @@ def page(title, body):
</body> </body>
</html>""".format(title, body) </html>""".format(title, body)
def mdpage(body): def mdpage(body):
try: try:
title, _ = body.split('\n', 1) title, _ = body.split('\n', 1)
@ -37,7 +50,21 @@ def mdpage(body):
class ThreadingServer(socketserver.ThreadingMixIn, http.server.HTTPServer): class ThreadingServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
pass pass
class MothHandler(http.server.CGIHTTPRequestHandler):
class MothHandler(http.server.SimpleHTTPRequestHandler):
def handle_one_request(self):
try:
super().handle_one_request()
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)
def do_GET(self): def do_GET(self):
if self.path == "/": if self.path == "/":
self.serve_front() self.serve_front()
@ -75,39 +102,70 @@ you are a fool.
body = [] body = []
path = self.path.rstrip('/') path = self.path.rstrip('/')
parts = path.split("/") parts = path.split("/")
#raise ValueError(parts)
if len(parts) < 3: if len(parts) < 3:
# List all categories # List all categories
body.append("# Puzzle Categories") body.append("# Puzzle Categories")
for i in glob.glob(os.path.join("puzzles", "*", "")): for i in glob.glob(os.path.join("puzzles", "*", "")):
body.append("* [{}](/{})".format(i, i)) body.append("* [{}](/{})".format(i, i))
elif len(parts) == 3: self.serve_md('\n'.join(body))
return
fpath = os.path.join("puzzles", parts[2])
cat = puzzles.Category(fpath, seed)
if len(parts) == 3:
# List all point values in a category # List all point values in a category
body.append("# Puzzles in category `{}`".format(parts[2])) body.append("# Puzzles in category `{}`".format(parts[2]))
puzz = [] for points in cat.pointvals:
for i in glob.glob(os.path.join("puzzles", parts[2], "*.moth")): body.append("* [puzzles/{cat}/{points}](/puzzles/{cat}/{points}/)".format(cat=parts[2], points=points))
base = os.path.basename(i) self.serve_md('\n'.join(body))
root, _ = os.path.splitext(base) return
points = int(root)
puzz.append(points) pzl = cat.puzzle(int(parts[3]))
for puzzle in sorted(puzz): if len(parts) == 4:
body.append("* [puzzles/{cat}/{points}](/puzzles/{cat}/{points}/)".format(cat=parts[2], points=puzzle))
elif len(parts) == 4:
body.append("# {} puzzle {}".format(parts[2], parts[3])) body.append("# {} puzzle {}".format(parts[2], parts[3]))
with open("puzzles/{}/{}.moth".format(parts[2], parts[3])) as f: body.append("* Author: `{}`".format(pzl['author']))
p = puzzles.Puzzle(f) body.append("* Summary: `{}`".format(pzl['summary']))
body.append("* Author: `{}`".format(p.fields.get("author")))
body.append("* Summary: `{}`".format(p.fields.get("summary")))
body.append('') body.append('')
body.append("## Body") body.append("## Body")
body.append(p.body) body.append(pzl.body)
body.append("## Answers:") body.append("## Answers")
for a in p.answers: for a in pzl['answer']:
body.append("* `{}`".format(a)) body.append("* `{}`".format(a))
body.append("") 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))
self.serve_md('\n'.join(body))
return
elif len(parts) == 5:
try:
self.serve_puzzle_file(pzl['files'][parts[4]])
except KeyError:
self.send_error(HTTPStatus.NOT_FOUND, "File not found")
return
else: else:
body.append("# Not Implemented Yet") body.append("# Not Implemented Yet")
self.serve_md('\n'.join(body)) self.serve_md('\n'.join(body))
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): def serve_file(self):
if self.path.endswith(".md"): if self.path.endswith(".md"):
self.serve_md() self.serve_md()
@ -125,8 +183,9 @@ you are a fool.
return None return None
content = mdpage(text) content = mdpage(text)
self.send_response(http.server.HTTPStatus.OK) self.send_response(HTTPStatus.OK)
self.send_header("Content-type", "text/html; encoding=utf-8")
self.send_header("Content-type", "text/html; charset=utf-8")
self.send_header("Content-Length", len(content)) self.send_header("Content-Length", len(content))
try: try:
fs = fspath.stat() fs = fspath.stat()
@ -136,9 +195,10 @@ you are a fool.
self.end_headers() self.end_headers()
self.wfile.write(content.encode('utf-8')) self.wfile.write(content.encode('utf-8'))
def run(address=('', 8080)):
def run(address=('localhost', 8080)):
httpd = ThreadingServer(address, MothHandler) httpd = ThreadingServer(address, MothHandler)
print("=== Listening on http://{}:{}/".format(address[0] or "localhost", address[1])) print("=== Listening on http://{}:{}/".format(address[0], address[1]))
httpd.serve_forever() httpd.serve_forever()
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -1,13 +1,14 @@
#!/usr/bin/python3 #!/usr/bin/python3
import hmac
import base64
import argparse import argparse
from collections import defaultdict, namedtuple
import glob import glob
import json import hashlib
import os from importlib.machinery import SourceFileLoader
import mistune import mistune
import os
import random import random
import tempfile
messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
@ -17,13 +18,129 @@ def djb2hash(buf):
h = ((h * 33) + c) & 0xffffffff h = ((h * 33) + c) & 0xffffffff
return h return h
class Puzzle: # We use a named tuple rather than a full class, because any random name generation has
def __init__(self, stream): # to be done with Puzzle's random number generator, and it's cleaner to not pass that around.
self.message = bytes(random.choice(messageChars) for i in range(20)) PuzzleFile = namedtuple('PuzzleFile', ['path', 'handle', 'name', 'visible'])
self.fields = {} PuzzleFile.__doc__ = """A file associated with a puzzle.
self.answers = [] path: The path to the original input file. May be None (when this is created from a file handle
self.hashes = [] 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,
the file is still expected to be accessible, but it's path must be known
(or figured out) to retrieve it."""
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):
"""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.
"""
super().__init__()
if (points is None and path is None) or (points is not None and path is not None):
raise ValueError("Either points or path must be set, but not both.")
self._dict = defaultdict(lambda: [])
if 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)
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 _read_config(self, stream):
"""Read a configuration file (ISO 2822)"""
body = [] body = []
header = True header = True
for line in stream: for line in stream:
@ -33,36 +150,135 @@ class Puzzle:
header = False header = False
continue continue
key, val = line.split(':', 1) key, val = line.split(':', 1)
key = key.lower()
val = val.strip() val = val.strip()
self._add_field(key, val) self[key] = val
else: else:
body.append(line) body.append(line)
self.body = ''.join(body) self.body = ''.join(body)
def _add_field(self, key, val): 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)
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.
:return: A file object for writing
"""
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': if key == 'answer':
h = djb2hash(val.encode('utf8')) # Handle adding answers to the puzzle
self.answers.append(val) self._dict['hash'].append(djb2hash(value.encode('utf8')))
self.hashes.append(h) 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: else:
self.fields[key] = val raise KeyError("Invalid Attribute: {}".format(key))
def __getitem__(self, item):
return self._dict[item.lower()]
def make_answer(self, word_count, 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.
:returns: The answer string
"""
answer = sep.join(self.rand.sample(self.ANSWER_WORDS, word_count))
self['answer'] = answer
return answer
def htmlify(self): def htmlify(self):
"""Format and return the markdown for the puzzle body."""
return mistune.markdown(self.body) return mistune.markdown(self.body)
def publish(self): def publish(self):
obj = { obj = {
'author': self.fields['author'], 'author': self['author'],
'hashes': self.hashes, 'hashes': self['hashes'],
'body': self.htmlify(), 'body': self.htmlify(),
} }
return obj return obj
def secrets(self): def secrets(self):
obj = { obj = {
'answers': self.answers, 'answers': self['answers'],
'summary': self.fields['summary'], 'summary': self['summary'],
} }
return obj return obj
@ -78,10 +294,29 @@ if __name__ == '__main__':
filename = os.path.basename(puzzlePath) filename = os.path.basename(puzzlePath)
points, ext = os.path.splitext(filename) points, ext = os.path.splitext(filename)
points = int(points) points = int(points)
puzzle = Puzzle(open(puzzlePath)) puzzle = Puzzle(puzzlePath, "test")
puzzles[points] = puzzle puzzles[points] = puzzle
for points in sorted(puzzles): for points in sorted(puzzles):
puzzle = puzzles[points] puzzle = puzzles[points]
print(puzzle.secrets()) print(puzzle.secrets())
class Category:
def __init__(self, path, seed):
self.path = path
self.seed = seed
self.pointvals = []
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()
def puzzle(self, points):
path = os.path.join(self.path, str(points))
return Puzzle(self.seed, path=path)
def puzzles(self):
for points in self.pointvals:
yield self.puzzle(points)

8
setup.cfg Normal file
View File

@ -0,0 +1,8 @@
[flake8]
# flake8 is an automated code formatting pedant.
# Use it, please.
#
# python3 -m flake8 .
#
ignore = E501
exclude = .git