diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c5879ef --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] +### Added +- A changelog +- Support for embedding Python libraries at the category or puzzle level +- Embedded graph in scoreboard +- Optional tracking of participant IDs +- New `notices.html` file for sending broadcast messages to players +### Changed +- Use native JS URL objects instead of wrangling everything by hand diff --git a/VERSION b/VERSION index 5436ea0..b2696f2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.3.2 \ No newline at end of file +3.4_rc1 diff --git a/devel/devel-server.py b/devel/devel-server.py index 635c22a..0d775dc 100755 --- a/devel/devel-server.py +++ b/devel/devel-server.py @@ -266,12 +266,24 @@ if __name__ == '__main__': '--base', default="", help="Base URL to this server, for reverse proxy setup" ) + parser.add_argument( + "-v", "--verbose", + action="count", + default=1, # Leave at 1, for now, to maintain current default behavior + help="Include more verbose logging. Use multiple flags to increase level", + ) args = parser.parse_args() parts = args.bind.split(":") addr = parts[0] or "0.0.0.0" port = int(parts[1]) + if args.verbose >= 2: + log_level = logging.DEBUG + elif args.verbose == 1: + log_level = logging.INFO + else: + log_level = logging.WARNING - logging.basicConfig(level=logging.INFO) + logging.basicConfig(level=log_level) server = MothServer((addr, port), MothRequestHandler) server.args["base_url"] = args.base diff --git a/devel/moth.py b/devel/moth.py index 7161029..623a44e 100644 --- a/devel/moth.py +++ b/devel/moth.py @@ -2,21 +2,26 @@ import argparse import contextlib +import copy import glob import hashlib import html import io import importlib.machinery +import logging import mistune import os import random import string +import sys import tempfile import shlex import yaml messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' +LOGGER = logging.getLogger(__name__) + def djb2hash(str): h = 5381 for c in str.encode("utf-8"): @@ -26,10 +31,28 @@ def djb2hash(str): @contextlib.contextmanager def pushd(newdir): curdir = os.getcwd() + LOGGER.debug("Attempting to chdir from %s to %s" % (curdir, newdir)) os.chdir(newdir) + + # Force a copy of the old path, instead of just a reference + old_path = list(sys.path) + old_modules = copy.copy(sys.modules) + sys.path.append(newdir) + try: yield finally: + # Restore the old path + to_remove = [] + for module in sys.modules: + if module not in old_modules: + to_remove.append(module) + + for module in to_remove: + del(sys.modules[module]) + + sys.path = old_path + LOGGER.debug("Changing directory back from %s to %s" % (newdir, curdir)) os.chdir(curdir) @@ -136,8 +159,10 @@ class Puzzle: stream.seek(0) if header == "yaml": + LOGGER.info("Puzzle is YAML-formatted") self.read_yaml_header(stream) elif header == "moth": + LOGGER.info("Puzzle is MOTH-formatted") self.read_moth_header(stream) for line in stream: @@ -172,6 +197,7 @@ class Puzzle: self.handle_header_key(key, val) def handle_header_key(self, key, val): + LOGGER.debug("Handling key: %s, value: %s", key, val) if key == 'author': self.authors.append(val) elif key == 'authors': @@ -199,6 +225,7 @@ class Puzzle: parts = shlex.split(val) name = parts[0] hidden = False + LOGGER.debug("Attempting to open %s", os.path.abspath(name)) stream = open(name, 'rb') try: name = parts[1] @@ -367,7 +394,7 @@ class Puzzle: files = [fn for fn,f in self.files.items() if f.visible] return { - 'authors': self.authors, + 'authors': self.get_authors(), 'hashes': self.hashes(), 'files': files, 'scripts': self.scripts, @@ -415,7 +442,8 @@ class Category: with pushd(self.path): self.catmod.make(points, puzzle) else: - puzzle.read_directory(path) + with pushd(self.path): + puzzle.read_directory(path) return puzzle def __iter__(self): diff --git a/devel/package-puzzles.py b/devel/package-puzzles.py deleted file mode 100755 index cbd7429..0000000 --- a/devel/package-puzzles.py +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import binascii -import glob -import hashlib -import io -import json -import logging -import moth -import os -import shutil -import string -import sys -import tempfile -import zipfile - -def write_kv_pairs(ziphandle, filename, kv): - """ Write out a sorted map to file - :param ziphandle: a zipfile object - :param filename: The filename to write within the zipfile object - :param kv: the map to write out - :return: - """ - filehandle = io.StringIO() - for key in sorted(kv.keys()): - if type(kv[key]) == type([]): - for val in kv[key]: - filehandle.write("%s %s\n" % (key, val)) - else: - filehandle.write("%s %s\n" % (key, kv[key])) - filehandle.seek(0) - ziphandle.writestr(filename, filehandle.read()) - -def escape(s): - return s.replace('&', '&').replace('<', '<').replace('>', '>') - -def generate_html(ziphandle, puzzle, puzzledir, category, points, authors, files): - html_content = io.StringIO() - file_content = io.StringIO() - if files: - file_content.write( -'''
-

Associated files:

- -
-''') - scripts = [''.format(s) for s in puzzle.scripts] - - html_content.write( -''' - - - - - {category} {points} - - {scripts} - - -

{category} for {points} points

-
-{body}
-{file_content}
-
- - -
Team hash:
-
Answer:
- -
-
-
Puzzle by {authors}
- -'''.format( - category=category, - points=points, - body=puzzle.html_body(), - file_content=file_content.getvalue(), - authors=', '.join(authors), - scripts='\n'.join(scripts), - ) - ) - ziphandle.writestr(os.path.join(puzzledir, 'index.html'), html_content.getvalue()) - -def build_category(categorydir, outdir): - zipfileraw = tempfile.NamedTemporaryFile(delete=False) - zf = zipfile.ZipFile(zipfileraw, 'x') - - category_seed = binascii.b2a_hex(os.urandom(20)) - puzzles_dict = {} - secrets = {} - - categoryname = os.path.basename(categorydir.strip(os.sep)) - seedfn = os.path.join("category_seed.txt") - zipfilename = os.path.join(outdir, "%s.mb" % categoryname) - logging.info("Building {} from {}".format(zipfilename, categorydir)) - - if os.path.exists(zipfilename): - # open and gather some state - existing = zipfile.ZipFile(zipfilename, 'r') - try: - category_seed = existing.open(seedfn).read().strip() - except: - pass - existing.close() - logging.debug("Using PRNG seed {}".format(category_seed)) - - zf.writestr(seedfn, category_seed) - - cat = moth.Category(categorydir, category_seed) - mapping = {} - answers = {} - summary = {} - for puzzle in cat: - logging.info("Processing point value {}".format(puzzle.points)) - - hashmap = hashlib.sha1(category_seed) - hashmap.update(str(puzzle.points).encode('utf-8')) - puzzlehash = hashmap.hexdigest() - - mapping[puzzle.points] = puzzlehash - answers[puzzle.points] = puzzle.answers - summary[puzzle.points] = puzzle.summary - - puzzledir = os.path.join('content', puzzlehash) - files = [] - for fn, f in puzzle.files.items(): - if f.visible: - files.append(fn) - payload = f.stream.read() - zf.writestr(os.path.join(puzzledir, fn), payload) - - puzzledict = { - 'authors': puzzle.authors, - 'hashes': puzzle.hashes(), - 'files': files, - 'body': puzzle.html_body(), - } - puzzlejson = json.dumps(puzzledict) - zf.writestr(os.path.join(puzzledir, 'puzzle.json'), puzzlejson) - generate_html(zf, puzzle, puzzledir, categoryname, puzzle.points, puzzle.get_authors(), files) - - write_kv_pairs(zf, 'map.txt', mapping) - write_kv_pairs(zf, 'answers.txt', answers) - write_kv_pairs(zf, 'summaries.txt', summary) - - # clean up - zf.close() - - shutil.move(zipfileraw.name, zipfilename) - - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description='Build a category package') - parser.add_argument('outdir', help='Output directory') - parser.add_argument('categorydirs', nargs='+', help='Directory of category source') - args = parser.parse_args() - - logging.basicConfig(level=logging.DEBUG) - - for categorydir in args.categorydirs: - build_category(categorydir, args.outdir) - diff --git a/example-puzzles/example/200/puzzle.py b/example-puzzles/example/200/puzzle.py new file mode 100755 index 0000000..cfb9614 --- /dev/null +++ b/example-puzzles/example/200/puzzle.py @@ -0,0 +1,19 @@ +import io +import categorylib # Category-level libraries can be imported here + +def make(puzzle): + import puzzlelib # puzzle-level libraries can only be imported inside of the make function + puzzle.authors = ['donaldson'] + puzzle.summary = 'more crazy stuff you can do with puzzle generation using Python libraries' + + puzzle.body.write("## Crazy Things You Can Do With Puzzle Generation (part II)\n") + puzzle.body.write("\n") + puzzle.body.write("The source to this puzzle has some more advanced examples of stuff you can do in Python.\n") + puzzle.body.write("\n") + puzzle.body.write("1 == %s\n\n" % puzzlelib.getone(),) + puzzle.body.write("2 == %s\n\n" % categorylib.gettwo(),) + + puzzle.answers.append('tea') + answer = puzzle.make_answer() # Generates a random answer, appending it to puzzle.answers too + puzzle.log("Answers: {}".format(puzzle.answers)) + diff --git a/example-puzzles/example/200/puzzlelib.py b/example-puzzles/example/200/puzzlelib.py new file mode 100644 index 0000000..566be76 --- /dev/null +++ b/example-puzzles/example/200/puzzlelib.py @@ -0,0 +1,7 @@ +"""This is an example of a puzzle-level library. + +This library can be imported by sibling puzzles using `import puzzlelib` +""" + +def getone(): + return 1 diff --git a/example-puzzles/example/categorylib.py b/example-puzzles/example/categorylib.py new file mode 100644 index 0000000..fb5a230 --- /dev/null +++ b/example-puzzles/example/categorylib.py @@ -0,0 +1,7 @@ +"""This is an example of a category-level library. + +This library can be imported by child puzzles using `import categorylib` +""" + +def gettwo(): + return 2 diff --git a/theme/moth.js b/theme/moth.js index e4cd113..e2607cd 100644 --- a/theme/moth.js +++ b/theme/moth.js @@ -69,7 +69,11 @@ function renderPuzzles(obj) { let a = document.createElement('a') i.appendChild(a) a.textContent = points - a.href = "puzzle.html?cat=" + cat + "&points=" + points + "&pid=" + id + let url = new URL("puzzle.html", window.location) + url.searchParams.set("cat", cat) + url.searchParams.set("points", points) + url.searchParams.set("pid", id) + a.href = url.toString() } }