From c78dd5a597981589629bf93fa26b9cde2bec8014 Mon Sep 17 00:00:00 2001 From: Dan Sanderson Date: Mon, 19 Apr 2021 01:34:25 -0700 Subject: [PATCH 01/17] New config system --- .gitignore | 3 +- cursewords/config.py | 161 ++++++++++++++++++++++++++++++++++++++ cursewords/config_test.py | 111 ++++++++++++++++++++++++++ cursewords/cursewords.py | 11 ++- requirements.txt | 1 + 5 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 cursewords/config.py create mode 100644 cursewords/config_test.py diff --git a/.gitignore b/.gitignore index 7bdb8a2..a646831 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.puz -__pycache__/* +*/__pycache__/* dist/* build/* +*.egg-info diff --git a/cursewords/config.py b/cursewords/config.py new file mode 100644 index 0000000..6e33cd7 --- /dev/null +++ b/cursewords/config.py @@ -0,0 +1,161 @@ +"""A simple configuration manager. + +A configuration file is in the TOML format. It lives either in the current +working directory or in ~/.config/, with the former overriding the latter. +Configuration files do not merge: it uses the first one it finds. + +For example, this config file might be named "~/.config/myapp.toml": + + name = "Mr. Chips" + timeout_seconds = 60 + + [network] + ip_addr = "10.0.0.1" + +You can allow command line arguments to override configuration parameters +by including an argparse Namespace. You must describe the possible +parameters to your argparse parser. + + import argparse + from .config import Config + + argparser = argparse.ArgumentParser() + argparser.add_argument('filename', --help='...') + argparser.add_argument('--name', type=str, --help='...') + argparser.add_argument('--timeout-seconds', type=int, --help='...') + argparser.add_argument('--network.ip-addr', type=str, --help='...') + args = argparser.parse_args() + cfg = Config('myapp.toml', args) + + # This is either the "--name" argument if specified, or the top-level + # "name" parameter in the config file. + name = cfg.name + + # Argparse converts hyphens in argument names to underscores. This must + # appear with an underscore in the config file, e.g. "timeout_seconds". + timeout_secs = cfg.timeout_seconds + + # Command line arguments can override arguments in TOML sections using + # a dot-delimited path. + ip_address = cfg.network.ip_addr + + # Use the argparse Namespace directly to access positional command line + # arguments. (Technically Config will see this too, so avoid using a + # config parameter whose name matches an optional argument's metavar.) + filename = args.filename + +For more information on the TOML file format: + https://en.wikipedia.org/wiki/TOML +""" + +import collections +import os.path +import toml + + +# The search path for configuration files, as an ordered list of directories. +CONFIG_DIRS = ['.', '~/.config'] + + +class ConfigNamespace: + """Helper class to represent a sub-tree of config values. + + You won't use this directly. Access values with attribute paths of the + Config instance. + """ + def __init__(self): + # A key is a str. A value is either a raw value or a ConfigNamespace + # instance. + self._dict = {} + + def _set(self, path, value): + # If the key is a dot path, drill down the path. + dot_i = path.find('.') + if dot_i != -1: + k = path[:dot_i] + rest = path[dot_i+1:] + if k not in self._dict: + self._dict[k] = ConfigNamespace() + self._dict[k]._set(rest, value) + return + + # If the value is a mapping, merge values into a child namespace. + if isinstance(value, collections.abc.Mapping): + if path not in self._dict: + self._dict[path] = ConfigNamespace() + for k in value: + self._dict[path]._set(k, value[k]) + return + + # For a simple key and value, set the value. + self._dict[path] = value + + def _merge(self, mapping_value): + for k in mapping_value: + self._set(k, mapping_value[k]) + + def __getattr__(self, name): + return self._dict.get(name) + + def __repr__(self): + return '[ConfigNamespace: ' + repr(self._dict) + ']' + + +class Config: + def __init__( + self, + config_fname, + override_args=None, + config_dirs=CONFIG_DIRS): + """Initializes the configuration manager. + + Args: + config_fname: The TOML filename of the config file. + override_args: Parsed command line arguments, as an argparse + Namespace. + config_dirs: The config file search path, as a list of directory + paths. Default is current working directory, then ~/.config. + """ + self.config_fname = config_fname + self.override_args = override_args + self.config_dirs = config_dirs + + self._cache = None + + def reload(self): + """Reloads the configuration file, if any.""" + # Actually we just empty the cache, then reload on next access. + self._cache = None + + def __getattr__(self, *args, **kwargs): + self._build() + return self._cache.__getattr__(*args, **kwargs) + + def _build(self): + # Builds the config from a TOML file (if any) and args (if any). + + # Config builds on first access, then uses cached config thereafter. + if self._cache is not None: + return + + self._cache = ConfigNamespace() + + for dpath in self.config_dirs: + cfgpath = os.path.normpath( + os.path.expanduser( + os.path.join(dpath, self.config_fname))) + if not os.path.isfile(cfgpath): + continue + with open(cfgpath) as infh: + self._cache._merge(toml.loads(infh.read())) + + # It wouldn't be difficult to merge multiple config files, but + # this isn't typically expected behavior. Use only the first + # file on the lookup path. + break + + if self.override_args is not None: + args_dict = dict([ + i for i in vars(self.override_args).items() + if i[1] is not None]) + self._cache._merge(args_dict) diff --git a/cursewords/config_test.py b/cursewords/config_test.py new file mode 100644 index 0000000..20b60e3 --- /dev/null +++ b/cursewords/config_test.py @@ -0,0 +1,111 @@ +import argparse +import os + +from . import config + + +CONFIG_FNAME = 'myapp.toml' + +CONFIG_TEXT = """ +name = "Mr. Chips" +timeout_seconds = 60 + +[network] +ip_addr = "10.0.0.1" +""" + +CONFIG_TEXT_ALT = """ +name = "Scooter Computer" +timeout_seconds = 20 +""" + + +def make_temp_dirs(tmpdir): + dirs = [os.path.join(tmpdir, x) for x in ['cwd', 'home']] + for dpath in dirs: + os.makedirs(dpath) + return dirs + + +def make_config(tmpdir, in_cwd=True): + dirs = make_temp_dirs(tmpdir) + cfgpath = os.path.join(dirs[0 if in_cwd else 1], CONFIG_FNAME) + with open(cfgpath, 'w') as outfh: + outfh.write(CONFIG_TEXT) + return dirs + + +def make_args(args): + argparser = argparse.ArgumentParser() + argparser.add_argument('--name', type=str) + argparser.add_argument('--timeout-seconds', type=int) + argparser.add_argument('--network.ip-addr', type=str) + return argparser.parse_args(args) + + +def test_config_uses_args(tmpdir): + config_dirs = make_temp_dirs(tmpdir) + args = make_args(['--timeout-seconds=999']) + cfg = config.Config( + CONFIG_FNAME, override_args=args, config_dirs=config_dirs) + assert cfg.timeout_seconds == 999 + + +def test_config_uses_file(tmpdir): + config_dirs = make_config(tmpdir) + args = make_args([]) + cfg = config.Config( + CONFIG_FNAME, override_args=args, config_dirs=config_dirs) + assert cfg.timeout_seconds == 60 + + +def test_config_honors_lookup_path(tmpdir): + config_dirs = make_config(tmpdir, in_cwd=False) + with open(os.path.join(config_dirs[0], CONFIG_FNAME), 'w') as outfh: + outfh.write(CONFIG_TEXT_ALT) + args = make_args([]) + cfg = config.Config( + CONFIG_FNAME, override_args=args, config_dirs=config_dirs) + assert cfg.timeout_seconds == 20 + + +def test_lookup_path_supports_cwd(tmpdir): + config_dirs = make_config(tmpdir) + os.chdir(config_dirs[0]) + config_dirs[0] = '.' + args = make_args([]) + cfg = config.Config( + CONFIG_FNAME, override_args=args, config_dirs=config_dirs) + assert cfg.timeout_seconds == 60 + + +def test_dotpath_from_arg(tmpdir): + config_dirs = make_temp_dirs(tmpdir) + args = make_args(['--network.ip-addr=192.168.0.1']) + cfg = config.Config( + CONFIG_FNAME, override_args=args, config_dirs=config_dirs) + assert cfg.network.ip_addr == '192.168.0.1' + + +def test_dotpath_from_file(tmpdir): + config_dirs = make_config(tmpdir) + args = make_args([]) + cfg = config.Config( + CONFIG_FNAME, override_args=args, config_dirs=config_dirs) + assert cfg.network.ip_addr == '10.0.0.1' + + +def test_arg_overrides_file(tmpdir): + config_dirs = make_config(tmpdir) + args = make_args(['--timeout-seconds=15']) + cfg = config.Config( + CONFIG_FNAME, override_args=args, config_dirs=config_dirs) + assert cfg.timeout_seconds == 15 + + +def test_dotpath_arg_overrides_file(tmpdir): + config_dirs = make_config(tmpdir) + args = make_args(['--network.ip-addr=192.168.0.1']) + cfg = config.Config( + CONFIG_FNAME, override_args=args, config_dirs=config_dirs) + assert cfg.network.ip_addr == '192.168.0.1' diff --git a/cursewords/cursewords.py b/cursewords/cursewords.py index 4a9940f..e0b8fa6 100755 --- a/cursewords/cursewords.py +++ b/cursewords/cursewords.py @@ -13,6 +13,10 @@ from blessed import Terminal from . import chars +from . import config + + +CONFIG_FNAME = 'cursewords.toml' class Cell: @@ -694,8 +698,9 @@ def main(): parser.add_argument('--version', action='version', version=version) args = parser.parse_args() + cfg = config.Config(CONFIG_FNAME, override_args=args) filename = args.filename - downs_only = args.downs_only + downs_only = cfg.downs_only try: puzfile = puz.read(filename) @@ -733,7 +738,7 @@ def main(): if necessary_resize: exit_text = textwrap.dedent("""\ This puzzle is {} columns wide and {} rows tall. - The terminal window must be {} to properly display + The terminal window must be {} to properly display it.""".format( grid.column_count, grid.row_count, ' and '.join(necessary_resize))) @@ -853,7 +858,7 @@ def main(): wrapped_clue = [line + term.clear_eol for line in wrapped_clue] # This is fun: since we're in raw mode, \n isn't sufficient to - # return the printing location to the first column. If you + # return the printing location to the first column. If you # don't also have \r, # it # prints diff --git a/requirements.txt b/requirements.txt index 7baf74c..dd65ead 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ blessed==1.15.0 puzpy==0.2.4 +pytest From 400308d91daa124de10753956fe444e3e2e607e4 Mon Sep 17 00:00:00 2001 From: Dan Sanderson Date: Tue, 20 Apr 2021 00:10:44 -0700 Subject: [PATCH 02/17] With apologies, an autopep8 reformatting pass. --- .DS_Store | Bin 0 -> 6148 bytes .gitignore | 1 + cursewords/config.py | 3 +- cursewords/cursewords.py | 185 +++++++++++++++++++++------------------ 4 files changed, 102 insertions(+), 87 deletions(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..11c781f37a48385ca228112f58c22ec2fb850329 GIT binary patch literal 6148 zcmeH~L2AQ53`M_EF9O+k+2w3{fZSjR$q90SkeWcD;6>8?9KBx}ZR&PiO!xxnjWiav z|H5Mdu*2812Sxx}x)X067G}%`O!&YZkK1&Azs~cg7ipUgcuF6!*w1Z23P=GdAO)m= z6j+f0d5mv&D|#k9iWHCn>rlYI4~6cm$<`U44u%*3$bscBu49%Uix(Ne@ysjo&XK#n)@L_pp^C^aAy&YDV(5wa&q<|DyDDcwr(a--A{jd3d(V|oe zNP#C)z=q@bc;HLr+4}4Cyne~5uN$3=%Nc(B1TgWV_>~^U{o)I 1: self.down_words.append(current_word) - self.down_words_grouped = sorted(self.down_words, - key=lambda word: (word[0][1], word[0][0])) + self.down_words_grouped = sorted( + self.down_words, + key=lambda word: (word[0][1], word[0][0])) num = self.puzfile.clue_numbering() self.across_clues = [word['clue'] for word in num.across] @@ -138,7 +139,8 @@ def load(self, puzfile): timer_bytes = self.puzfile.extensions.get(puz.Extensions.Timer, None) if timer_bytes: - self.start_time, self.timer_active = timer_bytes.decode().split(',') + self.start_time, self.timer_active = \ + timer_bytes.decode().split(',') else: self.start_time, self.timer_active = 0, 1 @@ -151,17 +153,17 @@ def draw(self): divider_row = self.get_divider_row() print(self.term.move(self.grid_y, self.grid_x) - + self.term.dim(top_row)) + + self.term.dim(top_row)) for index, y_val in enumerate( - range(self.grid_y + 1, - self.grid_y + self.row_count * 2), - 1): + range(self.grid_y + 1, + self.grid_y + self.row_count * 2), + 1): if index % 2 == 0: print(self.term.move(y_val, self.grid_x) + - self.term.dim(divider_row)) + self.term.dim(divider_row)) else: print(self.term.move(y_val, self.grid_x) + - self.term.dim(middle_row)) + self.term.dim(middle_row)) print(self.term.move(self.grid_y + self.row_count * 2, self.grid_x) + self.term.dim(bottom_row)) @@ -191,7 +193,7 @@ def fill(self): self.draw_cell(position) elif cell.is_block(): print(self.term.move(y_coord, x_coord - 1) + - self.term.dim(chars.squareblock)) + self.term.dim(chars.squareblock)) if cell.number: small = small_nums(cell.number) @@ -204,8 +206,8 @@ def confirm_quit(self, modified_since_save): confirmed = True if modified_since_save: confirmation = self.get_notification_input( - "Quit without saving? (y/n)", - chars=1, blocking=True, timeout=5) + "Quit without saving? (y/n)", + chars=1, blocking=True, timeout=5) if confirmation.lower() == 'y': confirmed = True else: @@ -214,8 +216,9 @@ def confirm_quit(self, modified_since_save): return confirmed def confirm_clear(self): - confirmation = self.get_notification_input("Clear puzzle? (y/n)", - chars=1, blocking=True, timeout=5) + confirmation = self.get_notification_input( + "Clear puzzle? (y/n)", + chars=1, blocking=True, timeout=5) if confirmation.lower() == 'y': confirmed = True else: @@ -223,8 +226,9 @@ def confirm_clear(self): return confirmed def confirm_reset(self): - confirmation = self.get_notification_input("Reset puzzle? (y/n)", - chars=1, blocking=True, timeout=5) + confirmation = self.get_notification_input( + "Reset puzzle? (y/n)", + chars=1, blocking=True, timeout=5) if confirmation.lower() == 'y': confirmed = True else: @@ -295,7 +299,6 @@ def to_term(self, position): term_y = self.grid_y + (2 * point_y) + 1 return (term_y, term_x) - def make_row(self, leftmost, middle, divider, rightmost): row = leftmost for col in range(1, self.column_count * 4): @@ -305,16 +308,19 @@ def make_row(self, leftmost, middle, divider, rightmost): return row def get_top_row(self): - return self.make_row(chars.ulcorner, chars.hline, chars.ttee, chars.urcorner) + return self.make_row(chars.ulcorner, chars.hline, + chars.ttee, chars.urcorner) def get_bottom_row(self): - return self.make_row(chars.llcorner, chars.hline, chars.btee, chars.lrcorner) + return self.make_row(chars.llcorner, chars.hline, + chars.btee, chars.lrcorner) def get_middle_row(self): return self.make_row(chars.vline, " ", chars.vline, chars.vline) def get_divider_row(self): - return self.make_row(chars.ltee, chars.hline, chars.bigplus, chars.rtee) + return self.make_row(chars.ltee, chars.hline, + chars.bigplus, chars.rtee) def compile_cell(self, position): cell = self.cells.get(position) @@ -356,19 +362,19 @@ def draw_cursor_cell(self, position): print(self.term.move(*self.to_term(position)) + value) def get_notification_input(self, message, timeout=5, chars=3, - input_condition=str.isalnum, blocking=False): + input_condition=str.isalnum, blocking=False): # If there's already a notification timer running, stop it. try: self.notification_timer.cancel() - except: + except BaseException: pass input_phrase = message + " " key_input_place = len(input_phrase) print(self.term.move(*self.notification_area) - + self.term.reverse(input_phrase) - + self.term.clear_eol) + + self.term.reverse(input_phrase) + + self.term.clear_eol) user_input = '' keypress = None @@ -376,14 +382,16 @@ def get_notification_input(self, message, timeout=5, chars=3, keypress = self.term.inkey(timeout) if input_condition(keypress): user_input += keypress - print(self.term.move(self.notification_area[0], - self.notification_area[1] + key_input_place), - user_input) + print(self.term.move( + self.notification_area[0], + self.notification_area[1] + key_input_place), + user_input) elif keypress.name in ['KEY_DELETE']: user_input = user_input[:-1] - print(self.term.move(self.notification_area[0], - self.notification_area[1] + key_input_place), - user_input + self.term.clear_eol) + print(self.term.move( + self.notification_area[0], + self.notification_area[1] + key_input_place), + user_input + self.term.clear_eol) elif blocking and keypress.name not in ['KEY_ENTER', 'KEY_ESCAPE']: continue else: @@ -393,10 +401,10 @@ def get_notification_input(self, message, timeout=5, chars=3, def send_notification(self, message, timeout=5): self.notification_timer = threading.Timer(timeout, - self.clear_notification_area) + self.clear_notification_area) self.notification_timer.daemon = True print(self.term.move(*self.notification_area) - + self.term.reverse(message) + self.term.clear_eol) + + self.term.reverse(message) + self.term.clear_eol) self.notification_timer.start() def clear_notification_area(self): @@ -456,7 +464,7 @@ def move_within_word(self, overwrite_mode=False, wrap_mode=False): if not overwrite_mode: ordered_spaces = [pos for pos in ordered_spaces - if self.grid.cells.get(pos).is_blankish()] + if self.grid.cells.get(pos).is_blankish()] return next(iter(ordered_spaces), None) @@ -495,7 +503,7 @@ def advance_to_next_word(self, blank_placement=False): # the blank_placement setting if (blank_placement and not any(self.grid.cells.get(pos).is_blankish() for - pos in itertools.chain(*self.grid.across_words))): + pos in itertools.chain(*self.grid.across_words))): blank_placement = False # Otherwise, if blank_placement is on, put the @@ -531,7 +539,7 @@ def retreat_to_previous_word(self, # the blank_placement setting if (blank_placement and not any(self.grid.cells.get(pos).is_blankish() for - pos in itertools.chain(*self.grid.across_words))): + pos in itertools.chain(*self.grid.across_words))): blank_placement = False if blank_placement and self.earliest_blank_in_word(): @@ -541,7 +549,7 @@ def retreat_to_previous_word(self, def earliest_blank_in_word(self): blanks = (pos for pos in self.current_word() - if self.grid.cells.get(pos).is_blankish()) + if self.grid.cells.get(pos).is_blankish()) return next(blanks, None) def move_right(self): @@ -591,11 +599,11 @@ def go_to_numbered_square(self): if num: pos = next((pos for pos in self.grid.cells if self.grid.cells.get(pos).number == int(num)), - None) + None) if pos: self.position = pos self.grid.send_notification( - "Moved cursor to square {}.".format(num)) + "Moved cursor to square {}.".format(num)) else: self.grid.send_notification("Not a valid number.") else: @@ -621,7 +629,7 @@ def run(self): while self.active: if self.is_running: self.time_passed = (self.starting_seconds - + int(time.time() - self.start_time)) + + int(time.time() - self.start_time)) self.show_time() time.sleep(0.5) @@ -631,7 +639,7 @@ def show_time(self): x_coord = self.grid.grid_x + self.grid.column_count * 4 - 7 print(self.grid.term.move(y_coord, x_coord) - + self.display_format()) + + self.display_format()) def display_format(self): time_amount = self.time_passed @@ -648,7 +656,7 @@ def save_format(self): time_amount = self.time_passed save_string = '{t},{r}'.format( - t=int(time_amount), r=int(self.active)) + t=int(time_amount), r=int(self.active)) save_bytes = save_string.encode(puz.ENCODING) @@ -672,6 +680,7 @@ def small_nums(number): return small_num + def encircle(letter): circle_dict = {"A": "Ⓐ", "B": "Ⓑ", "C": "Ⓒ", "D": "Ⓓ", "E": "Ⓔ", "F": "Ⓕ", "G": "Ⓖ", "H": "Ⓗ", "I": "Ⓘ", "J": "Ⓙ", "K": "Ⓚ", "L": "Ⓛ", @@ -688,14 +697,17 @@ def main(): version = f.read().strip() parser = argparse.ArgumentParser( - prog='cursewords', - description="""A terminal-based crossword puzzle solving interface.""") - - parser.add_argument('filename', metavar='PUZfile', - help="""path of puzzle file in the AcrossLite .puz format""") - parser.add_argument('--downs-only', action='store_true', - help="""displays only the down clues""") - parser.add_argument('--version', action='version', version=version) + prog='cursewords', + description="""A terminal-based crossword puzzle solving interface.""") + + parser.add_argument( + 'filename', metavar='PUZfile', + help="""path of puzzle file in the AcrossLite .puz format""") + parser.add_argument( + '--downs-only', action='store_true', + help="""displays only the down clues""") + parser.add_argument( + '--version', action='version', version=version) args = parser.parse_args() cfg = config.Config(CONFIG_FNAME, override_args=args) @@ -704,7 +716,7 @@ def main(): try: puzfile = puz.read(filename) - except: + except BaseException: sys.exit("Unable to parse {} as a .puz file.".format(filename)) term = Terminal() @@ -719,15 +731,15 @@ def main(): puzzle_height = 2 * grid.row_count min_width = (puzzle_width - + grid_x - + 2) # a little breathing room + + grid_x + + 2) # a little breathing room min_height = (puzzle_height - + grid_y # includes the top bar + timer - + 2 # padding above clues - + 3 # clue area - + 2 # toolbar - + 2) # again, just some breathing room + + grid_y # includes the top bar + timer + + 2 # padding above clues + + 3 # clue area + + 2 # toolbar + + 2) # again, just some breathing room necessary_resize = [] if term.width < min_width: @@ -766,8 +778,8 @@ def main(): puzzle_info = "{}…".format(puzzle_info[:pz_width - 1]) headline = " {:<{pz_w}}{:>{sw_w}} ".format( - puzzle_info, software_info, - pz_w=pz_width, sw_w=sw_width) + puzzle_info, software_info, + pz_w=pz_width, sw_w=sw_width) with term.location(x=0, y=0): print(term.dim(term.reverse(headline))) @@ -780,7 +792,7 @@ def main(): ("^R", "reveal"), ("^G", "go to"), ("^X", "clear"), - ("^Z", "reset"),] + ("^Z", "reset"), ] if term.width >= 15 * len(commands): for shortcut, action in commands: @@ -791,7 +803,7 @@ def main(): print(toolbar, end='') else: grid.notification_area = (grid.notification_area[0] - 1, grid_x) - command_split = int(len(commands)/2) - 1 + command_split = int(len(commands) / 2) - 1 for idx, (shortcut, action) in enumerate(commands): shortcut = term.reverse(shortcut) toolbar += "{:<25}".format(' '.join([shortcut, action])) @@ -806,9 +818,9 @@ def main(): term.width - 2 - grid_x) clue_wrapper = textwrap.TextWrapper( - width=clue_width, - max_lines=3, - subsequent_indent=grid_x * ' ') + width=clue_width, + max_lines=3, + subsequent_indent=grid_x * ' ') start_pos = grid.across_words[0][0] cursor = Cursor(start_pos, "across", grid) @@ -841,18 +853,18 @@ def main(): # Draw the clue for the new word: if cursor.direction == "across": num_index = grid.across_words.index( - cursor.current_word()) + cursor.current_word()) clue = grid.across_clues[num_index] if downs_only: clue = "—" elif cursor.direction == "down": num_index = grid.down_words_grouped.index( - cursor.current_word()) + cursor.current_word()) clue = grid.down_clues[num_index] num = str(grid.cells.get(cursor.current_word()[0]).number) compiled_clue = (num + " " + cursor.direction.upper() - + ": " + clue) + + ": " + clue) wrapped_clue = clue_wrapper.wrap(compiled_clue) wrapped_clue += [''] * (3 - len(wrapped_clue)) wrapped_clue = [line + term.clear_eol for line in wrapped_clue] @@ -865,7 +877,7 @@ def main(): # like # this after each newline print(term.move(info_location['y'], info_location['x']) - + '\r\n'.join(wrapped_clue)) + + '\r\n'.join(wrapped_clue)) # Otherwise, just draw the old square now that it's not under # the cursor @@ -873,16 +885,15 @@ def main(): grid.draw_highlighted_cell(old_position) current_cell = grid.cells.get(cursor.position) - value = current_cell.entry grid.draw_cursor_cell(cursor.position) # Check if the puzzle is complete! if not puzzle_complete and all(grid.cells.get(pos).is_correct() - for pos in grid.cells): + for pos in grid.cells): puzzle_complete = True with term.location(x=grid_x, y=2): print(term.reverse("You've completed the puzzle!"), - term.clear_eol) + term.clear_eol) timer.show_time() timer.active = False @@ -903,7 +914,8 @@ def main(): # ctrl-s elif keypress == chr(19): - grid.puzfile.extensions[puz.Extensions.Timer] = timer.save_format() + grid.puzfile.extensions[puz.Extensions.Timer] = \ + timer.save_format() grid.save(filename) modified_since_save = False @@ -955,8 +967,8 @@ def main(): # ctrl-c elif keypress == chr(3): group = grid.get_notification_input( - "Check (l)etter, (w)ord, or (p)uzzle?", - chars=1) + "Check (l)etter, (w)ord, or (p)uzzle?", + chars=1) scope = '' if group.lower() == 'l': scope = 'letter' @@ -970,7 +982,7 @@ def main(): if scope: grid.send_notification("Checked {scope} for errors.". - format(scope=scope)) + format(scope=scope)) else: grid.send_notification("No valid input entered.") @@ -995,12 +1007,11 @@ def main(): else: grid.send_notification("Clear command canceled.") - # ctrl-r elif keypress == chr(18): group = grid.get_notification_input( - "Reveal (l)etter, (w)ord, or (p)uzzle?", - chars=1) + "Reveal (l)etter, (w)ord, or (p)uzzle?", + chars=1) scope = '' if group.lower() == 'l': scope = 'letter' @@ -1014,7 +1025,7 @@ def main(): if scope: grid.send_notification("Revealed answers for {scope}.". - format(scope=scope)) + format(scope=scope)) else: grid.send_notification("No valid input entered.") @@ -1040,10 +1051,12 @@ def main(): cursor.retreat_within_word(end_placement=True) # Navigation - elif keypress.name in ['KEY_TAB'] and current_cell.is_blankish(): + elif (keypress.name in ['KEY_TAB'] and + current_cell.is_blankish()): cursor.advance_to_next_word(blank_placement=True) - elif keypress.name in ['KEY_TAB'] and not current_cell.is_blankish(): + elif (keypress.name in ['KEY_TAB'] and + not current_cell.is_blankish()): cursor.advance_within_word(overwrite_mode=False) elif keypress.name in ['KEY_PGDOWN']: @@ -1066,14 +1079,14 @@ def main(): cursor.switch_direction() elif ((cursor.direction == "across" and - keypress.name == 'KEY_RIGHT') or + keypress.name == 'KEY_RIGHT') or (cursor.direction == "down" and keypress.name == 'KEY_DOWN')): cursor.advance() elif ((cursor.direction == "across" and - keypress.name == 'KEY_LEFT') or + keypress.name == 'KEY_LEFT') or (cursor.direction == "down" and keypress.name == 'KEY_UP')): From c1519db9e44904c64de41c479a4245e909d43ee7 Mon Sep 17 00:00:00 2001 From: Dan Sanderson Date: Tue, 20 Apr 2021 02:00:04 -0700 Subject: [PATCH 03/17] Twinkle animation capability --- cursewords/cursewords.py | 53 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/cursewords/cursewords.py b/cursewords/cursewords.py index f988f68..120ba59 100755 --- a/cursewords/cursewords.py +++ b/cursewords/cursewords.py @@ -3,6 +3,7 @@ import argparse import itertools import os +import random import sys import time import textwrap @@ -66,10 +67,12 @@ def __init__(self, grid_x, grid_y, term): self.term = term self.notification_area = (term.height - 2, self.grid_x) + self.twinkle_delay = 0.12 def load(self, puzfile): self.puzfile = puzfile self.cells = {} + self.twinkles = {} self.row_count = puzfile.height self.column_count = puzfile.width @@ -410,6 +413,47 @@ def send_notification(self, message, timeout=5): def clear_notification_area(self): print(self.term.move(*self.notification_area) + self.term.clear_eol) + def draw_twinkle(self, pos, frame): + # 0th char is vline, will always reset square sides in last frame + twinkle_chars = chars.vline + '/-\\' + twinkle_c = twinkle_chars[frame % len(twinkle_chars)] + term_y, term_x = self.to_term(pos) + print(self.term.move(term_y, term_x - 2) + twinkle_c + + self.term.move(term_y, term_x + 2) + twinkle_c) + + def set_twinkle_timer(self, force=False): + if force or getattr(self, 'twinkle_timer', None) is None: + self.twinkle_timer = threading.Timer( + self.twinkle_delay, self.animate_twinkles) + self.twinkle_timer.daemon = True + self.twinkle_timer.start() + + def start_twinkle(self, pos, duration=None): + if duration is None: + duration = int(5 / self.twinkle_delay) + self.twinkles[pos] = duration + self.set_twinkle_timer() + + def animate_twinkles(self): + dead_twinkles = [] + for pos in self.twinkles: + self.twinkles[pos] -= 1 + self.draw_twinkle(pos, self.twinkles[pos]) + if self.twinkles[pos] == 0: + dead_twinkles.append(pos) + for pos in dead_twinkles: + del self.twinkles[pos] + + if self.twinkles: + self.set_twinkle_timer(force=True) + else: + self.twinkle_timer = None + + def clear_twinkles(self): + for pos in self.twinkles: + self.draw_twinkle(pos, 0) + self.twinkles = {} + class Cursor: def __init__(self, position, direction, grid): @@ -850,7 +894,7 @@ def main(): for pos in cursor.current_word(): grid.draw_highlighted_cell(pos) - # Draw the clue for the new word: + # Draw the clue for the new word: if cursor.direction == "across": num_index = grid.across_words.index( cursor.current_word()) @@ -924,6 +968,7 @@ def main(): if timer.is_running: timer.pause() grid.draw() + grid.clear_twinkles() with term.location(**info_location): print('\r\n'.join(['PUZZLE PAUSED' + term.clear_eol, @@ -960,6 +1005,12 @@ def main(): else: grid.send_notification("Reset command canceled.") + elif keypress == chr(1): + # Secret twinkle demo: ^A twinkles a random square + pos = random.choice(list(grid.cells.keys())) + grid.send_notification("Twinkle " + repr(pos)) + grid.start_twinkle(pos) + # If the puzzle is paused, skip all the rest of the logic elif puzzle_paused: continue From 627d8d54101a67bffca901eb14661a3c7e3400d9 Mon Sep 17 00:00:00 2001 From: Dan Sanderson Date: Thu, 22 Apr 2021 19:51:23 -0700 Subject: [PATCH 04/17] Twinkle unsolved word --- cursewords/cursewords.py | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/cursewords/cursewords.py b/cursewords/cursewords.py index 120ba59..ce6c0fe 100755 --- a/cursewords/cursewords.py +++ b/cursewords/cursewords.py @@ -67,7 +67,7 @@ def __init__(self, grid_x, grid_y, term): self.term = term self.notification_area = (term.height - 2, self.grid_x) - self.twinkle_delay = 0.12 + self.twinkle_delay = 0.1 def load(self, puzfile): self.puzfile = puzfile @@ -119,6 +119,16 @@ def load(self, puzfile): self.down_words, key=lambda word: (word[0][1], word[0][0])) + self.word_index = {} + for word_coords in (self.across_words + self.down_words): + word_text = ''.join( + self.cells[pos].solution for pos in word_coords).lower() + if word_text not in self.word_index: + self.word_index[word_text] = [] + # word_index maps word text to a list of coordinate sets. This + # supports the edge case where a puzzle contains dupes. + self.word_index[word_text].append(word_coords) + num = self.puzfile.clue_numbering() self.across_clues = [word['clue'] for word in num.across] self.down_clues = [word['clue'] for word in num.down] @@ -454,6 +464,24 @@ def clear_twinkles(self): self.draw_twinkle(pos, 0) self.twinkles = {} + def twinkle_unsolved_word(self, txt, duration=None): + """Twinkle an unsolved word via its lowercase text, if any. + + In the edge case where a word appears more than once in the puzzle + unsolved, only one unsolved instance is twinkled. + + Returns: + True if a matching unsolved word was found and twinkled. + """ + if txt in self.word_index: + for word_coords in self.word_index[txt]: + if all(self.cells[pos].is_correct() for pos in word_coords): + continue + for pos in word_coords: + self.start_twinkle(pos, duration=duration) + return True + return False + class Cursor: def __init__(self, position, direction, grid): @@ -1006,10 +1034,11 @@ def main(): grid.send_notification("Reset command canceled.") elif keypress == chr(1): - # Secret twinkle demo: ^A twinkles a random square - pos = random.choice(list(grid.cells.keys())) - grid.send_notification("Twinkle " + repr(pos)) - grid.start_twinkle(pos) + # Secret twinkle demo: ^A twinkles (and spoils) a random word. + # Will spoil but not twinkle if the word is solved. + word_txt = random.choice(list(grid.word_index.keys())) + grid.send_notification("Twinkled " + word_txt) + grid.twinkle_unsolved_word(word_txt) # If the puzzle is paused, skip all the rest of the logic elif puzzle_paused: From d8b6981d9197e02ab4fd9e6ea0770c6d002321df Mon Sep 17 00:00:00 2001 From: Dan Sanderson Date: Thu, 22 Apr 2021 21:33:19 -0700 Subject: [PATCH 05/17] Rewrite config mechanism to wrap argparse, support flags --- cursewords.toml | 39 ++++++++++ cursewords/config.py | 160 ++++++++++++++++++++++++++++---------- cursewords/config_test.py | 72 +++++++---------- cursewords/cursewords.py | 8 +- 4 files changed, 193 insertions(+), 86 deletions(-) create mode 100644 cursewords.toml diff --git a/cursewords.toml b/cursewords.toml new file mode 100644 index 0000000..e0f8969 --- /dev/null +++ b/cursewords.toml @@ -0,0 +1,39 @@ +# This is the configuration file for Cursewords. +# +# This file is expected to be named "cursewords.toml". It can be kept in the +# current working directory where Cursewords is run or in $HOME/.config/. +# +# Most options can be overridden on the command line, like so: +# cursewords --twitch.nickname=mybot --twitch.enable-clue=false puzfile.puz + +[twitch] +# The Twitch account that Cursewords should use to connect. +nickname = "..." + +# The Twitch channel that Cursewords should join. +channel = "..." + +# The OAuth token is a temporary password that Cursewords will use to connect +# to Twitch. This is *not* the Twitch password for the account. +# +# To get the OAuth token, sign in to this website with the bot's Twitch +# account and password: +# https://twitchapps.com/tmi/ +# +# The OAuth token is a string that begins "oauth:" followed by letters and +# numbers. Keep the OAuth token secret. +# +# To revoke access for this token, sign in to Twitch.tv with the bot account, +# go to Settings, then select the Connections tab. Locate the "Twitch +# Developer" connection then click Disconnect. +oauth_token = "oauth:..." + +# Viewers can guess words in the puzzle and cause things to happen. +enable_guessing = true + +# Viewers can type "!clue 22d" to have the bot post a clue to the chat room. +enable_clue = true + +# If enable_clue is true, the number of seconds a given user must wait between +# clue requests. +clues_per_second_per_person = 10 diff --git a/cursewords/config.py b/cursewords/config.py index fbe371f..9984ceb 100644 --- a/cursewords/config.py +++ b/cursewords/config.py @@ -4,7 +4,7 @@ working directory or in ~/.config/, with the former overriding the latter. Configuration files do not merge: it uses the first one it finds. -For example, this config file might be named "~/.config/myapp.toml": +For example, this config file might be named "$HOME/.config/myapp.toml": name = "Mr. Chips" timeout_seconds = 60 @@ -12,20 +12,25 @@ [network] ip_addr = "10.0.0.1" -You can allow command line arguments to override configuration parameters -by including an argparse Namespace. You must describe the possible -parameters to your argparse parser. +Config allows for overriding configuration parameters on the command line. +To enable this, register expected parameters with ConfigParser, then request +the ArgumentParser to set non-config parameters: - import argparse - from .config import Config + from . import config - argparser = argparse.ArgumentParser() + cfgparser = config.ConfigParser('myapp.toml') + cfgparser.add_parameter('name', type=str, --help='...') + cfgparser.add_parameter('timeout-seconds', type=int, --help='...') + cfgparser.add_parameter('network.ip-addr', type=str, --help='...') + cfgparser.add_parameter('verbose-log', type=bool, --help='...') + + # Set non-config arguments on the ArgumentParser. Technically Config will + # see these arguments too, so avoid using a config parameter whose name + # matches an optional argument's metavar. + argparser = cfgparser.get_argument_parser() argparser.add_argument('filename', --help='...') - argparser.add_argument('--name', type=str, --help='...') - argparser.add_argument('--timeout-seconds', type=int, --help='...') - argparser.add_argument('--network.ip-addr', type=str, --help='...') - args = argparser.parse_args() - cfg = Config('myapp.toml', args) + + cfg = cfgparser.parse_cfg() # This is either the "--name" argument if specified, or the top-level # "name" parameter in the config file. @@ -39,15 +44,26 @@ # a dot-delimited path. ip_address = cfg.network.ip_addr + # If type=bool, the parameter supports TOML true and false values. On the + # command line, the parameter can be set or overriden using flag syntax, + # e.g. --verbose-log or --no-verbose-log. + do_verbose_logging = cfg.verbose_log + # Use the argparse Namespace directly to access positional command line - # arguments. (Technically Config will see this too, so avoid using a - # config parameter whose name matches an optional argument's metavar.) + # arguments. + args = argparser.parse_args() filename = args.filename +Config can access all values in the TOML file, not just those registered with +add_parameter(). Only items with add_parameter() can be overriden on the +command line. Only simple types are supported on the command line. Structures +like lists are supported in TOML, but not supported on the command line. + For more information on the TOML file format: https://en.wikipedia.org/wiki/TOML """ +import argparse import collections import os.path import toml @@ -102,44 +118,93 @@ def __repr__(self): return '[ConfigNamespace: ' + repr(self._dict) + ']' -class Config: +class ConfigParameter: + def __init__(self, name, is_flag=False, default=None): + self.name = name + self.is_flag = is_flag + self.default = default + + +class ConfigParser: def __init__( self, config_fname, - override_args=None, - config_dirs=CONFIG_DIRS): + config_dirs=CONFIG_DIRS, + *args, + **kwargs): """Initializes the configuration manager. Args: config_fname: The TOML filename of the config file. - override_args: Parsed command line arguments, as an argparse - Namespace. config_dirs: The config file search path, as a list of directory - paths. Default is current working directory, then ~/.config. + paths. Default is current working directory (.), then + ~/.config. + *args: Remaining positional arguments are passed to + argparse.ArgumentParser. + **kwargs: Remaining keyword arguments are passed to + argparse.ArgumentParser. """ self.config_fname = config_fname - self.override_args = override_args self.config_dirs = config_dirs - self._cache = None + self._argparser = argparse.ArgumentParser(*args, **kwargs) + self._params = [] - def reload(self): - """Reloads the configuration file, if any.""" - # Actually we just empty the cache, then reload on next access. - self._cache = None - - def __getattr__(self, *args, **kwargs): - self._build() - return self._cache.__getattr__(*args, **kwargs) + def add_parameter( + self, + name, + default=None, + type=str, + help=None): + """Registers a config parameter as overrideable on the command line. - def _build(self): - # Builds the config from a TOML file (if any) and args (if any). + Args: + name: The TOML path name of the parameter. Hyphens will be + treated as hyphens for the command line and as underscores + for the TOML file. Dots indicate TOML sections. + default: The default value if not specified on either the command + line or in the TOML file. + type: The type of the value: str, int, or bool. + help: A description of the option for argparse's help messages. + """ + if type == bool: + self._params.append( + ConfigParameter( + name, + is_flag=True, + default=default)) + self._argparser.add_argument( + '--' + name, + action='store_true', + required=False, + help=help) + self._argparser.add_argument( + '--no-' + name, + action='store_false', + required=False, + help=help) + else: + self._params.append(ConfigParameter(name, default=default)) + self._argparser.add_argument( + '--' + name, + type=type, + required=False, + help=help) + + def get_argument_parser(self): + return self._argparser + + def parse_cfg(self, args=None): + """Parses the config file and command line arguments. - # Config builds on first access, then uses cached config thereafter. - if self._cache is not None: - return + Args: + args: A list of strings to parse as command line arguments. + The default is taken from sys.argv. - self._cache = ConfigNamespace() + Returns: + The results object. + """ + namespace = ConfigNamespace() for dpath in self.config_dirs: cfgpath = os.path.normpath( @@ -148,15 +213,30 @@ def _build(self): if not os.path.isfile(cfgpath): continue with open(cfgpath) as infh: - self._cache._merge(toml.loads(infh.read())) + namespace._merge(toml.loads(infh.read())) # It wouldn't be difficult to merge multiple config files, but # this isn't typically expected behavior. Use only the first # file on the lookup path. break - if self.override_args is not None: + args = self._argparser.parse_args(args=args) + if args is not None: args_dict = dict([ - i for i in vars(self.override_args).items() + i for i in vars(args).items() if i[1] is not None]) - self._cache._merge(args_dict) + + for param in self._params: + if param.is_flag: + if 'no_' + param.name in args_dict: + args_dict[param.name] = False + del args_dict['no_' + param.name] + + namespace._merge(args_dict) + + for param in self._params: + if (getattr(namespace, param.name) is None and + param.default is not None): + namespace._set(param.name, param.default) + + return namespace diff --git a/cursewords/config_test.py b/cursewords/config_test.py index 20b60e3..002b4ce 100644 --- a/cursewords/config_test.py +++ b/cursewords/config_test.py @@ -1,4 +1,3 @@ -import argparse import os from . import config @@ -35,37 +34,41 @@ def make_config(tmpdir, in_cwd=True): return dirs -def make_args(args): - argparser = argparse.ArgumentParser() - argparser.add_argument('--name', type=str) - argparser.add_argument('--timeout-seconds', type=int) - argparser.add_argument('--network.ip-addr', type=str) - return argparser.parse_args(args) +def make_config_parser(config_dirs, config_fname=CONFIG_FNAME): + cfgparser = config.ConfigParser( + config_fname=config_fname, + config_dirs=config_dirs) + cfgparser.add_parameter('name', type=str) + cfgparser.add_parameter('timeout-seconds', type=int) + cfgparser.add_parameter('network.ip-addr', type=str) + cfgparser.add_parameter('verbose-log', type=bool) + return cfgparser def test_config_uses_args(tmpdir): - config_dirs = make_temp_dirs(tmpdir) - args = make_args(['--timeout-seconds=999']) - cfg = config.Config( - CONFIG_FNAME, override_args=args, config_dirs=config_dirs) + cfgparser = make_config_parser(make_temp_dirs(tmpdir)) + cfg = cfgparser.parse_cfg(['--timeout-seconds=999']) assert cfg.timeout_seconds == 999 def test_config_uses_file(tmpdir): - config_dirs = make_config(tmpdir) - args = make_args([]) - cfg = config.Config( - CONFIG_FNAME, override_args=args, config_dirs=config_dirs) + cfgparser = make_config_parser(make_config(tmpdir)) + cfg = cfgparser.parse_cfg([]) assert cfg.timeout_seconds == 60 +def test_arg_overrides_file(tmpdir): + cfgparser = make_config_parser(make_config(tmpdir)) + cfg = cfgparser.parse_cfg(['--timeout-seconds=999']) + assert cfg.timeout_seconds == 999 + + def test_config_honors_lookup_path(tmpdir): config_dirs = make_config(tmpdir, in_cwd=False) + cfgparser = make_config_parser(config_dirs) with open(os.path.join(config_dirs[0], CONFIG_FNAME), 'w') as outfh: outfh.write(CONFIG_TEXT_ALT) - args = make_args([]) - cfg = config.Config( - CONFIG_FNAME, override_args=args, config_dirs=config_dirs) + cfg = cfgparser.parse_cfg([]) assert cfg.timeout_seconds == 20 @@ -73,39 +76,24 @@ def test_lookup_path_supports_cwd(tmpdir): config_dirs = make_config(tmpdir) os.chdir(config_dirs[0]) config_dirs[0] = '.' - args = make_args([]) - cfg = config.Config( - CONFIG_FNAME, override_args=args, config_dirs=config_dirs) + cfgparser = make_config_parser(config_dirs) + cfg = cfgparser.parse_cfg([]) assert cfg.timeout_seconds == 60 def test_dotpath_from_arg(tmpdir): - config_dirs = make_temp_dirs(tmpdir) - args = make_args(['--network.ip-addr=192.168.0.1']) - cfg = config.Config( - CONFIG_FNAME, override_args=args, config_dirs=config_dirs) + cfgparser = make_config_parser(make_temp_dirs(tmpdir)) + cfg = cfgparser.parse_cfg(['--network.ip-addr=192.168.0.1']) assert cfg.network.ip_addr == '192.168.0.1' def test_dotpath_from_file(tmpdir): - config_dirs = make_config(tmpdir) - args = make_args([]) - cfg = config.Config( - CONFIG_FNAME, override_args=args, config_dirs=config_dirs) + cfgparser = make_config_parser(make_config(tmpdir)) + cfg = cfgparser.parse_cfg([]) assert cfg.network.ip_addr == '10.0.0.1' -def test_arg_overrides_file(tmpdir): - config_dirs = make_config(tmpdir) - args = make_args(['--timeout-seconds=15']) - cfg = config.Config( - CONFIG_FNAME, override_args=args, config_dirs=config_dirs) - assert cfg.timeout_seconds == 15 - - -def test_dotpath_arg_overrides_file(tmpdir): - config_dirs = make_config(tmpdir) - args = make_args(['--network.ip-addr=192.168.0.1']) - cfg = config.Config( - CONFIG_FNAME, override_args=args, config_dirs=config_dirs) +def test_dotpath_args_overrides_file(tmpdir): + cfgparser = make_config_parser(make_config(tmpdir)) + cfg = cfgparser.parse_cfg(['--network.ip-addr=192.168.0.1']) assert cfg.network.ip_addr == '192.168.0.1' diff --git a/cursewords/cursewords.py b/cursewords/cursewords.py index ce6c0fe..1580571 100755 --- a/cursewords/cursewords.py +++ b/cursewords/cursewords.py @@ -1,6 +1,5 @@ #! /usr/bin/env python3 -import argparse import itertools import os import random @@ -768,10 +767,12 @@ def main(): with open(version_file) as f: version = f.read().strip() - parser = argparse.ArgumentParser( + cfgparser = config.ConfigParser( + CONFIG_FNAME, prog='cursewords', description="""A terminal-based crossword puzzle solving interface.""") + parser = cfgparser.get_argument_parser() parser.add_argument( 'filename', metavar='PUZfile', help="""path of puzzle file in the AcrossLite .puz format""") @@ -782,9 +783,8 @@ def main(): '--version', action='version', version=version) args = parser.parse_args() - cfg = config.Config(CONFIG_FNAME, override_args=args) filename = args.filename - downs_only = cfg.downs_only + downs_only = args.downs_only try: puzfile = puz.read(filename) From db991c4e9686ecbdf12aae519cd4d78b281b3208 Mon Sep 17 00:00:00 2001 From: Dan Sanderson Date: Fri, 23 Apr 2021 01:15:31 -0700 Subject: [PATCH 06/17] Test and repair config flags --- cursewords/config.py | 17 +++++++++++++---- cursewords/config_test.py | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/cursewords/config.py b/cursewords/config.py index 9984ceb..c34355b 100644 --- a/cursewords/config.py +++ b/cursewords/config.py @@ -180,7 +180,7 @@ def add_parameter( help=help) self._argparser.add_argument( '--no-' + name, - action='store_false', + action='store_true', required=False, help=help) else: @@ -228,9 +228,18 @@ def parse_cfg(self, args=None): for param in self._params: if param.is_flag: - if 'no_' + param.name in args_dict: - args_dict[param.name] = False - del args_dict['no_' + param.name] + flag_attr_name = param.name.replace('-', '_') + inverse_flag_attr_name = 'no_' + flag_attr_name + + if args_dict[inverse_flag_attr_name]: + # If --no-xxx, set value to False. + args_dict[flag_attr_name] = False + elif not args_dict[flag_attr_name]: + # If neither --no-xxx nor --xxx, delete the default + # False value. + del args_dict[flag_attr_name] + # Delete --no-xxx regardless. + del args_dict[inverse_flag_attr_name] namespace._merge(args_dict) diff --git a/cursewords/config_test.py b/cursewords/config_test.py index 002b4ce..a5f92b1 100644 --- a/cursewords/config_test.py +++ b/cursewords/config_test.py @@ -8,6 +8,8 @@ CONFIG_TEXT = """ name = "Mr. Chips" timeout_seconds = 60 +verbose_log = false +enable_feature = true [network] ip_addr = "10.0.0.1" @@ -42,6 +44,7 @@ def make_config_parser(config_dirs, config_fname=CONFIG_FNAME): cfgparser.add_parameter('timeout-seconds', type=int) cfgparser.add_parameter('network.ip-addr', type=str) cfgparser.add_parameter('verbose-log', type=bool) + cfgparser.add_parameter('enable-feature', type=bool) return cfgparser @@ -97,3 +100,24 @@ def test_dotpath_args_overrides_file(tmpdir): cfgparser = make_config_parser(make_config(tmpdir)) cfg = cfgparser.parse_cfg(['--network.ip-addr=192.168.0.1']) assert cfg.network.ip_addr == '192.168.0.1' + + +def test_bool_from_file(tmpdir): + cfgparser = make_config_parser(make_config(tmpdir)) + cfg = cfgparser.parse_cfg([]) + assert not cfg.verbose_log + assert cfg.enable_feature + + +def test_flag_positive(tmpdir): + cfgparser = make_config_parser(make_config(tmpdir)) + cfg = cfgparser.parse_cfg(['--verbose-log']) + assert cfg.verbose_log + assert cfg.enable_feature + + +def test_flag_negative(tmpdir): + cfgparser = make_config_parser(make_config(tmpdir)) + cfg = cfgparser.parse_cfg(['--no-enable-feature']) + assert not cfg.verbose_log + assert not cfg.enable_feature From 2713e03924d2976984f16e767cb4963457fe2c48 Mon Sep 17 00:00:00 2001 From: Dan Sanderson Date: Sat, 24 Apr 2021 01:41:24 -0700 Subject: [PATCH 07/17] A Twitch bot that twinkles guesses posted in chat; partial implementation of a !clue command --- cursewords.toml | 2 +- cursewords/cursewords.py | 47 +++++++++- cursewords/twitch.py | 197 +++++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + 4 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 cursewords/twitch.py diff --git a/cursewords.toml b/cursewords.toml index e0f8969..42c76ed 100644 --- a/cursewords.toml +++ b/cursewords.toml @@ -36,4 +36,4 @@ enable_clue = true # If enable_clue is true, the number of seconds a given user must wait between # clue requests. -clues_per_second_per_person = 10 +clue_cooldown_per_person = 10 diff --git a/cursewords/cursewords.py b/cursewords/cursewords.py index 1580571..dcb02c7 100755 --- a/cursewords/cursewords.py +++ b/cursewords/cursewords.py @@ -14,6 +14,7 @@ from . import chars from . import config +from . import twitch CONFIG_FNAME = 'cursewords.toml' @@ -770,18 +771,45 @@ def main(): cfgparser = config.ConfigParser( CONFIG_FNAME, prog='cursewords', - description="""A terminal-based crossword puzzle solving interface.""") + description='A terminal-based crossword puzzle solving interface.') + cfgparser.add_parameter( + 'twitch.nickname', + help='The Twitch bot account name') + cfgparser.add_parameter( + 'twitch.channel', + help='The Twitch channel to join') + cfgparser.add_parameter( + 'twitch.oauth_token', + help='The OAuth token to use to connect with this account') + cfgparser.add_parameter( + 'twitch.enable_guessing', + type=bool, + help='Enables reacting to guesses in the Twitch chat') + cfgparser.add_parameter( + 'twitch.enable_clue', + type=bool, + help='Enables the !clue command in the Twitch chat') + cfgparser.add_parameter( + 'twitch.clue_cooldown_per_person', + default=10, + type=int, + help='Seconds a Twitch chat user must wait between !clue commands') + cfgparser.add_parameter( + 'log_to_file', + type=bool, + help='Log error messages to a file named cursewords.log') parser = cfgparser.get_argument_parser() parser.add_argument( 'filename', metavar='PUZfile', - help="""path of puzzle file in the AcrossLite .puz format""") + help='Path of puzzle file in the AcrossLite .puz format') parser.add_argument( '--downs-only', action='store_true', - help="""displays only the down clues""") + help='Displays only the down clues') parser.add_argument( '--version', action='version', version=version) + cfg = cfgparser.parse_cfg() args = parser.parse_args() filename = args.filename downs_only = args.downs_only @@ -909,6 +937,17 @@ def main(): is_running=True, active=bool(int(grid.timer_active))) timer.start() + twitchbot = twitch.TwitchBot( + grid, + nickname=cfg.twitch.nickname, + channel=cfg.twitch.channel, + oauth_token=cfg.twitch.oauth_token, + enable_guessing=cfg.twitch.enable_guessing, + enable_clue=cfg.twitch.enable_clue, + clue_cooldown_per_person=cfg.twitch.clue_cooldown_per_person, + log_to_file=cfg.log_to_file) + twitchbot.start() + info_location = {'x': grid_x, 'y': grid_y + 2 * grid.row_count + 2} with term.raw(), term.hidden_cursor(): @@ -1185,6 +1224,8 @@ def main(): cursor.retreat_perpendicular() print(term.exit_fullscreen()) + twitchbot.running = False + twitchbot.join() if __name__ == '__main__': diff --git a/cursewords/twitch.py b/cursewords/twitch.py new file mode 100644 index 0000000..ad5c6f1 --- /dev/null +++ b/cursewords/twitch.py @@ -0,0 +1,197 @@ +import asyncio +import logging +import os +import re +import threading +import time +import websockets + + +TWITCH_URI = 'wss://irc-ws.chat.twitch.tv:443' +MESSAGE_COOLDOWN_SECS = 1 + + +class TwitchBot(threading.Thread): + def __init__( + self, + grid, + nickname, + channel, + oauth_token, + enable_guessing, + enable_clue, + clue_cooldown_per_person, + log_to_file): + self.grid = grid + self.nickname = nickname + self.channel = channel + self.oauth_token = oauth_token + self.enable_guessing = enable_guessing + self.enable_clue = enable_clue + self.clue_cooldown_per_person = clue_cooldown_per_person + + super().__init__(daemon=True) + + self.websocket = None + self.running = None + + self.message_handlers = [ + (re.compile(r'PING (.*)\r\n'), self.handle_ping), + (re.compile(r'.* PRIVMSG #(\S+) :(.*)\r\n'), self.handle_chat_msg), + (re.compile(r'.* WHISPER (\S+) :(.*)\r\n'), self.handle_whisper), + (re.compile(r'.* JOIN #(.*)\r\n'), self.handle_join), + (re.compile(r'.* NOTICE #(\S*) (.*)\r\n'), self.handle_notice) + ] + + self._outgoing_message_queue = [] + self._outgoing_message_last_time = time.monotonic() + + self.successful_guessing_users = set() + + if log_to_file: + logging.basicConfig( + filename='cursewords.log', + encoding='utf-8', + level=logging.INFO) + else: + logging.basicConfig(stream=os.devnull) + + def run(self): + # If no Twitch features are enabled, don't bother connecting to Twitch. + if (not self.enable_guessing and + not self.enable_clue): + return + + self.running = True + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(self.connect()) + + async def join_channel(self): + assert self.websocket + await self.websocket.send(f'PASS {self.oauth_token}\n') + await self.websocket.send(f'NICK {self.nickname}\n') + await self.websocket.send(f'JOIN #{self.channel}\n') + await self.websocket.send( + 'CAP REQ :twitch.tv/tags twitch.tv/commands\n') + + async def _post_next_message(self): + assert self.websocket + txt = self._outgoing_message_queue.pop() + await self.websocket.send(f'PRIVMSG #{self.channel} :{txt}\n') + self._outgoing_message_last_time = time.monotonic() + + async def post_message(self, txt): + self._outgoing_message_queue.insert(0, txt) + + async def send_whisper(self, user, msg): + assert self.websocket + # Note: Untested! This probably requires that a bot be verified. + # Without a verified bot account, this returns an error. + await self.post_message(f'.w {user} {msg}') + + async def connect(self): + async with websockets.connect(TWITCH_URI, ssl=True) as websocket: + logging.info( + f'TwitchBot connected to #{self.channel} as {self.nickname}') + self.websocket = websocket + await self.join_channel() + await self.event_loop() + + async def event_loop(self): + try: + await self.startup() + + while self.running: + # Listen for incoming messages, timing out once a second to + # process outgoing messages. + try: + resp = await asyncio.wait_for( + self.websocket.recv(), timeout=1) + for (pat, func) in self.message_handlers: + m = pat.search(resp) + if m: + await func(*m.groups()) + except asyncio.exceptions.TimeoutError: + pass + + # Post an outgoing message at most every MESSAGE_COOLDOWN_SECS. + # (Twitch does its own throttling of bots that drops messages.) + if (self._outgoing_message_queue and + (time.monotonic() - self._outgoing_message_last_time > + MESSAGE_COOLDOWN_SECS)): + await self._post_next_message() + + await self.shutdown() + + except websockets.exceptions.WebSocketException: + self.grid.send_notification( + 'Twitch connection lost, sorry') + + async def handle_ping(self, unused_domain): + await self.websocket.send('PONG :tmi.twitch.tv\n') + + async def startup(self): + await self.post_message('Hello I\'m the bot!') + + async def handle_join(self, unused_channel_name): + self.grid.send_notification( + f'Connected to Twitch #{self.channel} as {self.nickname}') + + async def handle_notice(self, unused_channel_name, msg): + logging.info(f'Twitch NOTICE: {msg}') + + async def handle_chat_msg(self, user, msg): + cmd_m = re.match(r'\s*!(\w+)(\s+.*)?', msg) + cmd = (None, None) + if cmd_m: + cmd = (cmd_m.group(1).lower(), cmd_m.group(2)) + + if self.enable_clue and cmd[0] == 'clue': + await self.do_clue(user, cmd[1]) + + if self.enable_guessing and cmd[0] is None: + await self.do_guesses(user, msg) + + async def do_clue(self, user, msg): + m = re.match(r'\s*(?P\d+)\s*(?P[aAdD])', msg) + if not m: + m = re.match(r'\s*(?P[aAdD])\D*\s*(?P\d+)', msg) + if m: + num = m.group('num') + cluedir = m.group('dir').upper() + # TODO: actual clue lookup (potentially fail) + # TODO: honor self.clue_cooldown_per_person + await self.post_message(f'{user} {num}{cluedir} (clue goes here)') + else: + await self.post_message( + f'{user} I didn\'t understand. Try something like: !clue 22d') + + def _itemize_guesses(self, msg): + phrases = re.split(r'[^\w\s]+', msg) + for phrase in phrases: + words = re.split(r'\W+', phrase.strip()) + for start_i in range(len(words)): + for end_i in range(start_i + 1, len(words) + 1): + guess = (''.join(words[start_i:end_i]) + .lower()) + if guess: + yield guess + + async def do_guesses(self, user, msg): + for guess in self._itemize_guesses(msg): + if guess in self.grid.word_index: + result = self.grid.twinkle_unsolved_word(guess) + if result: + self.successful_guessing_users.add(user) + + async def handle_whisper(self, user, msg): + # We have no whisper-based features. + pass + + async def shutdown(self): + if self.enable_guessing and self.successful_guessing_users: + print('\n\n\n\n\n### Thanks to these successful solvers:\n') + for user in sorted(self.successful_guessing_users): + print(' ' + user) + print('\n\n') diff --git a/requirements.txt b/requirements.txt index dd65ead..56331d5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ blessed==1.15.0 puzpy==0.2.4 pytest +websockets \ No newline at end of file From 13bcbd8ada78728bf23f5981764e84422603aeac Mon Sep 17 00:00:00 2001 From: Dan Sanderson Date: Sat, 24 Apr 2021 12:52:39 -0700 Subject: [PATCH 08/17] Working !clue command --- cursewords/cursewords.py | 23 +++++++++++++++-------- cursewords/twitch.py | 28 ++++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/cursewords/cursewords.py b/cursewords/cursewords.py index dcb02c7..3e4ba3b 100755 --- a/cursewords/cursewords.py +++ b/cursewords/cursewords.py @@ -2,7 +2,6 @@ import itertools import os -import random import sys import time import textwrap @@ -482,6 +481,20 @@ def twinkle_unsolved_word(self, txt, duration=None): return True return False + def get_clue_by_number(self, num, is_across=True): + if is_across: + word_list = self.across_words + clue_list = self.across_clues + else: + word_list = self.down_words_grouped + clue_list = self.down_clues + + for i, word in enumerate(word_list): + if self.cells[word[0]].number == num: + return clue_list[i] + + return None + class Cursor: def __init__(self, position, direction, grid): @@ -1072,13 +1085,6 @@ def main(): else: grid.send_notification("Reset command canceled.") - elif keypress == chr(1): - # Secret twinkle demo: ^A twinkles (and spoils) a random word. - # Will spoil but not twinkle if the word is solved. - word_txt = random.choice(list(grid.word_index.keys())) - grid.send_notification("Twinkled " + word_txt) - grid.twinkle_unsolved_word(word_txt) - # If the puzzle is paused, skip all the rest of the logic elif puzzle_paused: continue @@ -1224,6 +1230,7 @@ def main(): cursor.retreat_perpendicular() print(term.exit_fullscreen()) + timer.pause() twitchbot.running = False twitchbot.join() diff --git a/cursewords/twitch.py b/cursewords/twitch.py index ad5c6f1..5b46895 100644 --- a/cursewords/twitch.py +++ b/cursewords/twitch.py @@ -7,7 +7,11 @@ import websockets +# Twitch bot secure web socket URL TWITCH_URI = 'wss://irc-ws.chat.twitch.tv:443' + +# Minimum number of seconds between posts to the chat, to avoid Twitch +# dropping messages MESSAGE_COOLDOWN_SECS = 1 @@ -132,6 +136,7 @@ async def handle_ping(self, unused_domain): await self.websocket.send('PONG :tmi.twitch.tv\n') async def startup(self): + # TODO: better start-up message announcing enabled features await self.post_message('Hello I\'m the bot!') async def handle_join(self, unused_channel_name): @@ -160,14 +165,33 @@ async def do_clue(self, user, msg): if m: num = m.group('num') cluedir = m.group('dir').upper() - # TODO: actual clue lookup (potentially fail) + clue = self.grid.get_clue_by_number( + int(num), is_across=(cluedir == 'A')) + if clue: + await self.post_message(f'{user} {num}{cluedir}: {clue}') + else: + await self.post_message(f'{user} No clue for {num}{cluedir}') + # TODO: honor self.clue_cooldown_per_person - await self.post_message(f'{user} {num}{cluedir} (clue goes here)') else: await self.post_message( f'{user} I didn\'t understand. Try something like: !clue 22d') def _itemize_guesses(self, msg): + # Search a chat message for guesses. + # + # A guess is one or more words that may or may not be separated by + # spaces, case ignored. A guess begins and ends at a word boundary, and + # does not span across punctuation. + # + # For example, if someone posts: + # DOUBLE RAINBOW, maybe? + # + # These words are considered guesses: + # double + # doublerainbow + # rainbow + # maybe phrases = re.split(r'[^\w\s]+', msg) for phrase in phrases: words = re.split(r'\W+', phrase.strip()) From 50baac7f2e1110e0292505aa2b8cb0f26c1ce7d0 Mon Sep 17 00:00:00 2001 From: Dan Sanderson Date: Sat, 24 Apr 2021 14:21:07 -0700 Subject: [PATCH 09/17] Clue cooldown; docs; polish; Twitch master switch --- README.md | 83 +++++++++++++++++++++++++++++++++++++++- cursewords.toml | 18 ++++++--- cursewords/cursewords.py | 20 ++++++++-- cursewords/twitch.py | 59 ++++++++++++++++++++-------- 4 files changed, 153 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 109c9f8..c2196c3 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,12 @@ * a pausable puzzle timer * a puzzle completeness notification -It is currently under active development, and should not be considered fully "released." That said, it is stable and suitable for everyday use. +`cursewords` also includes features for solving puzzles on a Twitch stream with audience interactivity: + +* animated rewards for when a user guesses a word +* a chatbot command to request the clue of a given word be posted to the chat + +Cursewords is currently under active development, and should not be considered fully "released." That said, it is stable and suitable for everyday use. ## Installation @@ -34,3 +39,79 @@ If you've used a program to solve crossword puzzles, navigation should be pretty If you need some help, `ctrl+c` will check the current square, word, or entire puzzle for errors, and `ctrl+r` will reveal answers (subject to the same scoping options). To clear all entries on the puzzle, use `ctrl+x`, and to reset the puzzle to its original state (resetting the timer and removing any stored information about hints and corrections, use `ctrl+z`. To open a puzzle in `downs-only` mode, where only the down clues are visible, use the `--downs-only` flag when opening the file on the command line. + + +## Configuration + +You can configure the behavior of `cursewords` using command-line options or a configuration file. The configuration file must be named `cursewords.toml`, and can reside in either the directory where you run the `cursewords` command or in `$HOME/.config/`. See the `cursewords.toml` file included with this distribution for example settings. + +Settings settable by the configuration file can be overridden on the command line, like so: + +``` +cursewords --log-to-file --twitch.channel=PuzzleCity todaysnyt.puz +``` + +True/false settings, like `log-to-file`, are set as flags. `--log-to-file` sets the `log_to_file` option to `true`. To override a flag to `false`, prepend the name with `no-`, as in `--no-log-to-file`. + +Settings in TOML sections use the section name followed by a dot, as in `--twitch.channel=...`. + + +## Twitch integration + +`cursewords` includes features for live-streaming puzzle solving on Twitch. You can connect `cursewords` to the channel's chat room using a bot account or your own account. Features include: + +* **Guessing** + * When someone posts a message to the chat containing a solution to an unsolved clue, the board will highlight the squares. This signals to the solver that someone has made a correct guess, and rewards viewers for guessing. + * Any word or phrase in a chat message counts as a guess. +* **Clues** + * Anyone can request one of the clues from the board with the `!clue` command, like so: `!clue 22d` Cursewords posts the requested clue to the room. + +These features can be enabled or disabled separately with configuration. + +To enable Twitch integration: + +1. Create a Twitch.tv account for the bot, or use an existing account that you control. +2. In your browser, visit [https://twitchapps.com/tmi/] and sign in with the bot's account and password. This generates an OAuth token, a string of letters and numbers that begins `oauth:...` Copy the OAuth token to your clipboard. +3. Copy the `cursewords.toml` file from this distribution to the desired location, and edit it to set the parameters under `[twitch]`: + * `enable = true` : enables the Twitch features + * `nickname = "..."` : the bot's Twitch account name + * `channel = "..."` : the name of the Twitch channel (without the `#`) + * `oauth_token = "oauth:..."` : the OAuth token + * `enable_guessing = true` : enables the guessing feature + * `enable_clue = true` : enables the clue command + +When you run `cursewords`, it will attempt to connect to the Twitch chat room. You should see the bot account arrive in the room and announce its presence. + +### About OAuth tokens + +The OAuth token is a temporary password that `cursewords` uses to connect to Twitch with the given account. It is *not* the account password. The OAuth token allows you to grant access to `cursewords` without telling it the real account password. + +Keep the OAuth token a secret! It behaves like a real password for accessing the Twitch API. If you no longer want an app to be able to connect using the token, you can revoke access. Sign in to Twitch.tv with the bot account, go to **Settings**, then select the **Connections** tab. Locate the "Twitch Developer" connection then click **Disconnect**. + +You can generate a new OAuth token at any time by visiting: [https://twitchapps.com/tmi/] + +(There are nicer ways to [authenticate a bot on Twitch](https://dev.twitch.tv/docs/authentication), but Cursewords does not yet support them. Contributions welcome!) + +### Troubleshooting + +**The bot account does not join the room when I run `cursewords`.** +Verify that configuration parameters are set correctly, and that the configuration file is named `cursewords.toml` and is in either the current working directory or in: `$HOME/.config/` + +Occasionally `cursewords` will simply fail to connect to Twitch. Quit (Ctrl-Q) and re-run `cursewords` to try again. + +**The bot does not animate a correct guess posted by a user.** +Check that the `enable_guessing` configuration parameter is set to `true` (`--twitch.enable-guessing` on the command line). The bot will invite users to post guesses to the chat when it joins if this feature is enabled correctly. + +Check that the word is not already solved on the board. The guessing feature will only animate squares of a correct guess if the word is unsolved, incompletely solved, or solved incorrectly. + +A guess can be run together (`DOUBLERAINBOW`) or separated by spaces (`DOUBLE RAINBOW`), and case doesn't matter (`double raIN bOW`). It must start and end at a "word boundary:" `corporeous`, `oreodontoid`, or `choreo` will not match `oreo`. Punctuation cannot appear in the middle of the guess: `o-r-e-o` will not match. + +**The bot never replies to the `!clue` command.** +Check that the `enable_clue` configuration parameter is set to `true` (`--twitch.enable-clue` on the command line). The bot will invite users to use the `!clue` command when it joins if this feature is enabled correctly. + +Check that `!clue` (an exclamation point followed by the word "clue") is the first word in the chat message. + +**The bot replies to the `!clue` command sometimes, but not always.** +To prevent a user from cluttering the chat with clues, each user is restricted to one `!clue` per period of time. This period is configurable using the `clue_cooldown_per_person` parameter (`--twitch.clue-cooldown-per-person` on the command line). The bot will ignore clue commands from a user within this time. + +If two users request the same clue in succession, Twitch will prevent the bot from posting the same message twice in a row. There is no way to prevent this behavior for a (non-verified) bot. diff --git a/cursewords.toml b/cursewords.toml index 42c76ed..508cce2 100644 --- a/cursewords.toml +++ b/cursewords.toml @@ -6,7 +6,19 @@ # Most options can be overridden on the command line, like so: # cursewords --twitch.nickname=mybot --twitch.enable-clue=false puzfile.puz +# Log messages to a file named cursewords.log. +log_to_file = false + [twitch] +# Set this to "true" to enable Twitch support. +enable = false + +# Viewers can guess words in the puzzle and cause things to happen. +enable_guessing = true + +# Viewers can type "!clue 22d" to have the bot post a clue to the chat room. +enable_clue = true + # The Twitch account that Cursewords should use to connect. nickname = "..." @@ -28,12 +40,6 @@ channel = "..." # Developer" connection then click Disconnect. oauth_token = "oauth:..." -# Viewers can guess words in the puzzle and cause things to happen. -enable_guessing = true - -# Viewers can type "!clue 22d" to have the bot post a clue to the chat room. -enable_clue = true - # If enable_clue is true, the number of seconds a given user must wait between # clue requests. clue_cooldown_per_person = 10 diff --git a/cursewords/cursewords.py b/cursewords/cursewords.py index 3e4ba3b..cfa162c 100755 --- a/cursewords/cursewords.py +++ b/cursewords/cursewords.py @@ -1,6 +1,7 @@ #! /usr/bin/env python3 import itertools +import logging import os import sys import time @@ -17,6 +18,7 @@ CONFIG_FNAME = 'cursewords.toml' +LOG_FNAME = 'cursewords.log' class Cell: @@ -785,6 +787,10 @@ def main(): CONFIG_FNAME, prog='cursewords', description='A terminal-based crossword puzzle solving interface.') + cfgparser.add_parameter( + 'twitch.enable', + type=bool, + help='Enables Twitch integration') cfgparser.add_parameter( 'twitch.nickname', help='The Twitch bot account name') @@ -810,7 +816,7 @@ def main(): cfgparser.add_parameter( 'log_to_file', type=bool, - help='Log error messages to a file named cursewords.log') + help='Log messages to a file named cursewords.log') parser = cfgparser.get_argument_parser() parser.add_argument( @@ -827,6 +833,14 @@ def main(): filename = args.filename downs_only = args.downs_only + if cfg.log_to_file: + logging.basicConfig( + filename=LOG_FNAME, + encoding='utf-8', + level=logging.INFO) + else: + logging.basicConfig(stream=os.devnull) + try: puzfile = puz.read(filename) except BaseException: @@ -952,13 +966,13 @@ def main(): twitchbot = twitch.TwitchBot( grid, + enable=cfg.twitch.enable, nickname=cfg.twitch.nickname, channel=cfg.twitch.channel, oauth_token=cfg.twitch.oauth_token, enable_guessing=cfg.twitch.enable_guessing, enable_clue=cfg.twitch.enable_clue, - clue_cooldown_per_person=cfg.twitch.clue_cooldown_per_person, - log_to_file=cfg.log_to_file) + clue_cooldown_per_person=cfg.twitch.clue_cooldown_per_person) twitchbot.start() info_location = {'x': grid_x, 'y': grid_y + 2 * grid.row_count + 2} diff --git a/cursewords/twitch.py b/cursewords/twitch.py index 5b46895..a37de93 100644 --- a/cursewords/twitch.py +++ b/cursewords/twitch.py @@ -1,6 +1,6 @@ import asyncio import logging -import os +import random import re import threading import time @@ -15,18 +15,30 @@ MESSAGE_COOLDOWN_SECS = 1 +HELLOS = [ + 'Hello!', 'Bonjour!', '¡Hola!', 'Nǐ hǎo.', 'Konnichiwa!', 'Hallo!', + 'Namaste!', 'Shalom!', 'Greetings, puzzle people!' +] + +GOODBYES = [ + 'gg!', 'Well done!', 'And that\'s the game!', 'Let\'s do another one!', + 'Fantastic work!', 'Oreos for all!', 'Mazel tov!' +] + + class TwitchBot(threading.Thread): def __init__( self, grid, + enable, nickname, channel, oauth_token, enable_guessing, enable_clue, - clue_cooldown_per_person, - log_to_file): + clue_cooldown_per_person): self.grid = grid + self.enable = enable self.nickname = nickname self.channel = channel self.oauth_token = oauth_token @@ -52,18 +64,13 @@ def __init__( self.successful_guessing_users = set() - if log_to_file: - logging.basicConfig( - filename='cursewords.log', - encoding='utf-8', - level=logging.INFO) - else: - logging.basicConfig(stream=os.devnull) + self.last_clue_timestamp_per_user = {} def run(self): # If no Twitch features are enabled, don't bother connecting to Twitch. - if (not self.enable_guessing and - not self.enable_clue): + if (not self.enable or + (not self.enable_guessing and + not self.enable_clue)): return self.running = True @@ -136,8 +143,18 @@ async def handle_ping(self, unused_domain): await self.websocket.send('PONG :tmi.twitch.tv\n') async def startup(self): - # TODO: better start-up message announcing enabled features - await self.post_message('Hello I\'m the bot!') + hello = random.choice(HELLOS) + if self.enable_clue: + clue_msg = ( + ' To request a clue, say !clue followed by a clue name, ' + 'like: !clue 22d') + else: + clue_msg = '' + if self.enable_guessing: + guessing_msg = ' Post your guesses to the chat!' + else: + guessing_msg = '' + await self.post_message(f'{hello}{guessing_msg}{clue_msg}') async def handle_join(self, unused_channel_name): self.grid.send_notification( @@ -159,6 +176,14 @@ async def handle_chat_msg(self, user, msg): await self.do_guesses(user, msg) async def do_clue(self, user, msg): + if (self.clue_cooldown_per_person and + user in self.last_clue_timestamp_per_user and + (time.monotonic() - self.last_clue_timestamp_per_user[user] + < self.clue_cooldown_per_person)): + return + + self.last_clue_timestamp_per_user[user] = time.monotonic() + m = re.match(r'\s*(?P\d+)\s*(?P[aAdD])', msg) if not m: m = re.match(r'\s*(?P[aAdD])\D*\s*(?P\d+)', msg) @@ -171,8 +196,6 @@ async def do_clue(self, user, msg): await self.post_message(f'{user} {num}{cluedir}: {clue}') else: await self.post_message(f'{user} No clue for {num}{cluedir}') - - # TODO: honor self.clue_cooldown_per_person else: await self.post_message( f'{user} I didn\'t understand. Try something like: !clue 22d') @@ -214,8 +237,10 @@ async def handle_whisper(self, user, msg): pass async def shutdown(self): + goodbye = random.choice(GOODBYES) + await self.post_message(f'{goodbye}') if self.enable_guessing and self.successful_guessing_users: - print('\n\n\n\n\n### Thanks to these successful solvers:\n') + print('\n\n\n### Thanks to these successful solvers:\n') for user in sorted(self.successful_guessing_users): print(' ' + user) print('\n\n') From ef779a92d662ef47b3ae0374a9a7c450fb4307e8 Mon Sep 17 00:00:00 2001 From: Dan Sanderson Date: Sat, 24 Apr 2021 14:32:31 -0700 Subject: [PATCH 10/17] Repair goodbye message --- README.md | 4 ++-- cursewords/twitch.py | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c2196c3..9027689 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Settings in TOML sections use the section name followed by a dot, as in `--twitc ## Twitch integration -`cursewords` includes features for live-streaming puzzle solving on Twitch. You can connect `cursewords` to the channel's chat room using a bot account or your own account. Features include: +`cursewords` includes features for live-streaming puzzle solving on Twitch with audience interactivity. You can connect `cursewords` to the channel's chat room using a bot account or your own account. Features include: * **Guessing** * When someone posts a message to the chat containing a solution to an unsolved clue, the board will highlight the squares. This signals to the solver that someone has made a correct guess, and rewards viewers for guessing. @@ -114,4 +114,4 @@ Check that `!clue` (an exclamation point followed by the word "clue") is the fir **The bot replies to the `!clue` command sometimes, but not always.** To prevent a user from cluttering the chat with clues, each user is restricted to one `!clue` per period of time. This period is configurable using the `clue_cooldown_per_person` parameter (`--twitch.clue-cooldown-per-person` on the command line). The bot will ignore clue commands from a user within this time. -If two users request the same clue in succession, Twitch will prevent the bot from posting the same message twice in a row. There is no way to prevent this behavior for a (non-verified) bot. +If a single user requests the same clue twice in succession and the bot hasn't spoken since the previous time, Twitch will prevent the bot from posting the same message twice in a row. diff --git a/cursewords/twitch.py b/cursewords/twitch.py index a37de93..b55ca1f 100644 --- a/cursewords/twitch.py +++ b/cursewords/twitch.py @@ -14,7 +14,6 @@ # dropping messages MESSAGE_COOLDOWN_SECS = 1 - HELLOS = [ 'Hello!', 'Bonjour!', '¡Hola!', 'Nǐ hǎo.', 'Konnichiwa!', 'Hallo!', 'Namaste!', 'Shalom!', 'Greetings, puzzle people!' @@ -238,9 +237,12 @@ async def handle_whisper(self, user, msg): async def shutdown(self): goodbye = random.choice(GOODBYES) + self._outgoing_message_queue = [] await self.post_message(f'{goodbye}') + await self._post_next_message() + if self.enable_guessing and self.successful_guessing_users: - print('\n\n\n### Thanks to these successful solvers:\n') + print('\n### Thanks to these successful solvers:\n') for user in sorted(self.successful_guessing_users): print(' ' + user) - print('\n\n') + print('\n') From 05b76819132aea674794e58054eb80254b63f54e Mon Sep 17 00:00:00 2001 From: Dan Sanderson Date: Sat, 24 Apr 2021 14:35:22 -0700 Subject: [PATCH 11/17] Doc polish --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9027689..1cae0d6 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ * animated rewards for when a user guesses a word * a chatbot command to request the clue of a given word be posted to the chat -Cursewords is currently under active development, and should not be considered fully "released." That said, it is stable and suitable for everyday use. +`cursewords` is currently under active development, and should not be considered fully "released." That said, it is stable and suitable for everyday use. ## Installation @@ -64,7 +64,7 @@ Settings in TOML sections use the section name followed by a dot, as in `--twitc * When someone posts a message to the chat containing a solution to an unsolved clue, the board will highlight the squares. This signals to the solver that someone has made a correct guess, and rewards viewers for guessing. * Any word or phrase in a chat message counts as a guess. * **Clues** - * Anyone can request one of the clues from the board with the `!clue` command, like so: `!clue 22d` Cursewords posts the requested clue to the room. + * Anyone can request one of the clues from the board with the `!clue` command, like so: `!clue 22d` The requested clue is posted to the room. These features can be enabled or disabled separately with configuration. @@ -90,7 +90,7 @@ Keep the OAuth token a secret! It behaves like a real password for accessing the You can generate a new OAuth token at any time by visiting: [https://twitchapps.com/tmi/] -(There are nicer ways to [authenticate a bot on Twitch](https://dev.twitch.tv/docs/authentication), but Cursewords does not yet support them. Contributions welcome!) +(There are nicer ways to [authenticate a bot on Twitch](https://dev.twitch.tv/docs/authentication), but `cursewords` does not yet support them. Contributions welcome!) ### Troubleshooting From 25cbe77e63cf3af64b660ebfb3fccf63e76cc11d Mon Sep 17 00:00:00 2001 From: Dan Sanderson Date: Sat, 24 Apr 2021 14:36:10 -0700 Subject: [PATCH 12/17] More doc polish --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1cae0d6..208f9b1 100644 --- a/README.md +++ b/README.md @@ -94,24 +94,24 @@ You can generate a new OAuth token at any time by visiting: [https://twitchapps. ### Troubleshooting -**The bot account does not join the room when I run `cursewords`.** +**The bot account does not join the room when I run `cursewords`.**
Verify that configuration parameters are set correctly, and that the configuration file is named `cursewords.toml` and is in either the current working directory or in: `$HOME/.config/` Occasionally `cursewords` will simply fail to connect to Twitch. Quit (Ctrl-Q) and re-run `cursewords` to try again. -**The bot does not animate a correct guess posted by a user.** +**The bot does not animate a correct guess posted by a user.**
Check that the `enable_guessing` configuration parameter is set to `true` (`--twitch.enable-guessing` on the command line). The bot will invite users to post guesses to the chat when it joins if this feature is enabled correctly. Check that the word is not already solved on the board. The guessing feature will only animate squares of a correct guess if the word is unsolved, incompletely solved, or solved incorrectly. A guess can be run together (`DOUBLERAINBOW`) or separated by spaces (`DOUBLE RAINBOW`), and case doesn't matter (`double raIN bOW`). It must start and end at a "word boundary:" `corporeous`, `oreodontoid`, or `choreo` will not match `oreo`. Punctuation cannot appear in the middle of the guess: `o-r-e-o` will not match. -**The bot never replies to the `!clue` command.** +**The bot never replies to the `!clue` command.**
Check that the `enable_clue` configuration parameter is set to `true` (`--twitch.enable-clue` on the command line). The bot will invite users to use the `!clue` command when it joins if this feature is enabled correctly. Check that `!clue` (an exclamation point followed by the word "clue") is the first word in the chat message. -**The bot replies to the `!clue` command sometimes, but not always.** +**The bot replies to the `!clue` command sometimes, but not always.**
To prevent a user from cluttering the chat with clues, each user is restricted to one `!clue` per period of time. This period is configurable using the `clue_cooldown_per_person` parameter (`--twitch.clue-cooldown-per-person` on the command line). The bot will ignore clue commands from a user within this time. If a single user requests the same clue twice in succession and the bot hasn't spoken since the previous time, Twitch will prevent the bot from posting the same message twice in a row. From a0d3a8bf5f0e656074a67bfc67f0c4c42c5d0d84 Mon Sep 17 00:00:00 2001 From: Dan Sanderson Date: Sat, 24 Apr 2021 14:50:25 -0700 Subject: [PATCH 13/17] Doc polish --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 208f9b1..d306250 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ These features can be enabled or disabled separately with configuration. To enable Twitch integration: 1. Create a Twitch.tv account for the bot, or use an existing account that you control. -2. In your browser, visit [https://twitchapps.com/tmi/] and sign in with the bot's account and password. This generates an OAuth token, a string of letters and numbers that begins `oauth:...` Copy the OAuth token to your clipboard. +2. In your browser, visit https://twitchapps.com/tmi/ and sign in with the bot's account and password. This generates an OAuth token, a string of letters and numbers that begins `oauth:...` Copy the OAuth token to your clipboard. 3. Copy the `cursewords.toml` file from this distribution to the desired location, and edit it to set the parameters under `[twitch]`: * `enable = true` : enables the Twitch features * `nickname = "..."` : the bot's Twitch account name @@ -88,7 +88,7 @@ The OAuth token is a temporary password that `cursewords` uses to connect to Twi Keep the OAuth token a secret! It behaves like a real password for accessing the Twitch API. If you no longer want an app to be able to connect using the token, you can revoke access. Sign in to Twitch.tv with the bot account, go to **Settings**, then select the **Connections** tab. Locate the "Twitch Developer" connection then click **Disconnect**. -You can generate a new OAuth token at any time by visiting: [https://twitchapps.com/tmi/] +You can generate a new OAuth token at any time by visiting: https://twitchapps.com/tmi/ (There are nicer ways to [authenticate a bot on Twitch](https://dev.twitch.tv/docs/authentication), but `cursewords` does not yet support them. Contributions welcome!) From 4863deae8b6bef4ec0608f50b7845af3af77f098 Mon Sep 17 00:00:00 2001 From: Dan Sanderson Date: Sat, 24 Apr 2021 14:54:42 -0700 Subject: [PATCH 14/17] Reset clue cooldown on error --- cursewords/twitch.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cursewords/twitch.py b/cursewords/twitch.py index b55ca1f..b71605f 100644 --- a/cursewords/twitch.py +++ b/cursewords/twitch.py @@ -195,6 +195,9 @@ async def do_clue(self, user, msg): await self.post_message(f'{user} {num}{cluedir}: {clue}') else: await self.post_message(f'{user} No clue for {num}{cluedir}') + # Allow an immediate repost after an error + self.last_clue_timestamp_per_user[user] -= \ + self.clue_cooldown_per_person else: await self.post_message( f'{user} I didn\'t understand. Try something like: !clue 22d') From ccb9fa2e1d9d0e73c15a679482974bc8847b4cb5 Mon Sep 17 00:00:00 2001 From: Dan Sanderson Date: Sun, 25 Apr 2021 00:34:48 -0700 Subject: [PATCH 15/17] Remove erroneously committed DS_Store (a macOS thing) --- .DS_Store | Bin 6148 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 11c781f37a48385ca228112f58c22ec2fb850329..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~L2AQ53`M_EF9O+k+2w3{fZSjR$q90SkeWcD;6>8?9KBx}ZR&PiO!xxnjWiav z|H5Mdu*2812Sxx}x)X067G}%`O!&YZkK1&Azs~cg7ipUgcuF6!*w1Z23P=GdAO)m= z6j+f0d5mv&D|#k9iWHCn>rlYI4~6cm$<`U44u%*3$bscBu49%Uix(Ne@ysjo&XK#n)@L_pp^C^aAy&YDV(5wa&q<|DyDDcwr(a--A{jd3d(V|oe zNP#C)z=q@bc;HLr+4}4Cyne~5uN$3=%Nc(B1TgWV_>~^U{o)I Date: Sun, 25 Apr 2021 00:35:02 -0700 Subject: [PATCH 16/17] gitignore .DS_Store --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 92ca369..adaea74 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/* build/* *.egg-info .vscode +.DS_Store From 112a0b685be86fc9b20df77af7bce258cfba4279 Mon Sep 17 00:00:00 2001 From: Dan Sanderson Date: Sun, 25 Apr 2021 14:03:46 -0700 Subject: [PATCH 17/17] Repair message citation --- cursewords/twitch.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cursewords/twitch.py b/cursewords/twitch.py index b71605f..36411cd 100644 --- a/cursewords/twitch.py +++ b/cursewords/twitch.py @@ -52,7 +52,8 @@ def __init__( self.message_handlers = [ (re.compile(r'PING (.*)\r\n'), self.handle_ping), - (re.compile(r'.* PRIVMSG #(\S+) :(.*)\r\n'), self.handle_chat_msg), + (re.compile(r'.*:(\w+)!\S* PRIVMSG #\S+ :(.*)\r\n'), + self.handle_chat_msg), (re.compile(r'.* WHISPER (\S+) :(.*)\r\n'), self.handle_whisper), (re.compile(r'.* JOIN #(.*)\r\n'), self.handle_join), (re.compile(r'.* NOTICE #(\S*) (.*)\r\n'), self.handle_notice) @@ -121,6 +122,7 @@ async def event_loop(self): for (pat, func) in self.message_handlers: m = pat.search(resp) if m: + logging.info('Matched message: ' + resp) await func(*m.groups()) except asyncio.exceptions.TimeoutError: pass