Added the ability to make a file just from a handle. Also, only accept category/points as puzzle paths.

This commit is contained in:
Paul Ferrell 2016-10-17 21:26:56 -06:00
parent f22d7d95fb
commit 47a429f5b9
1 changed files with 72 additions and 49 deletions

View File

@ -12,7 +12,6 @@ import tempfile
messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
def djb2hash(buf): def djb2hash(buf):
h = 5381 h = 5381
for c in buf: for c in buf:
@ -23,15 +22,16 @@ def djb2hash(buf):
# to be done with Puzzle's random number generator, and it's cleaner to not pass that around. # 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 = namedtuple('PuzzleFile', ['path', 'handle', 'name', 'visible'])
class Puzzle: class Puzzle:
KNOWN_KEYS = [ KNOWN_KEYS = [
'file',
'resource',
'temp_file',
'answer', 'answer',
'points',
'author', 'author',
'file',
'hidden',
'name'
'resource',
'summary' 'summary'
] ]
REQUIRED_KEYS = [ REQUIRED_KEYS = [
@ -40,7 +40,7 @@ class Puzzle:
'points' 'points'
] ]
SINGULAR_KEYS = [ SINGULAR_KEYS = [
'points' 'name'
] ]
# Get a big list of clean words for our answer file. # Get a big list of clean words for our answer file.
@ -48,6 +48,11 @@ class Puzzle:
'answer_words.txt'))] 'answer_words.txt'))]
def __init__(self, path, category_seed): def __init__(self, path, category_seed):
"""Puzzle objects need a path to a puzzle description (
:param path:
:param category_seed:
"""
super().__init__() super().__init__()
self._dict = defaultdict(lambda: []) self._dict = defaultdict(lambda: [])
@ -60,32 +65,21 @@ class Puzzle:
if not os.path.exists(path): if not os.path.exists(path):
raise ValueError("No puzzle at path: {]".format(path)) raise ValueError("No puzzle at path: {]".format(path))
elif os.path.isfile(path):
try:
# Expected format is path/<points_int>.moth
self['points'] = int(os.path.split(path)[-1].split('.')[0])
except (IndexError, ValueError):
raise ValueError("Invalid puzzle config. "
"Expected something like <point_value>.moth")
stream = open(path)
self._read_config(stream)
elif os.path.isdir(path): elif os.path.isdir(path):
# Expected format is path/<points_int>.moth
pathname = os.path.split(path)[-1]
try: try:
# Expected format is path/<points_int>.moth self.points = int(pathname)
self['points'] = int(os.path.split(path)[-1]) except ValueError:
except (IndexError, ValueError): pass
raise ValueError("Invalid puzzle config. Expected an integer point value for a "
"directory name.")
files = os.listdir(path) files = os.listdir(path)
if 'config.moth' in files: if 'puzzle.moth' in files:
self._read_config(open(os.path.join(path, 'config.moth'))) self._read_config(open(os.path.join(path, 'puzzle.moth')))
if 'make.py' in files: if 'puzzle.py' in files:
# Good Lord this is dangerous as fuck. # Good Lord this is dangerous as fuck.
loader = SourceFileLoader('puzzle_mod', os.path.join(path, 'make.py')) loader = SourceFileLoader('puzzle_mod', os.path.join(path, 'puzzle.py'))
puzzle_mod = loader.load_module() puzzle_mod = loader.load_module()
if hasattr(puzzle_mod, 'make'): if hasattr(puzzle_mod, 'make'):
puzzle_mod.make(self) puzzle_mod.make(self)
@ -98,6 +92,18 @@ class Puzzle:
# Set our 'files' as a dict, since we want register them uniquely by name. # Set our 'files' as a dict, since we want register them uniquely by name.
self['files'] = dict() self['files'] = dict()
# A list of temporary files we've created that will need to be deleted.
self._temp_files = []
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): def _read_config(self, stream):
"""Read a configuration file (ISO 2822)""" """Read a configuration file (ISO 2822)"""
body = [] body = []
@ -109,7 +115,6 @@ 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[key] = val self[key] = val
else: else:
@ -121,7 +126,11 @@ class Puzzle:
return hashlib.sha1(str(self.rand.random()).encode('ascii')).digest() return hashlib.sha1(str(self.rand.random()).encode('ascii')).digest()
def _puzzle_file(self, path, name, visible=True): def _puzzle_file(self, path, name, visible=True):
"""Make a puzzle file instance for the given file. """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 path: The path to the file
:param name: The name of the file. If set to None, the published file will have :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. a random hash as a name and have visible set to False.
@ -136,20 +145,46 @@ class Puzzle:
return PuzzleFile(path=path, handle=file, name=name, visible=visible) return PuzzleFile(path=path, handle=file, name=name, visible=visible)
def make_file(self, name=None, mode='rw+b'): def make_temp_file(self, name=None, mode='rw+b', visible=True):
"""Get a file object for adding dynamically generated data to the puzzle. """Get a file object for adding dynamically generated data to the puzzle. When you're
:param name: The name of the file for links within the puzzle. If this is None, done with this file, flush it, but don't close it.
the file will be hidden with a random hash as the name. :param name: The name of the file for links within the puzzle. If this is None, a name
will be generated for you.
:param mode: The mode under which
: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
""" """
file = tempfile.TemporaryFile(mode=mode, delete=False) if name is None:
name = self.random_hash()
self._dict['files'].append(self._puzzle_file(file.name, name)) file = tempfile.NamedTemporaryFile(mode=mode, 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 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): 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: if key in ('file', 'resource', 'hidden') and self._puzzle_dir is None:
raise KeyError("Cannot set a puzzle file for single file puzzles.") raise KeyError("Cannot set a puzzle file for single file puzzles.")
@ -180,7 +215,7 @@ class Puzzle:
raise KeyError("Invalid Attribute: {}".format(key)) raise KeyError("Invalid Attribute: {}".format(key))
def __getitem__(self, item): def __getitem__(self, item):
return self._dict[item] return self._dict[item.lower()]
def make_answer(self, word_count, sep=b' '): def make_answer(self, word_count, sep=b' '):
"""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.
@ -197,23 +232,11 @@ class Puzzle:
return 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, dest): def publish(self):
"""Deploy the puzzle to the given directory, and return the info needed for describing
the puzzle and accepting answers in MOTH."""
if not os.path.exists(dest):
raise ValueError("Puzzle destination directory does not exist.")
# Delete the original directory
# Save puzzle html file
# Copy over all the files.
obj = { obj = {
'author': self['author'], 'author': self['author'],
'hashes': self['hashes'], 'hashes': self['hashes'],