From 2f093e47a87664fbd92c09910926c773e1895fbb Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Tue, 9 Feb 2016 00:43:41 -0800 Subject: [PATCH 1/8] Added binding convention to the config file. --- rtv/config.py | 47 +++++++++++++++++++++++++------ rtv/rtv.cfg | 67 +++++++++++++++++++++++++++++++++++++++++++- tests/test_config.py | 6 ++++ 3 files changed, 111 insertions(+), 9 deletions(-) diff --git a/rtv/config.py b/rtv/config.py index 81cf04b..84c1182 100644 --- a/rtv/config.py +++ b/rtv/config.py @@ -1,10 +1,13 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +import re import os +import curses import codecs import shutil import argparse +from curses import ascii from functools import partial import six @@ -195,12 +198,12 @@ class Config(object): return cls._parse_rtv_file(config) - @staticmethod - def _parse_rtv_file(config): + @classmethod + def _parse_rtv_file(cls, config): - out = {} + rtv = {} if config.has_section('rtv'): - out = dict(config.items('rtv')) + rtv = dict(config.items('rtv')) params = { 'ascii': partial(config.getboolean, 'rtv'), @@ -208,13 +211,41 @@ class Config(object): 'persistent': partial(config.getboolean, 'rtv'), 'history_size': partial(config.getint, 'rtv'), 'oauth_redirect_port': partial(config.getint, 'rtv'), - 'oauth_scope': lambda x: out[x].split(',') + 'oauth_scope': lambda x: rtv[x].split(',') } for key, func in params.items(): - if key in out: - out[key] = func(key) - return out + if key in rtv: + rtv[key] = func(key) + + bindings = {} + if config.has_section('bindings'): + bindings = dict(config.items('bindings')) + + for name, keys in bindings.items(): + bindings[name] = [cls._parse_key(key) for key in keys.split(',')] + + return rtv, bindings + + @staticmethod + def _parse_key(key): + """ + Parse a key represented by a string return its character code. + """ + + key = key.strip() + if re.match('[<]KEY_.*[>]', key): + # Curses control character + return getattr(curses, key[1:-1]) + elif re.match('[<].*[>]', key): + # Ascii control character + return getattr(ascii, key[1:-1]) + elif key.startswith('0x'): + # Ascii hex code + return int(key, 16) + else: + # Ascii character + return ord(key) @staticmethod def _ensure_filepath(filename): diff --git a/rtv/rtv.cfg b/rtv/rtv.cfg index 540ee7a..5d01cf5 100644 --- a/rtv/rtv.cfg +++ b/rtv/rtv.cfg @@ -49,4 +49,69 @@ oauth_redirect_uri = http://127.0.0.1:65000/ oauth_redirect_port = 65000 ; Access permissions that will be requested. -oauth_scope = edit,history,identity,mysubreddits,privatemessages,read,report,save,submit,subscribe,vote \ No newline at end of file +oauth_scope = edit,history,identity,mysubreddits,privatemessages,read,report,save,submit,subscribe,vote + +[bindings] +############## +# Key Bindings +############## +; Multiple keys that represent the same command should be listed as a comma +; seperated list. The a mixture of the following notations should be used to +; describe keys. +; +; 1.) Plain keys can be represented by either uppercase/lowercase characters +; or the hexadecimal codes referring their ascii codes. For reference, see +; https://en.wikipedia.org/wiki/ASCII#ASCII_printable_code_chart +; e.g. Q, q, 1, ? +; e.g. 0x20 (space), 0x3c (less-than sign) +; +; 2.) Special ascii control codes should be surrounded with <>. For reference, +; see https://en.wikipedia.org/wiki/ASCII#ASCII_control_code_chart +; e.g. (enter), (escape) +; +; 3.) Other special keys are defined by curses, they should be surrounded by <> +; and prefixed with KEY_. For reference, see +; https://docs.python.org/2/library/curses.html#constants +; e.g. (left arrow), , (page down) +; +; Notes: +; - Curses is unreliable and should always be used in conjunction +; with . + +; General keys +EXIT = q +FORCE_EXIT = Q +HELP = ? +SORT_HOT = 1 +SORT_TOP = 2 +SORT_RISING = 3 +SORT_NEW = 4 +SORT_CONTROVERSIAL = 5 +MOVE_UP = k, +MOVE_DOWN = j, +PAGE_UP = m, +PAGE_DOWN = n, +UPVOTE = a +DOWNVOTE = z +LOGIN = u +DELETE = d +EDIT = e +INBOX = i +REFRESH = r, + +; Submission page +SUBMISSION_TOGGLE_COMMENTS = l, 0x20, +SUBMISSION_OPEN_IN_BROWSER = o, , +SUBMISSION_POST = c +SUBMISSION_EXIT = h, + +; Subreddit page +SUBREDDIT_SEARCH = f +SUBREDDIT_PROMPT = / +SUBREDDIT_POST = c +SUBREDDIT_OPEN = l, +SUBREDDIT_OPEN_IN_BROWSER = o, , , + +; Subscription page +SUBSCRIPTION_SELECT = l, , , +SUBSCRIPTION_EXIT = h, s, , \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py index 734a592..ade7da5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -103,6 +103,12 @@ def test_config_from_file(): assert config.config == args +def test_config_keys(): + + config = Config() + pass + + def test_config_refresh_token(): "Ensure that the refresh token can be loaded, saved, and removed" From 181507d9bbb5b196cafdbe4fbfc433370a7267a8 Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Tue, 9 Feb 2016 02:56:55 -0800 Subject: [PATCH 2/8] Almost functional. --- rtv/__main__.py | 4 ++- rtv/config.py | 36 +++++----------------- rtv/objects.py | 74 ++++++++++++++++++++++++++++++++++++++++++--- rtv/page.py | 38 +++++++++++------------ rtv/rtv.cfg | 3 +- rtv/submission.py | 15 +++++---- rtv/subreddit.py | 16 +++++----- rtv/subscription.py | 10 +++--- 8 files changed, 121 insertions(+), 75 deletions(-) diff --git a/rtv/__main__.py b/rtv/__main__.py index c56dc9b..ee49b7d 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -26,6 +26,7 @@ _logger = logging.getLogger(__name__) # ptrace_scope to 0 in /etc/sysctl.d/10-ptrace.conf. # http://blog.mellenthin.de/archives/2010/10/18/gdb-attach-fails + def main(): "Main entry point" @@ -38,12 +39,13 @@ def main(): sys.stdout.write('\x1b]2;{0}\x07'.format(title)) args = Config.get_args() - fargs = Config.get_file(args.get('config')) + fargs, bindings = Config.get_file(args.get('config')) # Apply the file config first, then overwrite with any command line args config = Config() config.update(**fargs) config.update(**args) + config.keymap.update(**bindings) # Copy the default config file and quit if config['copy_config']: diff --git a/rtv/config.py b/rtv/config.py index 84c1182..53be7de 100644 --- a/rtv/config.py +++ b/rtv/config.py @@ -1,19 +1,17 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -import re import os -import curses import codecs import shutil import argparse -from curses import ascii from functools import partial import six from six.moves import configparser from . import docs, __version__ +from .objects import KeyMap PACKAGE = os.path.dirname(__file__) HOME = os.path.expanduser('~') @@ -115,7 +113,10 @@ class Config(object): self.history_file = history_file self.token_file = token_file self.config = kwargs - self.default = self.get_file(DEFAULT_CONFIG) + + default, bindings = self.get_file(DEFAULT_CONFIG) + self.default = default + self.keymap = KeyMap(bindings) # `refresh_token` and `history` are saved/loaded at separate locations, # so they are treated differently from the rest of the config options. @@ -198,8 +199,8 @@ class Config(object): return cls._parse_rtv_file(config) - @classmethod - def _parse_rtv_file(cls, config): + @staticmethod + def _parse_rtv_file(config): rtv = {} if config.has_section('rtv'): @@ -213,7 +214,6 @@ class Config(object): 'oauth_redirect_port': partial(config.getint, 'rtv'), 'oauth_scope': lambda x: rtv[x].split(',') } - for key, func in params.items(): if key in rtv: rtv[key] = func(key) @@ -223,30 +223,10 @@ class Config(object): bindings = dict(config.items('bindings')) for name, keys in bindings.items(): - bindings[name] = [cls._parse_key(key) for key in keys.split(',')] + bindings[name] = [key.strip() for key in keys.split(',')] return rtv, bindings - @staticmethod - def _parse_key(key): - """ - Parse a key represented by a string return its character code. - """ - - key = key.strip() - if re.match('[<]KEY_.*[>]', key): - # Curses control character - return getattr(curses, key[1:-1]) - elif re.match('[<].*[>]', key): - # Ascii control character - return getattr(ascii, key[1:-1]) - elif key.startswith('0x'): - # Ascii hex code - return int(key, 16) - else: - # Ascii character - return ord(key) - @staticmethod def _ensure_filepath(filename): """ diff --git a/rtv/objects.py b/rtv/objects.py index 34af0f8..7816e55 100644 --- a/rtv/objects.py +++ b/rtv/objects.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +import re import os import time import curses @@ -9,7 +10,9 @@ import inspect import weakref import logging import threading +from curses import ascii from contextlib import contextmanager +from collections import defaultdict import six import praw @@ -502,6 +505,11 @@ class Controller(object): >>> def func(self, *args) >>> ... + Register a KeyBinding that can be defined later by the config file + >>> @Controller.register(KeyMap.UPVOTE) + >>> def upvote(self, *args) + >> ... + Register a default behavior by using `None`. >>> @Controller.register(None) >>> def default_func(self, *args) @@ -515,13 +523,22 @@ class Controller(object): character_map = {} - def __init__(self, instance): + def __init__(self, instance, keymap=None): self.instance = instance - # Build a list of parent controllers that follow the object's MRO to - # check if any parent controllers have registered the keypress + # Build a list of parent controllers that follow the object's MRO + # to check if any parent controllers have registered the keypress self.parents = inspect.getmro(type(self))[:-1] + # Update the character map with the bindings defined in the keymap + if not keymap: + for controller in [self] + self.parents: + for binding, func in controller.character_map.items(): + if isinstance(binding, KeyBinding): + # TODO: Raise error if trying to overwrite a char + mappings = {char: func for char in keymap.get(binding)} + self.character_map.update(mappings) + def trigger(self, char, *args, **kwargs): if isinstance(char, six.string_types) and len(char) == 1: @@ -551,4 +568,53 @@ class Controller(object): else: cls.character_map[char] = f return f - return inner \ No newline at end of file + return inner + + +class KeyBinding(object): + + def __init__(self, val): + self.val = val.upper() + + +class KeyMapMeta(type): + + def __getattr__(cls, key): + return KeyBinding(key) + + +@six.add_metaclass(KeyMapMeta) +class KeyMap(object): + + def __init__(self, bindings): + self._bindings = defaultdict(list) + self.update(bindings) + + def update(self, **bindings): + for command, keys in bindings: + binding = KeyBinding(command) + self._bindings[binding] = [self._parse_key(key) for key in keys] + + def get(self, binding): + return self._bindings[binding] + + @staticmethod + def _parse_key(key): + """ + Parse a key represented by a string and return its character code. + """ + + if isinstance(key, int): + return key + elif re.match('[<]KEY_.*[>]', key): + # Curses control character + return getattr(curses, key[1:-1]) + elif re.match('[<].*[>]', key): + # Ascii control character + return getattr(ascii, key[1:-1]) + elif key.startswith('0x'): + # Ascii hex code + return int(key, 16) + else: + # Ascii character + return ord(key) \ No newline at end of file diff --git a/rtv/page.py b/rtv/page.py index b3c6162..155cff7 100644 --- a/rtv/page.py +++ b/rtv/page.py @@ -9,7 +9,7 @@ from functools import wraps from kitchen.text.display import textual_width from . import docs -from .objects import Controller, Color +from .objects import Controller, Color, KeyMap def logged_in(f): @@ -68,60 +68,60 @@ class Page(object): ch = self.term.stdscr.getch() self.controller.trigger(ch) - @PageController.register('q') + @PageController.register(KeyMap.EXIT) def exit(self): if self.term.prompt_y_or_n('Do you really want to quit? (y/n): '): sys.exit() - @PageController.register('Q') + @PageController.register(KeyMap.FORCE_EXIT) def force_exit(self): sys.exit() - @PageController.register('?') + @PageController.register(KeyMap.HELP) def show_help(self): self.term.show_notification(docs.HELP.strip().splitlines()) - @PageController.register('1') + @PageController.register(KeyMap.SORT_HOT) def sort_content_hot(self): self.refresh_content(order='hot') - @PageController.register('2') + @PageController.register(KeyMap.SORT_TOP) def sort_content_top(self): self.refresh_content(order='top') - @PageController.register('3') + @PageController.register(KeyMap.SORT_RISING) def sort_content_rising(self): self.refresh_content(order='rising') - @PageController.register('4') + @PageController.register(KeyMap.SORT_NEW) def sort_content_new(self): self.refresh_content(order='new') - @PageController.register('5') + @PageController.register(KeyMap.SORT_CONTROVERSIAL) def sort_content_controversial(self): self.refresh_content(order='controversial') - @PageController.register(curses.KEY_UP, 'k') + @PageController.register(KeyMap.MOVE_UP) def move_cursor_up(self): self._move_cursor(-1) self.clear_input_queue() - @PageController.register(curses.KEY_DOWN, 'j') + @PageController.register(KeyMap.MOVE_DOWN) def move_cursor_down(self): self._move_cursor(1) self.clear_input_queue() - @PageController.register('m', curses.KEY_PPAGE) + @PageController.register(KeyMap.PAGE_UP) def move_page_up(self): self._move_page(-1) self.clear_input_queue() - @PageController.register('n', curses.KEY_NPAGE) + @PageController.register(KeyMap.PAGE_DOWN) def move_page_down(self): self._move_page(1) self.clear_input_queue() - @PageController.register('a') + @PageController.register(KeyMap.UPVOTE) @logged_in def upvote(self): data = self.content.get(self.nav.absolute_index) @@ -138,7 +138,7 @@ class Page(object): if not self.term.loader.exception: data['likes'] = True - @PageController.register('z') + @PageController.register(KeyMap.DOWNVOTE) @logged_in def downvote(self): data = self.content.get(self.nav.absolute_index) @@ -155,7 +155,7 @@ class Page(object): if not self.term.loader.exception: data['likes'] = None - @PageController.register('u') + @PageController.register(KeyMap.LOGIN) def login(self): """ Prompt to log into the user's account, or log out of the current @@ -169,7 +169,7 @@ class Page(object): else: self.oauth.authorize() - @PageController.register('d') + @PageController.register(KeyMap.DELETE) @logged_in def delete_item(self): """ @@ -193,7 +193,7 @@ class Page(object): if self.term.loader.exception is None: self.refresh_content() - @PageController.register('e') + @PageController.register(KeyMap.EDIT) @logged_in def edit(self): """ @@ -228,7 +228,7 @@ class Page(object): if self.term.loader.exception is None: self.refresh_content() - @PageController.register('i') + @PageController.register(KeyMap.INBOX) @logged_in def get_inbox(self): """ diff --git a/rtv/rtv.cfg b/rtv/rtv.cfg index 5d01cf5..8404827 100644 --- a/rtv/rtv.cfg +++ b/rtv/rtv.cfg @@ -100,7 +100,7 @@ INBOX = i REFRESH = r, ; Submission page -SUBMISSION_TOGGLE_COMMENTS = l, 0x20, +SUBMISSION_TOGGLE_COMMENT = l, 0x20, SUBMISSION_OPEN_IN_BROWSER = o, , SUBMISSION_POST = c SUBMISSION_EXIT = h, @@ -111,6 +111,7 @@ SUBREDDIT_PROMPT = / SUBREDDIT_POST = c SUBREDDIT_OPEN = l, SUBREDDIT_OPEN_IN_BROWSER = o, , , +SUBREDDIT_OPEN_SUBSCRIPTIONS = s ; Subscription page SUBSCRIPTION_SELECT = l, , , diff --git a/rtv/submission.py b/rtv/submission.py index 959ff0c..1ec91f6 100644 --- a/rtv/submission.py +++ b/rtv/submission.py @@ -7,8 +7,7 @@ import curses from . import docs from .content import SubmissionContent from .page import Page, PageController, logged_in -from .objects import Navigator, Color -from .terminal import Terminal +from .objects import Navigator, Color, KeyMap class SubmissionController(PageController): @@ -29,7 +28,7 @@ class SubmissionPage(Page): # Start at the submission post, which is indexed as -1 self.nav = Navigator(self.content.get, page_index=-1) - @SubmissionController.register(curses.KEY_RIGHT, 'l', ' ') + @SubmissionController.register(KeyMap.SUBMISSION_TOGGLE_COMMENT) def toggle_comment(self): "Toggle the selected comment tree between visible and hidden" @@ -41,13 +40,13 @@ class SubmissionPage(Page): # causes the cursor index to go out of bounds. self.nav.page_index, self.nav.cursor_index = current_index, 0 - @SubmissionController.register(curses.KEY_LEFT, 'h') + @SubmissionController.register(KeyMap.SUBMISSION_EXIT) def exit_submission(self): "Close the submission and return to the subreddit page" self.active = False - @SubmissionController.register(curses.KEY_F5, 'r') + @SubmissionController.register(KeyMap.REFRESH) def refresh_content(self, order=None, name=None): "Re-download comments and reset the page index" @@ -60,7 +59,7 @@ class SubmissionPage(Page): if not self.term.loader.exception: self.nav = Navigator(self.content.get, page_index=-1) - @SubmissionController.register(curses.KEY_ENTER, Terminal.RETURN, 'o') + @SubmissionController.register(KeyMap.SUBMISSION_OPEN_IN_BROWSER) def open_link(self): "Open the selected item with the webbrowser" @@ -71,7 +70,7 @@ class SubmissionPage(Page): else: self.term.flash() - @SubmissionController.register('c') + @SubmissionController.register(KeyMap.SUBMISSION_POST) @logged_in def add_comment(self): """ @@ -114,7 +113,7 @@ class SubmissionPage(Page): if not self.term.loader.exception: self.refresh_content() - @SubmissionController.register('d') + @SubmissionController.register(KeyMap.DELETE) @logged_in def delete_comment(self): "Delete a comment as long as it is not the current submission" diff --git a/rtv/subreddit.py b/rtv/subreddit.py index 1e79ca3..950fe59 100644 --- a/rtv/subreddit.py +++ b/rtv/subreddit.py @@ -7,7 +7,7 @@ import curses from . import docs from .content import SubredditContent from .page import Page, PageController, logged_in -from .objects import Navigator, Color +from .objects import Navigator, Color, KeyMap from .submission import SubmissionPage from .subscription import SubscriptionPage from .terminal import Terminal @@ -31,7 +31,7 @@ class SubredditPage(Page): self.controller = SubredditController(self) self.nav = Navigator(self.content.get) - @SubredditController.register(curses.KEY_F5, 'r') + @SubredditController.register(KeyMap.REFRESH) def refresh_content(self, order=None, name=None): "Re-download all submissions and reset the page index" @@ -49,7 +49,7 @@ class SubredditPage(Page): if not self.term.loader.exception: self.nav = Navigator(self.content.get) - @SubredditController.register('f') + @SubredditController.register(KeyMap.SUBREDDIT_SEARCH) def search_subreddit(self, name=None): "Open a prompt to search the given subreddit" @@ -65,7 +65,7 @@ class SubredditPage(Page): if not self.term.loader.exception: self.nav = Navigator(self.content.get) - @SubredditController.register('/') + @SubredditController.register(KeyMap.SUBREDDIT_PROMPT) def prompt_subreddit(self): "Open a prompt to navigate to a different subreddit" @@ -73,7 +73,7 @@ class SubredditPage(Page): if name is not None: self.refresh_content(order='ignore', name=name) - @SubredditController.register(curses.KEY_RIGHT, 'l') + @SubredditController.register(KeyMap.SUBREDDIT_OPEN) def open_submission(self, url=None): "Select the current submission to view posts" @@ -93,7 +93,7 @@ class SubredditPage(Page): if data.get('url_type') == 'selfpost': self.config.history.add(data['url_full']) - @SubredditController.register(curses.KEY_ENTER, Terminal.RETURN, 'o') + @SubredditController.register(KeyMap.SUBREDDIT_OPEN_IN_BROWSER) def open_link(self): "Open a link with the webbrowser" @@ -107,7 +107,7 @@ class SubredditPage(Page): self.term.open_browser(data['url_full']) self.config.history.add(data['url_full']) - @SubredditController.register('c') + @SubredditController.register(KeyMap.SUBREDDIT_POST) @logged_in def post_submission(self): "Post a new submission to the given subreddit" @@ -145,7 +145,7 @@ class SubredditPage(Page): self.refresh_content() - @SubredditController.register('s') + @SubredditController.register(KeyMap.SUBREDDIT_OPEN_SUBSCRIPTIONS) @logged_in def open_subscriptions(self): "Open user subscriptions page" diff --git a/rtv/subscription.py b/rtv/subscription.py index 6341a4a..efbfe3e 100644 --- a/rtv/subscription.py +++ b/rtv/subscription.py @@ -5,8 +5,7 @@ import curses from .page import Page, PageController from .content import SubscriptionContent -from .objects import Color, Navigator -from .terminal import Terminal +from .objects import Color, Navigator, KeyMap class SubscriptionController(PageController): @@ -23,7 +22,7 @@ class SubscriptionPage(Page): self.nav = Navigator(self.content.get) self.subreddit_data = None - @SubscriptionController.register(curses.KEY_F5, 'r') + @SubscriptionController.register(KeyMap.REFRESH) def refresh_content(self, order=None, name=None): "Re-download all subscriptions and reset the page index" @@ -38,15 +37,14 @@ class SubscriptionPage(Page): if not self.term.loader.exception: self.nav = Navigator(self.content.get) - @SubscriptionController.register(curses.KEY_ENTER, Terminal.RETURN, - curses.KEY_RIGHT, 'l') + @SubscriptionController.register(KeyMap.SUBSCRIPTION_SELECT) def select_subreddit(self): "Store the selected subreddit and return to the subreddit page" self.subreddit_data = self.content.get(self.nav.absolute_index) self.active = False - @SubscriptionController.register(curses.KEY_LEFT, Terminal.ESCAPE, 'h', 's') + @SubscriptionController.register(KeyMap.SUBSCRIPTION_CLOSE) def close_subscriptions(self): "Close subscriptions and return to the subreddit page" From c5bf97efcc0adabf10a61aeaddfac552a5f2ddeb Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Tue, 9 Feb 2016 18:32:25 -0800 Subject: [PATCH 3/8] Working on tests. --- rtv/exceptions.py | 8 +++ rtv/objects.py | 113 +++++++++++++++++++++++++++++------------- rtv/page.py | 38 +++++++------- rtv/submission.py | 16 +++--- rtv/subreddit.py | 18 +++---- rtv/subscription.py | 10 ++-- tests/test_config.py | 26 +++++++--- tests/test_objects.py | 87 +++++++++++++++++++++++++++++++- 8 files changed, 231 insertions(+), 85 deletions(-) diff --git a/rtv/exceptions.py b/rtv/exceptions.py index ab6a8c6..8e4da1d 100644 --- a/rtv/exceptions.py +++ b/rtv/exceptions.py @@ -10,6 +10,14 @@ class RTVError(Exception): "Base RTV error class" +class KeystringError(RTVError): + "Unable to parse key string" + + +class ConfigError(RTVError): + "There was a problem with the configuration" + + class AccountError(RTVError): "Could not access user account" diff --git a/rtv/objects.py b/rtv/objects.py index 7816e55..cda6979 100644 --- a/rtv/objects.py +++ b/rtv/objects.py @@ -12,7 +12,6 @@ import logging import threading from curses import ascii from contextlib import contextmanager -from collections import defaultdict import six import praw @@ -530,14 +529,23 @@ class Controller(object): # to check if any parent controllers have registered the keypress self.parents = inspect.getmro(type(self))[:-1] - # Update the character map with the bindings defined in the keymap if not keymap: - for controller in [self] + self.parents: - for binding, func in controller.character_map.items(): - if isinstance(binding, KeyBinding): - # TODO: Raise error if trying to overwrite a char - mappings = {char: func for char in keymap.get(binding)} - self.character_map.update(mappings) + return + + # Go through the controller and all of it's parents and look for + # Command objects in the character map. Use the keymap the lookup the + # keys associated with those command objects and add them to the + # character map. + for controller in self.parents: + for command, func in controller.character_map.copy().items(): + if isinstance(command, Command): + for key in keymap.get(command): + if key in controller.character_map: + raise exceptions.ConfigError( + 'Invalid configuration, cannot bind the `%s`' + ' key to two different commands in the `%s`' + ' context' % (key, self.__class__.__name__)) + controller.character_map[key] = func def trigger(self, char, *args, **kwargs): @@ -571,32 +579,57 @@ class Controller(object): return inner -class KeyBinding(object): +class Command(object): + """ + Minimal class that should be used to wrap abstract commands that may be + implemented as one or more physical keystrokes. + + E.g. Command("REFRESH") can be represented by the KeyMap to be triggered + by either `r` or `F5` + """ def __init__(self, val): self.val = val.upper() + def __repr__(self): + return 'Command(%s)' % self.val -class KeyMapMeta(type): + def __eq__(self, other): + return repr(self) == repr(other) - def __getattr__(cls, key): - return KeyBinding(key) + def __ne__(self, other): + return not self == other + + def __hash__(self): + return hash(repr(self)) -@six.add_metaclass(KeyMapMeta) class KeyMap(object): + """ + Mapping between commands and the keys that they represent. + """ def __init__(self, bindings): - self._bindings = defaultdict(list) - self.update(bindings) + self.set_bindings(bindings) - def update(self, **bindings): - for command, keys in bindings: - binding = KeyBinding(command) - self._bindings[binding] = [self._parse_key(key) for key in keys] + def set_bindings(self, bindings): + # Clear the keymap before applying the bindings to avoid confusion. + # If a user defines custom bindings in their config file, they must + # explicitly define ALL of the bindings. + self._keymap = {} + for command, keys in bindings.items(): + if not isinstance(command, Command): + command = Command(command) + self._keymap[command] = [self._parse_key(key) for key in keys] - def get(self, binding): - return self._bindings[binding] + def get(self, command): + if not isinstance(command, Command): + command = Command(command) + try: + return self._keymap[command] + except KeyError: + raise exceptions.ConfigError( + 'Invalid configuration, `%s` key undefined' % command.val) @staticmethod def _parse_key(key): @@ -604,17 +637,27 @@ class KeyMap(object): Parse a key represented by a string and return its character code. """ - if isinstance(key, int): - return key - elif re.match('[<]KEY_.*[>]', key): - # Curses control character - return getattr(curses, key[1:-1]) - elif re.match('[<].*[>]', key): - # Ascii control character - return getattr(ascii, key[1:-1]) - elif key.startswith('0x'): - # Ascii hex code - return int(key, 16) - else: - # Ascii character - return ord(key) \ No newline at end of file + try: + if isinstance(key, int): + return key + elif re.match('[<]KEY_.*[>]', key): + # Curses control character + return getattr(curses, key[1:-1]) + elif re.match('[<].*[>]', key): + # Ascii control character + return getattr(ascii, key[1:-1]) + elif key.startswith('0x'): + # Ascii hex code + return int(key, 16) + else: + # Ascii character + code = ord(key) + if 0 <= code <= 255: + return code + # Python 3.3 has a curses.get_wch() function that we can use + # for unicode keys, but Python 2.7 is limited to ascii. + raise exceptions.ConfigError( + 'Invalid key `%s`, must be in the ascii range' % key) + + except (AttributeError, ValueError, TypeError): + raise exceptions.ConfigError('Invalid key string `%s`' % key) \ No newline at end of file diff --git a/rtv/page.py b/rtv/page.py index 155cff7..0e006b1 100644 --- a/rtv/page.py +++ b/rtv/page.py @@ -9,7 +9,7 @@ from functools import wraps from kitchen.text.display import textual_width from . import docs -from .objects import Controller, Color, KeyMap +from .objects import Controller, Color, Command def logged_in(f): @@ -68,60 +68,60 @@ class Page(object): ch = self.term.stdscr.getch() self.controller.trigger(ch) - @PageController.register(KeyMap.EXIT) + @PageController.register(Command('EXIT')) def exit(self): if self.term.prompt_y_or_n('Do you really want to quit? (y/n): '): sys.exit() - @PageController.register(KeyMap.FORCE_EXIT) + @PageController.register(Command('FORCE_EXIT')) def force_exit(self): sys.exit() - @PageController.register(KeyMap.HELP) + @PageController.register(Command('HELP')) def show_help(self): self.term.show_notification(docs.HELP.strip().splitlines()) - @PageController.register(KeyMap.SORT_HOT) + @PageController.register(Command('SORT_HOT')) def sort_content_hot(self): self.refresh_content(order='hot') - @PageController.register(KeyMap.SORT_TOP) + @PageController.register(Command('SORT_TOP')) def sort_content_top(self): self.refresh_content(order='top') - @PageController.register(KeyMap.SORT_RISING) + @PageController.register(Command('SORT_RISING')) def sort_content_rising(self): self.refresh_content(order='rising') - @PageController.register(KeyMap.SORT_NEW) + @PageController.register(Command('SORT_NEW')) def sort_content_new(self): self.refresh_content(order='new') - @PageController.register(KeyMap.SORT_CONTROVERSIAL) + @PageController.register(Command('SORT_CONTROVERSIAL')) def sort_content_controversial(self): self.refresh_content(order='controversial') - @PageController.register(KeyMap.MOVE_UP) + @PageController.register(Command('MOVE_UP')) def move_cursor_up(self): self._move_cursor(-1) self.clear_input_queue() - @PageController.register(KeyMap.MOVE_DOWN) + @PageController.register(Command('MOVE_DOWN')) def move_cursor_down(self): self._move_cursor(1) self.clear_input_queue() - @PageController.register(KeyMap.PAGE_UP) + @PageController.register(Command('PAGE_UP')) def move_page_up(self): self._move_page(-1) self.clear_input_queue() - @PageController.register(KeyMap.PAGE_DOWN) + @PageController.register(Command('PAGE_DOWN')) def move_page_down(self): self._move_page(1) self.clear_input_queue() - @PageController.register(KeyMap.UPVOTE) + @PageController.register(Command('UPVOTE')) @logged_in def upvote(self): data = self.content.get(self.nav.absolute_index) @@ -138,7 +138,7 @@ class Page(object): if not self.term.loader.exception: data['likes'] = True - @PageController.register(KeyMap.DOWNVOTE) + @PageController.register(Command('DOWNVOTE')) @logged_in def downvote(self): data = self.content.get(self.nav.absolute_index) @@ -155,7 +155,7 @@ class Page(object): if not self.term.loader.exception: data['likes'] = None - @PageController.register(KeyMap.LOGIN) + @PageController.register(Command('LOGIN')) def login(self): """ Prompt to log into the user's account, or log out of the current @@ -169,7 +169,7 @@ class Page(object): else: self.oauth.authorize() - @PageController.register(KeyMap.DELETE) + @PageController.register(Command('DELETE')) @logged_in def delete_item(self): """ @@ -193,7 +193,7 @@ class Page(object): if self.term.loader.exception is None: self.refresh_content() - @PageController.register(KeyMap.EDIT) + @PageController.register(Command('EDIT')) @logged_in def edit(self): """ @@ -228,7 +228,7 @@ class Page(object): if self.term.loader.exception is None: self.refresh_content() - @PageController.register(KeyMap.INBOX) + @PageController.register(Command('INBOX')) @logged_in def get_inbox(self): """ diff --git a/rtv/submission.py b/rtv/submission.py index 1ec91f6..bfdc814 100644 --- a/rtv/submission.py +++ b/rtv/submission.py @@ -7,7 +7,7 @@ import curses from . import docs from .content import SubmissionContent from .page import Page, PageController, logged_in -from .objects import Navigator, Color, KeyMap +from .objects import Navigator, Color, Command class SubmissionController(PageController): @@ -24,11 +24,11 @@ class SubmissionPage(Page): else: self.content = SubmissionContent(submission, term.loader) - self.controller = SubmissionController(self) + self.controller = SubmissionController(self, keymap=config.keymap) # Start at the submission post, which is indexed as -1 self.nav = Navigator(self.content.get, page_index=-1) - @SubmissionController.register(KeyMap.SUBMISSION_TOGGLE_COMMENT) + @SubmissionController.register(Command('SUBMISSION_TOGGLE_COMMENT')) def toggle_comment(self): "Toggle the selected comment tree between visible and hidden" @@ -40,13 +40,13 @@ class SubmissionPage(Page): # causes the cursor index to go out of bounds. self.nav.page_index, self.nav.cursor_index = current_index, 0 - @SubmissionController.register(KeyMap.SUBMISSION_EXIT) + @SubmissionController.register(Command('SUBMISSION_EXIT')) def exit_submission(self): "Close the submission and return to the subreddit page" self.active = False - @SubmissionController.register(KeyMap.REFRESH) + @SubmissionController.register(Command('REFRESH')) def refresh_content(self, order=None, name=None): "Re-download comments and reset the page index" @@ -59,7 +59,7 @@ class SubmissionPage(Page): if not self.term.loader.exception: self.nav = Navigator(self.content.get, page_index=-1) - @SubmissionController.register(KeyMap.SUBMISSION_OPEN_IN_BROWSER) + @SubmissionController.register(Command('SUBMISSION_OPEN_IN_BROWSER')) def open_link(self): "Open the selected item with the webbrowser" @@ -70,7 +70,7 @@ class SubmissionPage(Page): else: self.term.flash() - @SubmissionController.register(KeyMap.SUBMISSION_POST) + @SubmissionController.register(Command('SUBMISSION_POST')) @logged_in def add_comment(self): """ @@ -113,7 +113,7 @@ class SubmissionPage(Page): if not self.term.loader.exception: self.refresh_content() - @SubmissionController.register(KeyMap.DELETE) + @SubmissionController.register(Command('DELETE')) @logged_in def delete_comment(self): "Delete a comment as long as it is not the current submission" diff --git a/rtv/subreddit.py b/rtv/subreddit.py index 950fe59..5c14339 100644 --- a/rtv/subreddit.py +++ b/rtv/subreddit.py @@ -7,7 +7,7 @@ import curses from . import docs from .content import SubredditContent from .page import Page, PageController, logged_in -from .objects import Navigator, Color, KeyMap +from .objects import Navigator, Color, Command from .submission import SubmissionPage from .subscription import SubscriptionPage from .terminal import Terminal @@ -28,10 +28,10 @@ class SubredditPage(Page): super(SubredditPage, self).__init__(reddit, term, config, oauth) self.content = SubredditContent.from_name(reddit, name, term.loader) - self.controller = SubredditController(self) + self.controller = SubredditController(self, keymap=config.keymap) self.nav = Navigator(self.content.get) - @SubredditController.register(KeyMap.REFRESH) + @SubredditController.register(Command('REFRESH')) def refresh_content(self, order=None, name=None): "Re-download all submissions and reset the page index" @@ -49,7 +49,7 @@ class SubredditPage(Page): if not self.term.loader.exception: self.nav = Navigator(self.content.get) - @SubredditController.register(KeyMap.SUBREDDIT_SEARCH) + @SubredditController.register(Command('SUBREDDIT_SEARCH')) def search_subreddit(self, name=None): "Open a prompt to search the given subreddit" @@ -65,7 +65,7 @@ class SubredditPage(Page): if not self.term.loader.exception: self.nav = Navigator(self.content.get) - @SubredditController.register(KeyMap.SUBREDDIT_PROMPT) + @SubredditController.register(Command('SUBREDDIT_PROMPT')) def prompt_subreddit(self): "Open a prompt to navigate to a different subreddit" @@ -73,7 +73,7 @@ class SubredditPage(Page): if name is not None: self.refresh_content(order='ignore', name=name) - @SubredditController.register(KeyMap.SUBREDDIT_OPEN) + @SubredditController.register(Command('SUBREDDIT_OPEN')) def open_submission(self, url=None): "Select the current submission to view posts" @@ -93,7 +93,7 @@ class SubredditPage(Page): if data.get('url_type') == 'selfpost': self.config.history.add(data['url_full']) - @SubredditController.register(KeyMap.SUBREDDIT_OPEN_IN_BROWSER) + @SubredditController.register(Command('SUBREDDIT_OPEN_IN_BROWSER')) def open_link(self): "Open a link with the webbrowser" @@ -107,7 +107,7 @@ class SubredditPage(Page): self.term.open_browser(data['url_full']) self.config.history.add(data['url_full']) - @SubredditController.register(KeyMap.SUBREDDIT_POST) + @SubredditController.register(Command('SUBREDDIT_POST')) @logged_in def post_submission(self): "Post a new submission to the given subreddit" @@ -145,7 +145,7 @@ class SubredditPage(Page): self.refresh_content() - @SubredditController.register(KeyMap.SUBREDDIT_OPEN_SUBSCRIPTIONS) + @SubredditController.register(Command('SUBREDDIT_OPEN_SUBSCRIPTIONS')) @logged_in def open_subscriptions(self): "Open user subscriptions page" diff --git a/rtv/subscription.py b/rtv/subscription.py index efbfe3e..eb0f29d 100644 --- a/rtv/subscription.py +++ b/rtv/subscription.py @@ -5,7 +5,7 @@ import curses from .page import Page, PageController from .content import SubscriptionContent -from .objects import Color, Navigator, KeyMap +from .objects import Color, Navigator, Command class SubscriptionController(PageController): @@ -18,11 +18,11 @@ class SubscriptionPage(Page): super(SubscriptionPage, self).__init__(reddit, term, config, oauth) self.content = SubscriptionContent.from_user(reddit, term.loader) - self.controller = SubscriptionController(self) + self.controller = SubscriptionController(self, keymap=config.keymap) self.nav = Navigator(self.content.get) self.subreddit_data = None - @SubscriptionController.register(KeyMap.REFRESH) + @SubscriptionController.register(Command('REFRESH')) def refresh_content(self, order=None, name=None): "Re-download all subscriptions and reset the page index" @@ -37,14 +37,14 @@ class SubscriptionPage(Page): if not self.term.loader.exception: self.nav = Navigator(self.content.get) - @SubscriptionController.register(KeyMap.SUBSCRIPTION_SELECT) + @SubscriptionController.register(Command('SUBSCRIPTION_SELECT')) def select_subreddit(self): "Store the selected subreddit and return to the subreddit page" self.subreddit_data = self.content.get(self.nav.absolute_index) self.active = False - @SubscriptionController.register(KeyMap.SUBSCRIPTION_CLOSE) + @SubscriptionController.register(Command('SUBSCRIPTION_CLOSE')) def close_subscriptions(self): "Close subscriptions and return to the subreddit page" diff --git a/tests/test_config.py b/tests/test_config.py index ade7da5..87aa428 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -88,25 +88,35 @@ def test_config_from_file(): 'link': 'https://reddit.com/permalink •', 'subreddit': 'cfb'} + bindings = { + 'REFRESH': 'r, ', + 'UPVOTE': ''} + with NamedTemporaryFile(suffix='.cfg') as fp: - fargs = Config.get_file(filename=fp.name) + fargs, fbindings = Config.get_file(filename=fp.name) config = Config(**fargs) + config.keymap.set_bindings(fbindings) assert config.config == {} + assert config.keymap._keymap == {} + # [rtv] rows = ['{0}={1}'.format(key, val) for key, val in args.items()] data = '\n'.join(['[rtv]'] + rows) fp.write(codecs.encode(data, 'utf-8')) + + # [bindings] + rows = ['{0}={1}'.format(key, val) for key, val in bindings.items()] + data = '\n'.join(['', '', '[bindings]'] + rows) + fp.write(codecs.encode(data, 'utf-8')) + fp.flush() - fargs = Config.get_file(filename=fp.name) + fargs, fbindings = Config.get_file(filename=fp.name) config.update(**fargs) + config.keymap.set_bindings(fbindings) assert config.config == args - - -def test_config_keys(): - - config = Config() - pass + assert config.keymap.get('REFRESH') == [ord('r'), 269] + assert config.keymap.get('UPVOTE') == [] def test_config_refresh_token(): diff --git a/tests/test_objects.py b/tests/test_objects.py index 2dda79e..6e0a751 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -4,10 +4,13 @@ from __future__ import unicode_literals import time import curses +import six import pytest import requests -from rtv.objects import Color, Controller, Navigator, curses_session +from rtv import exceptions +from rtv.objects import Color, Controller, Navigator, Command, KeyMap, \ + curses_session try: from unittest import mock @@ -246,6 +249,88 @@ def test_objects_controller(): assert controller_c.trigger('3') is None +def test_objects_controller_command(): + + class ControllerA(Controller): + character_map = {} + + class ControllerB(ControllerA): + character_map = {} + + @ControllerA.register(Command('REFRESH')) + def call_page(_): + return 'a1' + + @ControllerA.register(Command('UPVOTE')) + def call_page(_): + return 'a2' + + @ControllerB.register(Command('REFRESH')) + def call_page(_): + return 'b1' + + # Two commands aren't allowed to share keys + keymap = KeyMap({'REFRESH': [0x10, 0x11], 'UPVOTE': [0x11, 0x12]}) + with pytest.raises(exceptions.ConfigError) as e: + ControllerA(None, keymap=keymap) + assert 'ControllerA' in six.text_type(e) + + # Reset the character map + ControllerA.character_map = {Command('REFRESH'): int, Command('UPVOTE'):int} + + # All commands must be defined in the keymap + keymap = KeyMap({'REFRESH': [0x10]}) + with pytest.raises(exceptions.ConfigError) as e: + ControllerB(None, keymap=keymap) + assert 'UPVOTE' in six.text_type(e) + + +def test_objects_command(): + + c1 = Command("REFRESH") + c2 = Command("refresh") + c3 = Command("EXIT") + + assert c1 == c2 + assert c1 != c3 + + keymap = {c1: None, c2: None, c3: None} + assert len(keymap) == 2 + assert c1 in keymap + assert c2 in keymap + assert c3 in keymap + + +def test_objects_keymap(): + + bindings = { + 'refresh': ['a', 0x12, '', ''], + 'exit': [], + Command('UPVOTE'): ['b', ''] + } + + keymap = KeyMap(bindings) + assert keymap.get(Command('REFRESH')) == [97, 18, 10, 259] + assert keymap.get(Command('exit')) == [] + assert keymap.get('upvote') == [98, 269] + with pytest.raises(exceptions.ConfigError) as e: + keymap.get('downvote') + assert 'Command(DOWNVOTE)' in six.text_type(e) + + # Updating the bindings wipes out the old ones + bindings = {'refresh': ['a', 0x12, '', '']} + keymap.set_bindings(bindings) + assert keymap.get('refresh') + with pytest.raises(exceptions.ConfigError) as e: + keymap.get('upvote') + assert 'Command(UPVOTE)' in six.text_type(e) + + for key in ('', None, '', '', '', '♬'): + with pytest.raises(exceptions.ConfigError) as e: + keymap.set_bindings({'refresh': [key]}) + assert six.text_type(key) in six.text_type(e) + + def test_objects_navigator_properties(): def valid_page_cb(_): From 0de30334148e2d5a8b980ea4bf95c7828d2c391d Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Tue, 9 Feb 2016 23:08:44 -0800 Subject: [PATCH 4/8] Updated bindings in __main__. --- rtv/__main__.py | 5 ++++- rtv/objects.py | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/rtv/__main__.py b/rtv/__main__.py index ee49b7d..ff470df 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -45,7 +45,10 @@ def main(): config = Config() config.update(**fargs) config.update(**args) - config.keymap.update(**bindings) + + # If key bindings are supplied in the config file, overwrite the defaults + if bindings: + config.keymap.set_bindings(bindings) # Copy the default config file and quit if config['copy_config']: diff --git a/rtv/objects.py b/rtv/objects.py index cda6979..93ec0e8 100644 --- a/rtv/objects.py +++ b/rtv/objects.py @@ -610,6 +610,7 @@ class KeyMap(object): """ def __init__(self, bindings): + self._keymap = None self.set_bindings(bindings) def set_bindings(self, bindings): From d08b9f47be157d8e2a7f1da815fb31a687b8cc89 Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Tue, 9 Feb 2016 23:42:07 -0800 Subject: [PATCH 5/8] Fixed the tests. --- rtv/objects.py | 11 +++++++---- rtv/subscription.py | 2 +- tests/test_config.py | 4 ++-- tests/test_objects.py | 18 ++++++++++++------ tests/test_page.py | 4 ++-- 5 files changed, 24 insertions(+), 15 deletions(-) diff --git a/rtv/objects.py b/rtv/objects.py index 93ec0e8..214bdde 100644 --- a/rtv/objects.py +++ b/rtv/objects.py @@ -540,12 +540,15 @@ class Controller(object): for command, func in controller.character_map.copy().items(): if isinstance(command, Command): for key in keymap.get(command): - if key in controller.character_map: + val = keymap.parse(key) + # Check if the key is already programmed to trigger a + # different function. + if controller.character_map.get(val, func) != func: raise exceptions.ConfigError( 'Invalid configuration, cannot bind the `%s`' ' key to two different commands in the `%s`' ' context' % (key, self.__class__.__name__)) - controller.character_map[key] = func + controller.character_map[val] = func def trigger(self, char, *args, **kwargs): @@ -621,7 +624,7 @@ class KeyMap(object): for command, keys in bindings.items(): if not isinstance(command, Command): command = Command(command) - self._keymap[command] = [self._parse_key(key) for key in keys] + self._keymap[command] = keys def get(self, command): if not isinstance(command, Command): @@ -633,7 +636,7 @@ class KeyMap(object): 'Invalid configuration, `%s` key undefined' % command.val) @staticmethod - def _parse_key(key): + def parse(key): """ Parse a key represented by a string and return its character code. """ diff --git a/rtv/subscription.py b/rtv/subscription.py index eb0f29d..89f7877 100644 --- a/rtv/subscription.py +++ b/rtv/subscription.py @@ -44,7 +44,7 @@ class SubscriptionPage(Page): self.subreddit_data = self.content.get(self.nav.absolute_index) self.active = False - @SubscriptionController.register(Command('SUBSCRIPTION_CLOSE')) + @SubscriptionController.register(Command('SUBSCRIPTION_EXIT')) def close_subscriptions(self): "Close subscriptions and return to the subreddit page" diff --git a/tests/test_config.py b/tests/test_config.py index 87aa428..1f409c4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -115,8 +115,8 @@ def test_config_from_file(): config.update(**fargs) config.keymap.set_bindings(fbindings) assert config.config == args - assert config.keymap.get('REFRESH') == [ord('r'), 269] - assert config.keymap.get('UPVOTE') == [] + assert config.keymap.get('REFRESH') == ['r', ''] + assert config.keymap.get('UPVOTE') == [''] def test_config_refresh_token(): diff --git a/tests/test_objects.py b/tests/test_objects.py index 6e0a751..d9592f6 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -276,7 +276,7 @@ def test_objects_controller_command(): assert 'ControllerA' in six.text_type(e) # Reset the character map - ControllerA.character_map = {Command('REFRESH'): int, Command('UPVOTE'):int} + ControllerA.character_map = {Command('REFRESH'): 0, Command('UPVOTE'): 0} # All commands must be defined in the keymap keymap = KeyMap({'REFRESH': [0x10]}) @@ -310,12 +310,12 @@ def test_objects_keymap(): } keymap = KeyMap(bindings) - assert keymap.get(Command('REFRESH')) == [97, 18, 10, 259] + assert keymap.get(Command('REFRESH')) == ['a', 0x12, '', ''] assert keymap.get(Command('exit')) == [] - assert keymap.get('upvote') == [98, 269] + assert keymap.get('upvote') == ['b', ''] with pytest.raises(exceptions.ConfigError) as e: keymap.get('downvote') - assert 'Command(DOWNVOTE)' in six.text_type(e) + assert 'DOWNVOTE' in six.text_type(e) # Updating the bindings wipes out the old ones bindings = {'refresh': ['a', 0x12, '', '']} @@ -323,11 +323,17 @@ def test_objects_keymap(): assert keymap.get('refresh') with pytest.raises(exceptions.ConfigError) as e: keymap.get('upvote') - assert 'Command(UPVOTE)' in six.text_type(e) + assert 'UPVOTE' in six.text_type(e) + # Strings should be parsed correctly into keys + assert KeyMap.parse('a') == 97 + assert KeyMap.parse(0x12) == 18 + assert KeyMap.parse('') == 10 + assert KeyMap.parse('') == 259 + assert KeyMap.parse('') == 269 for key in ('', None, '', '', '', '♬'): with pytest.raises(exceptions.ConfigError) as e: - keymap.set_bindings({'refresh': [key]}) + keymap.parse(key) assert six.text_type(key) in six.text_type(e) diff --git a/tests/test_page.py b/tests/test_page.py index 01978d9..2891e16 100644 --- a/tests/test_page.py +++ b/tests/test_page.py @@ -38,7 +38,7 @@ def test_page_logged_in(terminal): def test_page_unauthenticated(reddit, terminal, config, oauth): page = Page(reddit, terminal, config, oauth) - page.controller = PageController(page) + page.controller = PageController(page, keymap=config.keymap) with mock.patch.object(page, 'refresh_content'), \ mock.patch.object(page, 'content'), \ mock.patch.object(page, 'nav'), \ @@ -104,7 +104,7 @@ def test_page_unauthenticated(reddit, terminal, config, oauth): def test_page_authenticated(reddit, terminal, config, oauth, refresh_token): page = Page(reddit, terminal, config, oauth) - page.controller = PageController(page) + page.controller = PageController(page, keymap=config.keymap) config.refresh_token = refresh_token # Login From a0da5fc6ca382ac537e607e280b8122af28b1516 Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Wed, 10 Feb 2016 00:35:51 -0800 Subject: [PATCH 6/8] Improved config error reporting. --- rtv/__main__.py | 4 ++++ rtv/exceptions.py | 12 ++++-------- rtv/page.py | 1 - rtv/rtv.cfg | 15 ++++++++------- rtv/submission.py | 5 ++--- rtv/subreddit.py | 7 +++---- rtv/subscription.py | 6 +++--- 7 files changed, 24 insertions(+), 26 deletions(-) diff --git a/rtv/__main__.py b/rtv/__main__.py index ff470df..40ed843 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -14,6 +14,7 @@ from .oauth import OAuthHelper from .terminal import Terminal from .objects import curses_session from .subreddit import SubredditPage +from .exceptions import ConfigError from .__version__ import __version__ @@ -106,6 +107,9 @@ def main(): # Launch the subreddit page page.loop() + except ConfigError as e: + _logger.exception(e) + print(e) except Exception as e: _logger.exception(e) raise diff --git a/rtv/exceptions.py b/rtv/exceptions.py index 8e4da1d..3e8fa6a 100644 --- a/rtv/exceptions.py +++ b/rtv/exceptions.py @@ -6,18 +6,14 @@ class EscapeInterrupt(Exception): "Signal that the ESC key has been pressed" +class ConfigError(Exception): + "There was a problem with the configuration" + + class RTVError(Exception): "Base RTV error class" -class KeystringError(RTVError): - "Unable to parse key string" - - -class ConfigError(RTVError): - "There was a problem with the configuration" - - class AccountError(RTVError): "Could not access user account" diff --git a/rtv/page.py b/rtv/page.py index 0e006b1..9441c2d 100644 --- a/rtv/page.py +++ b/rtv/page.py @@ -39,7 +39,6 @@ class Page(object): self.oauth = oauth self.content = None self.nav = None - self.controller = None self.active = True self._row = 0 diff --git a/rtv/rtv.cfg b/rtv/rtv.cfg index 8404827..6e88069 100644 --- a/rtv/rtv.cfg +++ b/rtv/rtv.cfg @@ -55,12 +55,12 @@ oauth_scope = edit,history,identity,mysubreddits,privatemessages,read,report,sav ############## # Key Bindings ############## -; Multiple keys that represent the same command should be listed as a comma -; seperated list. The a mixture of the following notations should be used to -; describe keys. +; If you would like to define custom bindings, copy this section into your +; config file with the [bindings] heading. All commands must be bound to at +; least one key for the config to be valid. ; ; 1.) Plain keys can be represented by either uppercase/lowercase characters -; or the hexadecimal codes referring their ascii codes. For reference, see +; or the hexadecimal numbers referring their ascii codes. For reference, see ; https://en.wikipedia.org/wiki/ASCII#ASCII_printable_code_chart ; e.g. Q, q, 1, ? ; e.g. 0x20 (space), 0x3c (less-than sign) @@ -77,9 +77,10 @@ oauth_scope = edit,history,identity,mysubreddits,privatemessages,read,report,sav ; Notes: ; - Curses is unreliable and should always be used in conjunction ; with . +; - Use 0x20 for the space key -; General keys -EXIT = q +; Base page +EXIT = q FORCE_EXIT = Q HELP = ? SORT_HOT = 1 @@ -115,4 +116,4 @@ SUBREDDIT_OPEN_SUBSCRIPTIONS = s ; Subscription page SUBSCRIPTION_SELECT = l, , , -SUBSCRIPTION_EXIT = h, s, , \ No newline at end of file +SUBSCRIPTION_EXIT = h, s, , diff --git a/rtv/submission.py b/rtv/submission.py index bfdc814..3e9fbe2 100644 --- a/rtv/submission.py +++ b/rtv/submission.py @@ -17,14 +17,13 @@ class SubmissionController(PageController): class SubmissionPage(Page): def __init__(self, reddit, term, config, oauth, url=None, submission=None): - super(SubmissionPage, self).__init__(reddit, term, config, oauth) + self.controller = SubmissionController(self, keymap=config.keymap) + super(SubmissionPage, self).__init__(reddit, term, config, oauth) if url: self.content = SubmissionContent.from_url(reddit, url, term.loader) else: self.content = SubmissionContent(submission, term.loader) - - self.controller = SubmissionController(self, keymap=config.keymap) # Start at the submission post, which is indexed as -1 self.nav = Navigator(self.content.get, page_index=-1) diff --git a/rtv/subreddit.py b/rtv/subreddit.py index 5c14339..95e3177 100644 --- a/rtv/subreddit.py +++ b/rtv/subreddit.py @@ -10,7 +10,6 @@ from .page import Page, PageController, logged_in from .objects import Navigator, Color, Command from .submission import SubmissionPage from .subscription import SubscriptionPage -from .terminal import Terminal class SubredditController(PageController): @@ -25,10 +24,10 @@ class SubredditPage(Page): name (string): Name of subreddit to open url (string): Optional submission to load upon start """ - super(SubredditPage, self).__init__(reddit, term, config, oauth) - - self.content = SubredditContent.from_name(reddit, name, term.loader) self.controller = SubredditController(self, keymap=config.keymap) + + super(SubredditPage, self).__init__(reddit, term, config, oauth) + self.content = SubredditContent.from_name(reddit, name, term.loader) self.nav = Navigator(self.content.get) @SubredditController.register(Command('REFRESH')) diff --git a/rtv/subscription.py b/rtv/subscription.py index 89f7877..d6dbda2 100644 --- a/rtv/subscription.py +++ b/rtv/subscription.py @@ -15,10 +15,10 @@ class SubscriptionController(PageController): class SubscriptionPage(Page): def __init__(self, reddit, term, config, oauth): - super(SubscriptionPage, self).__init__(reddit, term, config, oauth) - - self.content = SubscriptionContent.from_user(reddit, term.loader) self.controller = SubscriptionController(self, keymap=config.keymap) + + super(SubscriptionPage, self).__init__(reddit, term, config, oauth) + self.content = SubscriptionContent.from_user(reddit, term.loader) self.nav = Navigator(self.content.get) self.subreddit_data = None From 5fda5a7999ab654ef95b13df8a550704f9f5011c Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Wed, 10 Feb 2016 01:02:37 -0800 Subject: [PATCH 7/8] Linter fixes. Added ctrl-d and ctrl-u for pagedown/pageup. --- rtv/objects.py | 23 ++++++++++++----------- rtv/page.py | 1 + rtv/rtv.cfg | 9 ++++++--- rtv/submission.py | 4 ++-- rtv/subreddit.py | 4 ++-- rtv/subscription.py | 4 ++-- 6 files changed, 25 insertions(+), 20 deletions(-) diff --git a/rtv/objects.py b/rtv/objects.py index 214bdde..dcd6118 100644 --- a/rtv/objects.py +++ b/rtv/objects.py @@ -4,13 +4,13 @@ from __future__ import unicode_literals import re import os import time -import curses import signal import inspect import weakref import logging import threading -from curses import ascii +import curses +import curses.ascii from contextlib import contextmanager import six @@ -545,9 +545,9 @@ class Controller(object): # different function. if controller.character_map.get(val, func) != func: raise exceptions.ConfigError( - 'Invalid configuration, cannot bind the `%s`' - ' key to two different commands in the `%s`' - ' context' % (key, self.__class__.__name__)) + "Invalid configuration! `%s` is bound to " + "duplicate commands in the " + "%s" % (key, controller.__name__)) controller.character_map[val] = func def trigger(self, char, *args, **kwargs): @@ -632,8 +632,8 @@ class KeyMap(object): try: return self._keymap[command] except KeyError: - raise exceptions.ConfigError( - 'Invalid configuration, `%s` key undefined' % command.val) + raise exceptions.ConfigError('Invalid configuration! `%s` key is ' + 'undefined' % command.val) @staticmethod def parse(key): @@ -649,7 +649,7 @@ class KeyMap(object): return getattr(curses, key[1:-1]) elif re.match('[<].*[>]', key): # Ascii control character - return getattr(ascii, key[1:-1]) + return getattr(curses.ascii, key[1:-1]) elif key.startswith('0x'): # Ascii hex code return int(key, 16) @@ -660,8 +660,9 @@ class KeyMap(object): return code # Python 3.3 has a curses.get_wch() function that we can use # for unicode keys, but Python 2.7 is limited to ascii. - raise exceptions.ConfigError( - 'Invalid key `%s`, must be in the ascii range' % key) + raise exceptions.ConfigError('Invalid configuration! `%s` is ' + 'not in the ascii range' % key) except (AttributeError, ValueError, TypeError): - raise exceptions.ConfigError('Invalid key string `%s`' % key) \ No newline at end of file + raise exceptions.ConfigError('Invalid configuration! "%s" is not a ' + 'valid key' % key) \ No newline at end of file diff --git a/rtv/page.py b/rtv/page.py index 9441c2d..0e006b1 100644 --- a/rtv/page.py +++ b/rtv/page.py @@ -39,6 +39,7 @@ class Page(object): self.oauth = oauth self.content = None self.nav = None + self.controller = None self.active = True self._row = 0 diff --git a/rtv/rtv.cfg b/rtv/rtv.cfg index 6e88069..1aa0a62 100644 --- a/rtv/rtv.cfg +++ b/rtv/rtv.cfg @@ -77,7 +77,10 @@ oauth_scope = edit,history,identity,mysubreddits,privatemessages,read,report,sav ; Notes: ; - Curses is unreliable and should always be used in conjunction ; with . -; - Use 0x20 for the space key +; - Use 0x20 for the space key. +; - A subset of Ctrl modifiers are available through the ascii control codes. +; For example, Ctrl-D will trigger an signal. See the table above for +; a complete reference. ; Base page EXIT = q @@ -90,8 +93,8 @@ SORT_NEW = 4 SORT_CONTROVERSIAL = 5 MOVE_UP = k, MOVE_DOWN = j, -PAGE_UP = m, -PAGE_DOWN = n, +PAGE_UP = m, , +PAGE_DOWN = n, , UPVOTE = a DOWNVOTE = z LOGIN = u diff --git a/rtv/submission.py b/rtv/submission.py index 3e9fbe2..5e18abd 100644 --- a/rtv/submission.py +++ b/rtv/submission.py @@ -17,9 +17,9 @@ class SubmissionController(PageController): class SubmissionPage(Page): def __init__(self, reddit, term, config, oauth, url=None, submission=None): - self.controller = SubmissionController(self, keymap=config.keymap) - super(SubmissionPage, self).__init__(reddit, term, config, oauth) + + self.controller = SubmissionController(self, keymap=config.keymap) if url: self.content = SubmissionContent.from_url(reddit, url, term.loader) else: diff --git a/rtv/subreddit.py b/rtv/subreddit.py index 95e3177..4c85fe5 100644 --- a/rtv/subreddit.py +++ b/rtv/subreddit.py @@ -24,9 +24,9 @@ class SubredditPage(Page): name (string): Name of subreddit to open url (string): Optional submission to load upon start """ - self.controller = SubredditController(self, keymap=config.keymap) - super(SubredditPage, self).__init__(reddit, term, config, oauth) + + self.controller = SubredditController(self, keymap=config.keymap) self.content = SubredditContent.from_name(reddit, name, term.loader) self.nav = Navigator(self.content.get) diff --git a/rtv/subscription.py b/rtv/subscription.py index d6dbda2..44a443d 100644 --- a/rtv/subscription.py +++ b/rtv/subscription.py @@ -15,9 +15,9 @@ class SubscriptionController(PageController): class SubscriptionPage(Page): def __init__(self, reddit, term, config, oauth): - self.controller = SubscriptionController(self, keymap=config.keymap) - super(SubscriptionPage, self).__init__(reddit, term, config, oauth) + + self.controller = SubscriptionController(self, keymap=config.keymap) self.content = SubscriptionContent.from_user(reddit, term.loader) self.nav = Navigator(self.content.get) self.subreddit_data = None From 66cb430ebec0a118aa5a39d275505ebf188bbf99 Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Wed, 10 Feb 2016 01:17:47 -0800 Subject: [PATCH 8/8] Typo in docstring. --- rtv/objects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rtv/objects.py b/rtv/objects.py index dcd6118..f64e3cb 100644 --- a/rtv/objects.py +++ b/rtv/objects.py @@ -505,7 +505,7 @@ class Controller(object): >>> ... Register a KeyBinding that can be defined later by the config file - >>> @Controller.register(KeyMap.UPVOTE) + >>> @Controller.register(Command("UPVOTE")) >>> def upvote(self, *args) >> ...