Squashed commit of the following:

Updated the supported python versions list.
    Fixed regression in displaying xposts. #173.
    Fixing a few style things.
    Added a more robust test for the tornado handler.
    Trying without pytest-cov
    Updated travis for coverage.
    Remove python 3.2 support because no unicode literals, following what praw supports.
    "Side effect is not iterable."
    Added requirements for travis.
    Renamed travis file correctly.
    Adding test configurations, got tox working.
    Adding vcr cassettes to the repo.
    Renamed requirements files.
    Split up tests and cleaned up test names.
    Tests done, still one failure.
    Treat cassettes as binary to prevent bad merging.
    Fixed a few broken tests.
    Added a timeout to notifications.
    Prepping subreddit page.
    Finished submission page tests.
    Working on submission tests.
    Fixed vcr matching on urls with params, started submission tests.
    Log cleanup.
    Still trying to fix a broken test.
    -Fixed a few pytest bugs and tweaked logging.
    Still working on subscription tests.
    Finished page tests, on to subscription page.
    Finished content tests and starting page tests.
    Added the test refresh-token file to gitignore.
    Moved functional test file out of the repository.
    Continuing work on subreddit content tests.
    Tests now match module names, cassettes are split into individual tests for faster loading.
    Linter fixes.
    Cleanup.
    Added support for nested loaders.
    Added pytest options, starting subreddit content tests.
    Back on track with loader, continuing content tests.
    Finishing submission content tests and discovered snag with loader exception handling.
    VCR up and running, continuing to implement content tests.
    Playing around with vcr.py
    Moved helper functions into terminal and new objects.py
    Fixed a few broken tests.
    Working on navigator tests.
    Reorganizing some things.
    Mocked webbrowser._tryorder for terminal test.
    Completed oauth tests.
    Progress on the oauth tests.
    Working on adding fake tornado request.
    Starting on OAuth tool tests.
    Finished curses helpers tests.
    Still working on curses helpers tests.
    Almost finished with tests on curses helpers.
    Adding tests and working on mocking stdscr.
    Starting to add tests for curses functions.
    Merge branch 'future_work' of https://github.com/michael-lazar/rtv into future_work
    Refactoring controller, still in progress.
    Renamed auth handler.
    Rename CursesHelper to CursesBase.
    Added temporary file with a possible template for func testing.
    Mixup between basename and dirname.
    Merge branch 'future_work' of https://github.com/michael-lazar/rtv into future_work
    py3 compatability for mock.
    Beginning to refactor the curses session.
    Started adding tests, improved unicode handling in the config.
    Cleanup, fixed a few typos.
    Major refactor, almost done!.
    Started a config class.
    Merge branch 'master' into future_work
    The editor now handles unicode characters in all situations.
    Fixed a few typos from previous commits.
    __main__.py formatting.
    Cleaned up history logic and moved to the config file.
This commit is contained in:
Michael Lazar
2015-12-02 22:37:50 -08:00
parent b91bb86e36
commit a7b789bfd9
70 changed files with 42141 additions and 1560 deletions

View File

@@ -1,257 +1,47 @@
import curses
import time
import six
import sys
import logging
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import sys
import time
import curses
from functools import wraps
import praw.errors
import requests
from kitchen.text.display import textual_width
from .helpers import open_editor
from .curses_helpers import (Color, show_notification, show_help, prompt_input,
add_line)
from .docs import COMMENT_EDIT_FILE, SUBMISSION_FILE
__all__ = ['Navigator', 'BaseController', 'BasePage']
_logger = logging.getLogger(__name__)
from . import docs
from .objects import Controller, Color
class Navigator(object):
def logged_in(f):
"""
Handles math behind cursor movement and screen paging.
Decorator for Page methods that require the user to be authenticated.
"""
def __init__(
self,
valid_page_cb,
page_index=0,
cursor_index=0,
inverted=False):
self.page_index = page_index
self.cursor_index = cursor_index
self.inverted = inverted
self._page_cb = valid_page_cb
self._header_window = None
self._content_window = None
@property
def step(self):
return 1 if not self.inverted else -1
@property
def position(self):
return (self.page_index, self.cursor_index, self.inverted)
@property
def absolute_index(self):
return self.page_index + (self.step * self.cursor_index)
def move(self, direction, n_windows):
"Move the cursor down (positive direction) or up (negative direction)"
valid, redraw = True, False
forward = ((direction * self.step) > 0)
if forward:
if self.page_index < 0:
if self._is_valid(0):
# Special case - advance the page index if less than zero
self.page_index = 0
self.cursor_index = 0
redraw = True
else:
valid = False
else:
self.cursor_index += 1
if not self._is_valid(self.absolute_index):
# Move would take us out of bounds
self.cursor_index -= 1
valid = False
elif self.cursor_index >= (n_windows - 1):
# Flip the orientation and reset the cursor
self.flip(self.cursor_index)
self.cursor_index = 0
redraw = True
else:
if self.cursor_index > 0:
self.cursor_index -= 1
else:
self.page_index -= self.step
if self._is_valid(self.absolute_index):
# We have reached the beginning of the page - move the
# index
redraw = True
else:
self.page_index += self.step
valid = False # Revert
return valid, redraw
def move_page(self, direction, n_windows):
"""
Move page down (positive direction) or up (negative direction).
"""
# top of subreddit/submission page or only one
# submission/reply on the screen: act as normal move
if (self.absolute_index < 0) | (n_windows == 0):
valid, redraw = self.move(direction, n_windows)
else:
# first page
if self.absolute_index < n_windows and direction < 0:
self.page_index = -1
self.cursor_index = 0
self.inverted = False
# not submission mode: starting index is 0
if not self._is_valid(self.absolute_index):
self.page_index = 0
valid = True
else:
# flip to the direction of movement
if ((direction > 0) & (self.inverted is True))\
| ((direction < 0) & (self.inverted is False)):
self.page_index += (self.step * (n_windows-1))
self.inverted = not self.inverted
self.cursor_index \
= (n_windows-(direction < 0)) - self.cursor_index
valid = False
adj = 0
# check if reached the bottom
while not valid:
n_move = n_windows - adj
if n_move == 0:
break
self.page_index += n_move * direction
valid = self._is_valid(self.absolute_index)
if not valid:
self.page_index -= n_move * direction
adj += 1
redraw = True
return valid, redraw
def flip(self, n_windows):
"Flip the orientation of the page"
self.page_index += (self.step * n_windows)
self.cursor_index = n_windows
self.inverted = not self.inverted
def _is_valid(self, page_index):
"Check if a page index will cause entries to fall outside valid range"
try:
self._page_cb(page_index)
except IndexError:
return False
else:
return True
@wraps(f)
def wrapped_method(self, *args, **kwargs):
if not self.reddit.is_oauth_session():
self.term.show_notification('Not logged in')
return
return f(self, *args, **kwargs)
return wrapped_method
class SafeCaller(object):
def __init__(self, window):
self.window = window
self.catch = True
def __enter__(self):
return self
def __exit__(self, exc_type, e, exc_tb):
if self.catch:
if isinstance(e, praw.errors.APIException):
message = ['Error: {}'.format(e.error_type), e.message]
show_notification(self.window, message)
_logger.exception(e)
return True
elif isinstance(e, praw.errors.ClientException):
message = ['Error: Client Exception', e.message]
show_notification(self.window, message)
_logger.exception(e)
return True
elif isinstance(e, requests.HTTPError):
show_notification(self.window, ['Unexpected Error'])
_logger.exception(e)
return True
elif isinstance(e, requests.ConnectionError):
show_notification(self.window, ['Unexpected Error'])
_logger.exception(e)
return True
class PageController(Controller):
character_map = {}
class BaseController(object):
"""
Event handler for triggering functions with curses keypresses.
class Page(object):
Register a keystroke to a class method using the @egister decorator.
#>>> @Controller.register('a', 'A')
#>>> def func(self, *args)
def __init__(self, reddit, term, config, oauth):
Register a default behavior by using `None`.
#>>> @Controller.register(None)
#>>> def default_func(self, *args)
Bind the controller to a class instance and trigger a key. Additional
arguments will be passed to the function.
#>>> controller = Controller(self)
#>>> controller.trigger('a', *args)
"""
character_map = {None: (lambda *args, **kwargs: None)}
def __init__(self, instance):
self.instance = instance
def trigger(self, char, *args, **kwargs):
if isinstance(char, six.string_types) and len(char) == 1:
char = ord(char)
func = self.character_map.get(char)
if func is None:
func = BaseController.character_map.get(char)
if func is None:
func = self.character_map.get(None)
if func is None:
func = BaseController.character_map.get(None)
return func(self.instance, *args, **kwargs)
@classmethod
def register(cls, *chars):
def wrap(f):
for char in chars:
if isinstance(char, six.string_types) and len(char) == 1:
cls.character_map[ord(char)] = f
else:
cls.character_map[char] = f
return f
return wrap
class BasePage(object):
"""
Base terminal viewer incorporates a cursor to navigate content
"""
MIN_HEIGHT = 10
MIN_WIDTH = 20
def __init__(self, stdscr, reddit, content, oauth, **kwargs):
self.stdscr = stdscr
self.reddit = reddit
self.content = content
self.term = term
self.config = config
self.oauth = oauth
self.nav = Navigator(self.content.get, **kwargs)
self.content = None
self.nav = None
self.controller = None
self.active = True
self._header_window = None
self._content_window = None
self._subwindows = None
@@ -260,101 +50,114 @@ class BasePage(object):
raise NotImplementedError
@staticmethod
def draw_item(window, data, inverted):
def _draw_item(window, data, inverted):
raise NotImplementedError
@BaseController.register('q')
def loop(self):
"""
Main control loop runs the following steps:
1. Re-draw the screen
2. Wait for user to press a key (includes terminal resizing)
3. Trigger the method registered to the input key
The loop will run until self.active is set to False from within one of
the methods.
"""
self.active = True
while self.active:
self.draw()
ch = self.term.stdscr.getch()
self.controller.trigger(ch)
@PageController.register('q')
def exit(self):
"""
Prompt to exit the application.
"""
ch = prompt_input(self.stdscr, "Do you really want to quit? (y/n): ")
if ch == 'y':
if self.term.prompt_y_or_n('Do you really want to quit? (y/n): '):
sys.exit()
elif ch != 'n':
curses.flash()
@BaseController.register('Q')
@PageController.register('Q')
def force_exit(self):
sys.exit()
@BaseController.register('?')
def help(self):
show_help(self._content_window)
@PageController.register('?')
def show_help(self):
self.term.show_notification(docs.HELP.strip().splitlines())
@BaseController.register('1')
@PageController.register('1')
def sort_content_hot(self):
self.refresh_content(order='hot')
@BaseController.register('2')
@PageController.register('2')
def sort_content_top(self):
self.refresh_content(order='top')
@BaseController.register('3')
@PageController.register('3')
def sort_content_rising(self):
self.refresh_content(order='rising')
@BaseController.register('4')
@PageController.register('4')
def sort_content_new(self):
self.refresh_content(order='new')
@BaseController.register('5')
@PageController.register('5')
def sort_content_controversial(self):
self.refresh_content(order='controversial')
@BaseController.register(curses.KEY_UP, 'k')
@PageController.register(curses.KEY_UP, 'k')
def move_cursor_up(self):
self._move_cursor(-1)
self.clear_input_queue()
@BaseController.register(curses.KEY_DOWN, 'j')
@PageController.register(curses.KEY_DOWN, 'j')
def move_cursor_down(self):
self._move_cursor(1)
self.clear_input_queue()
@BaseController.register('n', curses.KEY_NPAGE)
def move_page_down(self):
self._move_page(1)
self.clear_input_queue()
@BaseController.register('m', curses.KEY_PPAGE)
@PageController.register('m', curses.KEY_PPAGE)
def move_page_up(self):
self._move_page(-1)
self.clear_input_queue()
@BaseController.register('a')
@PageController.register('n', curses.KEY_NPAGE)
def move_page_down(self):
self._move_page(1)
self.clear_input_queue()
@PageController.register('a')
@logged_in
def upvote(self):
data = self.content.get(self.nav.absolute_index)
try:
if 'likes' not in data:
pass
elif data['likes']:
if 'likes' not in data:
self.term.flash()
elif data['likes']:
with self.term.loader():
data['object'].clear_vote()
if not self.term.loader.exception:
data['likes'] = None
else:
else:
with self.term.loader():
data['object'].upvote()
if not self.term.loader.exception:
data['likes'] = True
except praw.errors.LoginOrScopeRequired:
show_notification(self.stdscr, ['Not logged in'])
@BaseController.register('z')
@PageController.register('z')
@logged_in
def downvote(self):
data = self.content.get(self.nav.absolute_index)
try:
if 'likes' not in data:
pass
elif data['likes'] or data['likes'] is None:
if 'likes' not in data:
self.term.flash()
elif data['likes'] or data['likes'] is None:
with self.term.loader():
data['object'].downvote()
if not self.term.loader.exception:
data['likes'] = False
else:
else:
with self.term.loader():
data['object'].clear_vote()
if not self.term.loader.exception:
data['likes'] = None
except praw.errors.LoginOrScopeRequired:
show_notification(self.stdscr, ['Not logged in'])
@BaseController.register('u')
@PageController.register('u')
def login(self):
"""
Prompt to log into the user's account, or log out of the current
@@ -362,138 +165,105 @@ class BasePage(object):
"""
if self.reddit.is_oauth_session():
ch = prompt_input(self.stdscr, "Log out? (y/n): ")
if ch == 'y':
if self.term.prompt_y_or_n('Log out? (y/n): '):
self.oauth.clear_oauth_data()
show_notification(self.stdscr, ['Logged out'])
elif ch != 'n':
curses.flash()
self.term.show_notification('Logged out')
else:
self.oauth.authorize()
@BaseController.register('d')
def delete(self):
@PageController.register('d')
@logged_in
def delete_item(self):
"""
Delete a submission or comment.
"""
if not self.reddit.is_oauth_session():
show_notification(self.stdscr, ['Not logged in'])
return
data = self.content.get(self.nav.absolute_index)
if data.get('author') != self.reddit.user.name:
curses.flash()
self.term.flash()
return
prompt = 'Are you sure you want to delete this? (y/n): '
char = prompt_input(self.stdscr, prompt)
if char != 'y':
show_notification(self.stdscr, ['Aborted'])
if not self.term.prompt_y_or_n(prompt):
self.term.show_notification('Aborted')
return
with self.safe_call as s:
with self.loader(message='Deleting', delay=0):
data['object'].delete()
time.sleep(2.0)
s.catch = False
with self.term.loader(message='Deleting', delay=0):
data['object'].delete()
# Give reddit time to process the request
time.sleep(2.0)
if self.term.loader.exception is None:
self.refresh_content()
@BaseController.register('e')
@PageController.register('e')
@logged_in
def edit(self):
"""
Edit a submission or comment.
"""
if not self.reddit.is_oauth_session():
show_notification(self.stdscr, ['Not logged in'])
return
data = self.content.get(self.nav.absolute_index)
if data.get('author') != self.reddit.user.name:
curses.flash()
self.term.flash()
return
if data['type'] == 'Submission':
subreddit = self.reddit.get_subreddit(self.content.name)
content = data['text']
info = SUBMISSION_FILE.format(content=content, name=subreddit)
info = docs.SUBMISSION_EDIT_FILE.format(
content=content, name=subreddit)
elif data['type'] == 'Comment':
content = data['body']
info = COMMENT_EDIT_FILE.format(content=content)
info = docs.COMMENT_EDIT_FILE.format(content=content)
else:
curses.flash()
self.term.flash()
return
text = open_editor(info)
text = self.term.open_editor(info)
if text == content:
show_notification(self.stdscr, ['Aborted'])
self.term.show_notification('Aborted')
return
with self.safe_call as s:
with self.loader(message='Editing', delay=0):
data['object'].edit(text)
time.sleep(2.0)
s.catch = False
with self.term.loader(message='Editing', delay=0):
data['object'].edit(text)
time.sleep(2.0)
if self.term.loader.exception is None:
self.refresh_content()
@BaseController.register('i')
@PageController.register('i')
@logged_in
def get_inbox(self):
"""
Checks the inbox for unread messages and displays a notification.
"""
if not self.reddit.is_oauth_session():
show_notification(self.stdscr, ['Not logged in'])
return
inbox = len(list(self.reddit.get_unread(limit=1)))
try:
if inbox > 0:
show_notification(self.stdscr, ['New Messages'])
elif inbox == 0:
show_notification(self.stdscr, ['No New Messages'])
except praw.errors.LoginOrScopeRequired:
show_notification(self.stdscr, ['Not Logged In'])
message = 'New Messages' if inbox > 0 else 'No New Messages'
self.term.show_notification(message)
def clear_input_queue(self):
"""
Clear excessive input caused by the scroll wheel or holding down a key
"""
self.stdscr.nodelay(1)
while self.stdscr.getch() != -1:
continue
self.stdscr.nodelay(0)
@property
def safe_call(self):
"""
Wrap praw calls with extended error handling.
If a PRAW related error occurs inside of this context manager, a
notification will be displayed on the screen instead of the entire
application shutting down. This function will return a callback that
can be used to check the status of the call.
Usage:
#>>> with self.safe_call as s:
#>>> self.reddit.submit(...)
#>>> s.catch = False
#>>> on_success()
"""
return SafeCaller(self.stdscr)
with self.term.no_delay():
while self.term.getch() != -1:
continue
def draw(self):
n_rows, n_cols = self.stdscr.getmaxyx()
if n_rows < self.MIN_HEIGHT or n_cols < self.MIN_WIDTH:
window = self.term.stdscr
n_rows, n_cols = window.getmaxyx()
if n_rows < self.term.MIN_HEIGHT or n_cols < self.term.MIN_WIDTH:
# TODO: Will crash when you try to navigate if the terminal is too
# small at startup because self._subwindows will never be populated
return
# Note: 2 argument form of derwin breaks PDcurses on Windows 7!
self._header_window = self.stdscr.derwin(1, n_cols, 0, 0)
self._content_window = self.stdscr.derwin(n_rows - 1, n_cols, 1, 0)
self._header_window = window.derwin(1, n_cols, 0, 0)
self._content_window = window.derwin(n_rows - 1, n_cols, 1, 0)
self.stdscr.erase()
window.erase()
self._draw_header()
self._draw_content()
self._add_cursor()
@@ -503,20 +273,26 @@ class BasePage(object):
n_rows, n_cols = self._header_window.getmaxyx()
self._header_window.erase()
attr = curses.A_REVERSE | curses.A_BOLD | Color.CYAN
self._header_window.bkgd(' ', attr)
# curses.bkgd expects bytes in py2 and unicode in py3
ch, attr = str(' '), curses.A_REVERSE | curses.A_BOLD | Color.CYAN
self._header_window.bkgd(ch, attr)
sub_name = self.content.name.replace('/r/front', 'Front Page')
add_line(self._header_window, sub_name, 0, 0)
self.term.add_line(self._header_window, sub_name, 0, 0)
if self.content.order is not None:
add_line(self._header_window, ' [{}]'.format(self.content.order))
order = ' [{}]'.format(self.content.order)
self.term.add_line(self._header_window, order)
if self.reddit.user is not None:
# The starting position of the name depends on if we're converting
# to ascii or not
width = len if self.config['ascii'] else textual_width
username = self.reddit.user.name
s_col = (n_cols - textual_width(username) - 1)
s_col = (n_cols - width(username) - 1)
# Only print username if it fits in the empty space on the right
if (s_col - 1) >= textual_width(sub_name):
add_line(self._header_window, username, 0, s_col)
if (s_col - 1) >= width(sub_name):
self.term.add_line(self._header_window, username, 0, s_col)
self._header_window.refresh()
@@ -543,7 +319,7 @@ class BasePage(object):
start = current_row - window_rows if inverted else current_row
subwindow = self._content_window.derwin(
window_rows, window_cols, start, data['offset'])
attr = self.draw_item(subwindow, data, inverted)
attr = self._draw_item(subwindow, data, inverted)
self._subwindows.append((subwindow, attr))
available_rows -= (window_rows + 1) # Add one for the blank line
current_row += step * (window_rows + 1)
@@ -571,7 +347,7 @@ class BasePage(object):
self._remove_cursor()
valid, redraw = self.nav.move(direction, len(self._subwindows))
if not valid:
curses.flash()
self.term.flash()
# Note: ACS_VLINE doesn't like changing the attribute,
# so always redraw.
@@ -582,14 +358,14 @@ class BasePage(object):
self._remove_cursor()
valid, redraw = self.nav.move_page(direction, len(self._subwindows)-1)
if not valid:
curses.flash()
self.term.flash()
# Note: ACS_VLINE doesn't like changing the attribute,
# so always redraw.
self._draw_content()
self._add_cursor()
def _edit_cursor(self, attribute=None):
def _edit_cursor(self, attribute):
# Don't allow the cursor to go below page index 0
if self.nav.absolute_index < 0:
@@ -599,7 +375,7 @@ class BasePage(object):
# This could happen if the window is resized and the cursor index is
# pushed out of bounds
if self.nav.cursor_index >= len(self._subwindows):
self.nav.cursor_index = len(self._subwindows)-1
self.nav.cursor_index = len(self._subwindows) - 1
window, attr = self._subwindows[self.nav.cursor_index]
if attr is not None:
@@ -609,4 +385,4 @@ class BasePage(object):
for row in range(n_rows):
window.chgat(row, 0, 1, attribute)
window.refresh()
window.refresh()