From 7d9c8ad0d429e0ad66484c640ebd4e296e3ad3f6 Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Fri, 20 Mar 2015 02:12:01 -0700 Subject: [PATCH] Major refactor, package cleanup. Untested. --- rtv/__init__.py | 6 + rtv/__main__.py | 108 +++++++++- rtv/__version__.py | 1 + rtv/config.py | 5 + rtv/content.py | 77 ++----- rtv/{utils.py => curses_helpers.py} | 300 ++++++++++++++-------------- rtv/docs.py | 46 +++++ rtv/errors.py | 12 -- rtv/exceptions.py | 12 ++ rtv/helpers.py | 127 ++++++++++++ rtv/main.py | 106 ---------- rtv/page.py | 15 +- rtv/submission.py | 41 ++-- rtv/subreddit.py | 49 ++--- rtv/workers.py | 95 --------- setup.py | 6 +- version.py | 1 + 17 files changed, 526 insertions(+), 481 deletions(-) create mode 100644 rtv/__version__.py create mode 100644 rtv/config.py rename rtv/{utils.py => curses_helpers.py} (51%) create mode 100644 rtv/docs.py delete mode 100644 rtv/errors.py create mode 100644 rtv/exceptions.py create mode 100644 rtv/helpers.py delete mode 100644 rtv/main.py delete mode 100644 rtv/workers.py create mode 120000 version.py diff --git a/rtv/__init__.py b/rtv/__init__.py index e69de29..2004061 100644 --- a/rtv/__init__.py +++ b/rtv/__init__.py @@ -0,0 +1,6 @@ +from .__version__ import __version__ + +__title__ = 'Reddit Terminal Viewer' +__author__ = 'Michael Lazar' +__license__ = 'The MIT License (MIT)' +__copyright__ = '(c) 2015 Michael Lazar' \ No newline at end of file diff --git a/rtv/__main__.py b/rtv/__main__.py index 846ccc1..a44d87f 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -1,7 +1,105 @@ -# Entry point for rtv module -# Run by moving into the top level directory (the one with setup.py) -# and typing "python -m rtv" +import os +import sys +import argparse +import locale +import logging -from rtv.main import main +import requests +import praw +import praw.errors +from six.moves import configparser -main() +from . import config +from .exceptions import SubmissionError, SubredditError +from .curses_helpers import curses_session +from .submission import SubmissionPage +from .subreddit import SubredditPage +from .docs import * + +__all__ = [] + +def load_config(): + """ + Search for a configuration file at the location ~/.rtv and attempt to load + saved settings for things like the username and password. + """ + + config_path = os.path.join(os.path.expanduser('~'), '.rtv') + config = configparser.ConfigParser() + config.read(config_path) + + defaults = {} + if config.has_section('rtv'): + defaults = dict(config.items('rtv')) + + return defaults + +def command_line(): + + parser = argparse.ArgumentParser( + prog='rtv', description=SUMMARY, + epilog=CONTROLS+HELP, + formatter_class=argparse.RawDescriptionHelpFormatter) + + parser.add_argument('-s', dest='subreddit', help='subreddit name') + parser.add_argument('-l', dest='link', help='full link to a submission') + parser.add_argument('--unicode', action='store_true', + help='enable unicode (experimental)') + parser.add_argument('--log', action='store', + help='Log all HTTP requests to the given file') + + group = parser.add_argument_group('authentication (optional)', AUTH) + group.add_argument('-u', dest='username', help='reddit username') + group.add_argument('-p', dest='password', help='reddit password') + + args = parser.parse_args() + + return args + +def main(): + "Main entry point" + + # logging.basicConfig(level=logging.DEBUG, filename='rtv.log') + locale.setlocale(locale.LC_ALL, '') + + args = command_line() + local_config = load_config() + + # Fill in empty arguments with config file values. Paramaters explicitly + # typed on the command line will take priority over config file params. + for key, val in local_config.items(): + if getattr(args, key) is None: + setattr(args, key, val) + + config.unicode = args.unicode + + if args.log: + logging.basicConfig(level=logging.DEBUG, filename=args.log) + + try: + print('Connecting...') + reddit = praw.Reddit(user_agent=AGENT) + reddit.config.decode_html_entities = True + if args.username: + # PRAW will prompt for password if it is None + reddit.login(args.username, args.password) + with curses_session() as stdscr: + if args.link: + page = SubmissionPage(stdscr, reddit, url=args.link) + page.loop() + page = SubredditPage(stdscr, reddit, args.subreddit) + page.loop() + except praw.errors.InvalidUserPass: + print('Invalid password for username: {}'.format(args.username)) + except requests.ConnectionError: + print('Connection timeout') + except requests.HTTPError: + print('HTTP Error: 404 Not Found') + except SubmissionError as e: + print('Could not reach submission URL: {}'.format(e.url)) + except SubredditError as e: + print('Could not reach subreddit: {}'.format(e.name)) + except KeyboardInterrupt: + return + +sys.exit(main()) \ No newline at end of file diff --git a/rtv/__version__.py b/rtv/__version__.py new file mode 100644 index 0000000..16d5bab --- /dev/null +++ b/rtv/__version__.py @@ -0,0 +1 @@ +__version__ = '1.0.post1' diff --git a/rtv/config.py b/rtv/config.py new file mode 100644 index 0000000..20223d2 --- /dev/null +++ b/rtv/config.py @@ -0,0 +1,5 @@ +""" +Global configuration settings +""" + +unicode = False \ No newline at end of file diff --git a/rtv/content.py b/rtv/content.py index 7d6807a..42b3224 100644 --- a/rtv/content.py +++ b/rtv/content.py @@ -1,57 +1,12 @@ import textwrap -from datetime import datetime -from contextlib import contextmanager import praw -import six import requests -from .errors import SubmissionURLError, SubredditNameError +from .exceptions import SubmissionError, SubredditError +from .helpers import humanize_timestamp, wrap_text, strip_subreddit_url -def split_text(big_text, width): - return [ - text for line in big_text.splitlines() - # wrap returns an empty list when "line" is a newline. In order to - # consider newlines we need a list containing an empty string. - for text in (textwrap.wrap(line, width=width) or [''])] - -def strip_subreddit_url(permalink): - """ - Grab the subreddit from the permalink because submission.subreddit.url - makes a seperate call to the API. - """ - - subreddit = permalink.split('/')[4] - return '/r/{}'.format(subreddit) - -def humanize_timestamp(utc_timestamp, verbose=False): - """ - Convert a utc timestamp into a human readable relative-time. - """ - - timedelta = datetime.utcnow() - datetime.utcfromtimestamp(utc_timestamp) - - seconds = int(timedelta.total_seconds()) - if seconds < 60: - return 'moments ago' if verbose else '0min' - minutes = seconds // 60 - if minutes < 60: - return ('%d minutes ago' % minutes) if verbose else ('%dmin' % minutes) - hours = minutes // 60 - if hours < 24: - return ('%d hours ago' % hours) if verbose else ('%dhr' % hours) - days = hours // 24 - if days < 30: - return ('%d days ago' % days) if verbose else ('%dday' % days) - months = days // 30.4 - if months < 12: - return ('%d months ago' % months) if verbose else ('%dmonth' % months) - years = months // 12 - return ('%d years ago' % years) if verbose else ('%dyr' % years) - -@contextmanager -def default_loader(): - yield +__all__ = ['SubredditContent', 'SubmissionContent'] class BaseContent(object): @@ -149,7 +104,6 @@ class BaseContent(object): return data - class SubmissionContent(BaseContent): """ Grab a submission from PRAW and lazily store comments to an internal @@ -159,7 +113,7 @@ class SubmissionContent(BaseContent): def __init__( self, submission, - loader=default_loader, + loader, indent_size=2, max_indent_level=4): @@ -178,7 +132,7 @@ class SubmissionContent(BaseContent): cls, reddit, url, - loader=default_loader, + loader, indent_size=2, max_indent_level=4): @@ -186,7 +140,7 @@ class SubmissionContent(BaseContent): with loader(): submission = reddit.get_submission(url, comment_sort='hot') except praw.errors.APIException: - raise SubmissionURLError(url) + raise SubmissionError(url) return cls(submission, loader, indent_size, max_indent_level) @@ -202,8 +156,8 @@ class SubmissionContent(BaseContent): elif index == -1: data = self._submission_data data['split_title'] = textwrap.wrap(data['title'], width=n_cols-2) - data['split_text'] = split_text(data['text'], width=n_cols-2) - data['n_rows'] = (len(data['split_title']) + len(data['split_text']) + 5) + data['split_text'] = wrap_text(data['text'], width=n_cols-2) + data['n_rows'] = len(data['split_title'])+len(data['split_text'])+5 data['offset'] = 0 else: @@ -212,7 +166,8 @@ class SubmissionContent(BaseContent): data['offset'] = indent_level * self.indent_size if data['type'] == 'Comment': - data['split_body'] = split_text(data['body'], width=n_cols-data['offset']) + width = n_cols - data['offset'] + data['split_body'] = wrap_text(data['body'], width=width) data['n_rows'] = len(data['split_body']) + 1 else: data['n_rows'] = 1 @@ -257,7 +212,8 @@ class SubmissionContent(BaseContent): elif data['type'] == 'MoreComments': with self._loader(): comments = data['object'].comments(update=False) - comments = self.flatten_comments(comments, root_level=data['level']) + comments = self.flatten_comments(comments, + root_level=data['level']) comment_data = [self.strip_praw_comment(c) for c in comments] self._comment_data[index:index+1] = comment_data @@ -281,6 +237,9 @@ class SubredditContent(BaseContent): @classmethod def from_name(cls, reddit, name, loader, order='hot'): + if name is None: + name = 'front' + name = name.strip(' /') # Strip leading and trailing backslashes if name.startswith('r/'): name = name[2:] @@ -306,7 +265,7 @@ class SubredditContent(BaseContent): elif order == 'controversial': submissions = reddit.get_controversial(limit=None) else: - raise SubredditNameError(display_name) + raise SubredditError(display_name) else: subreddit = reddit.get_subreddit(name) @@ -321,7 +280,7 @@ class SubredditContent(BaseContent): elif order == 'controversial': submissions = subreddit.get_controversial(limit=None) else: - raise SubredditNameError(display_name) + raise SubredditError(display_name) # Verify that content exists for the given submission generator. # This is necessary because PRAW loads submissions lazily, and @@ -331,7 +290,7 @@ class SubredditContent(BaseContent): try: content.get(0) except (praw.errors.APIException, requests.HTTPError): - raise SubredditNameError(display_name) + raise SubredditError(display_name) return content diff --git a/rtv/utils.py b/rtv/curses_helpers.py similarity index 51% rename from rtv/utils.py rename to rtv/curses_helpers.py index 10abd39..ac37ff2 100644 --- a/rtv/utils.py +++ b/rtv/curses_helpers.py @@ -1,72 +1,154 @@ import os +import time +import threading import curses from curses import textpad, ascii -from contextlib import contextmanager -import six -from six.moves import configparser +from .docs import HELP +from .helpers import strip_textpad -from .errors import EscapePressed +__all__ = ['ESCAPE', 'UARROW', 'DARROW', 'BULLET', 'show_notification', + 'show_help', 'LoadScreen', 'Color', 'text_input', 'curses_session'] -HELP = """ -Global Commands - `UP/DOWN` or `j/k` : Scroll to the prev/next item - `a/z` : Upvote/downvote the selected item - `r` : Refresh the current page - `q` : Quit the program - `o` : Open the selected item in the default web browser - `?` : Show this help message +ESCAPE = 27 -Subreddit Mode - `RIGHT` or `l` : View comments for the selected submission - `/` : Open a prompt to switch subreddits +# Curses does define constants for these (e.g. curses.ACS_BULLET) +# However, they rely on using the curses.addch() function, which has been +# found to be buggy and a PITA to work with. By defining them as unicode +# points they can be added via the more reliable curses.addstr(). +# http://bugs.python.org/issue21088 +UARROW = u'\u25b2'.encode('utf-8') +DARROW = u'\u25bc'.encode('utf-8') +BULLET = u'\u2022'.encode('utf-8') -Submission Mode - `LEFT` or `h` : Return to subreddit mode - `RIGHT` or `l` : Fold the selected comment, or load additional comments -""" +def show_notification(stdscr, message): + """ + Overlay a message box on the center of the screen and wait for user input. -class Symbol(object): + Params: + message (list): List of strings, one per line. + """ - UNICODE = False + n_rows, n_cols = stdscr.getmaxyx() - ESCAPE = 27 + box_width = max(map(len, message)) + 2 + box_height = len(message) + 2 - # Curses does define constants for these (e.g. curses.ACS_BULLET) - # However, they rely on using the curses.addch() function, which has been - # found to be buggy and a PITA to work with. By defining them as unicode - # points they can be added via the more reliable curses.addstr(). - # http://bugs.python.org/issue21088 - UARROW = u'\u25b2'.encode('utf-8') - DARROW = u'\u25bc'.encode('utf-8') - BULLET = u'\u2022'.encode('utf-8') + # Make sure the window is large enough to fit the message + if (box_width > n_cols) or (box_height > n_rows): + curses.flash() + return - @classmethod - def clean(cls, string): + s_row = (n_rows - box_height) // 2 + s_col = (n_cols - box_width) // 2 + + window = stdscr.derwin(box_height, box_width, s_row, s_col) + window.erase() + window.border() + + for index, line in enumerate(message, start=1): + window.addstr(index, 1, line) + window.refresh() + stdscr.getch() + + window.clear() + window = None + stdscr.refresh() + +def show_help(stdscr): + """ + Overlay a message box with the help screen. + """ + show_notification(stdscr, docs.HELP.split("\n")) + +class LoadScreen(object): + """ + Display a loading dialog while waiting for a blocking action to complete. + + This class spins off a seperate thread to animate the loading screen in the + background. + + Usage: + #>>> loader = LoadScreen(stdscr) + #>>> with loader(...): + #>>> blocking_request(...) + """ + + def __init__(self, stdscr): + + self._stdscr = stdscr + + self._args = None + self._animator = None + self._is_running = None + + def __call__( + self, + delay=0.5, + interval=0.4, + message='Downloading', + trail='...'): """ - Required reading! - http://nedbatchelder.com/text/unipain.html - - Python 2 input string will be a unicode type (unicode code points). Curses - will accept that if all of the points are in the ascii range. However, if - any of the code points are not valid ascii curses will throw a - UnicodeEncodeError: 'ascii' codec can't encode character, ordinal not in - range(128). However, if we encode the unicode to a utf-8 byte string and - pass that to curses, curses will render correctly. - - Python 3 input string will be a string type (unicode code points). Curses - will accept that in all cases. However, the n character count in addnstr - will get screwed up. - + Params: + delay (float): Length of time that the loader will wait before + printing on the screen. Used to prevent flicker on pages that + load very fast. + interval (float): Length of time between each animation frame. + message (str): Message to display + trail (str): Trail of characters that will be animated by the + loading screen. """ - encoding = 'utf-8' if cls.UNICODE else 'ascii' - string = string.encode(encoding, 'replace') - return string + self._args = (delay, interval, message, trail) + return self + + def __enter__(self): + + self._animator = threading.Thread(target=self.animate, args=self._args) + self._animator.daemon = True + + self._is_running = True + self._animator.start() + + def __exit__(self, exc_type, exc_val, exc_tb): + + self._is_running = False + self._animator.join() + + def animate(self, delay, interval, message, trail): + + start = time.time() + while (time.time() - start) < delay: + if not self._is_running: + return + + message_len = len(message) + len(trail) + n_rows, n_cols = self._stdscr.getmaxyx() + s_row = (n_rows - 3) // 2 + s_col = (n_cols - message_len - 1) // 2 + window = self._stdscr.derwin(3, message_len+2, s_row, s_col) + + while True: + for i in range(len(trail)+1): + + if not self._is_running: + window.clear() + window = None + self._stdscr.refresh() + return + + window.erase() + window.border() + window.addstr(1, 1, message + trail[:i]) + window.refresh() + time.sleep(interval) class Color(object): + """ + Color attributes for curses. + """ - COLORS = { + _colors = { 'RED': (curses.COLOR_RED, -1), 'GREEN': (curses.COLOR_GREEN, -1), 'YELLOW': (curses.COLOR_YELLOW, -1), @@ -76,12 +158,6 @@ class Color(object): 'WHITE': (curses.COLOR_WHITE, -1), } - @classmethod - def get_level(cls, level): - - levels = [cls.MAGENTA, cls.CYAN, cls.GREEN, cls.YELLOW] - return levels[level % len(levels)] - @classmethod def init(cls): """ @@ -94,25 +170,15 @@ class Color(object): # Assign the terminal's default (background) color to code -1 curses.use_default_colors() - for index, (attr, code) in enumerate(cls.COLORS.items(), start=1): + for index, (attr, code) in enumerate(cls._colors.items(), start=1): curses.init_pair(index, code[0], code[1]) setattr(cls, attr, curses.color_pair(index)) -def load_config(): - """ - Search for a configuration file at the location ~/.rtv and attempt to load - saved settings for things like the username and password. - """ + @classmethod + def get_level(cls, level): - config_path = os.path.join(os.path.expanduser('~'), '.rtv') - config = configparser.ConfigParser() - config.read(config_path) - - defaults = {} - if config.has_section('rtv'): - defaults = dict(config.items('rtv')) - - return defaults + levels = [cls.MAGENTA, cls.CYAN, cls.GREEN, cls.YELLOW] + return levels[level % len(levels)] def text_input(window, allow_resize=True): """ @@ -131,23 +197,17 @@ def text_input(window, allow_resize=True): # Turn insert_mode off to avoid the recursion error described here # http://bugs.python.org/issue13051 textbox = textpad.Textbox(window, insert_mode=False) - - # Strip whitespace from the textbox 'smarter' than textpad.Textbox() does. textbox.stripspaces = 0 def validate(ch): "Filters characters for special key sequences" - - if ch == Symbol.ESCAPE: - raise EscapePressed - + if ch == ESCAPE: + raise EscapeInterrupt if (not allow_resize) and (ch == curses.KEY_RESIZE): - raise EscapePressed - + raise EscapeInterrupt # Fix backspace for iterm if ch == ascii.DEL: ch = curses.KEY_BACKSPACE - return ch # Wrapping in an exception block so that we can distinguish when the user @@ -155,84 +215,17 @@ def text_input(window, allow_resize=True): # input. try: out = textbox.edit(validate=validate) - except EscapePressed: + except EscapeInterrupt: out = None curses.curs_set(0) - - if out is None: - return out - else: - return strip_text(out) - -def strip_text(text): - "Intelligently strip whitespace from the text output of a curses textpad." - - # Trivial case where the textbox is only one line long. - if '\n' not in text: - return text.rstrip() - - # Allow one space at the end of the line. If there is more than one space, - # assume that a newline operation was intended by the user - stack, current_line = [], '' - for line in text.split('\n'): - if line.endswith(' '): - stack.append(current_line + line.rstrip()) - current_line = '' - else: - current_line += line - stack.append(current_line) - - # Prune empty lines at the bottom of the textbox. - for item in stack[::-1]: - if len(item) == 0: - stack.pop() - else: - break - - out = '\n'.join(stack) - return out - -def display_message(stdscr, message): - "Display a message box at the center of the screen and wait for a keypress" - - n_rows, n_cols = stdscr.getmaxyx() - - box_width = max(map(len, message)) + 2 - box_height = len(message) + 2 - - # Make sure the window is large enough to fit the message - # TODO: Should find a better way to display the message in this situation - if (box_width > n_cols) or (box_height > n_rows): - curses.flash() - return - - s_row = (n_rows - box_height) // 2 - s_col = (n_cols - box_width) // 2 - window = stdscr.derwin(box_height, box_width, s_row, s_col) - - window.erase() - window.border() - - for index, line in enumerate(message, start=1): - window.addstr(index, 1, line) - - window.refresh() - stdscr.getch() - - window.clear() - window = None - stdscr.refresh() - -def display_help(stdscr): - """Display a help message box at the center of the screen and wait for a - keypress""" - - help_msgs = HELP.split("\n") - display_message(stdscr, help_msgs) + return strip_textpad(out) @contextmanager def curses_session(): + """ + Setup terminal and initialize curses. + """ try: # Curses must wait for some time after the Escape key is pressed to @@ -274,8 +267,9 @@ def curses_session(): yield stdscr finally: + if stdscr is not None: stdscr.keypad(0) curses.echo() curses.nocbreak() - curses.endwin() + curses.endwin() \ No newline at end of file diff --git a/rtv/docs.py b/rtv/docs.py new file mode 100644 index 0000000..bd3d85d --- /dev/null +++ b/rtv/docs.py @@ -0,0 +1,46 @@ +from .__version__ import __version__ + +__all__ = ['AGENT', 'SUMMARY', 'AUTH', 'CONTROLS', 'HELP'] + +AGENT = """ +desktop:https://github.com/michael-lazar/rtv:{} (by /u/civilization_phaze_3) +""".format(__version__) + +SUMMARY = """ +Reddit Terminal Viewer is a lightweight browser for www.reddit.com built into a +terminal window. +""" + +AUTH = """ +Authenticating is required to vote and leave comments. For added security, it +is recommended that only you only provide a username. There will be an +additional prompt for the password if it is not provided. +""" + +CONTROLS = """ +Controls +-------- +RTV currently supports browsing both subreddits and individual submissions. +In each mode the controls are slightly different. In subreddit mode you can +browse through the top submissions on either the front page or a specific +subreddit. In submission mode you can view the self text for a submission and +browse comments. +""" + +HELP = """ +Global Commands + `UP/DOWN` or `j/k` : Scroll to the prev/next item + `a/z` : Upvote/downvote the selected item + `r` : Refresh the current page + `q` : Quit the program + `o` : Open the selected item in the default web browser + `?` : Show this help message + +Subreddit Mode + `RIGHT` or `l` : View comments for the selected submission + `/` : Open a prompt to switch subreddits + +Submission Mode + `LEFT` or `h` : Return to subreddit mode + `RIGHT` or `l` : Fold the selected comment, or load additional comments +""" \ No newline at end of file diff --git a/rtv/errors.py b/rtv/errors.py deleted file mode 100644 index 6e70a57..0000000 --- a/rtv/errors.py +++ /dev/null @@ -1,12 +0,0 @@ -class EscapePressed(Exception): - pass - -class SubmissionURLError(Exception): - - def __init__(self, url): - self.url = url - -class SubredditNameError(Exception): - - def __init__(self, name): - self.name = name \ No newline at end of file diff --git a/rtv/exceptions.py b/rtv/exceptions.py new file mode 100644 index 0000000..cd935bb --- /dev/null +++ b/rtv/exceptions.py @@ -0,0 +1,12 @@ +class SubmissionError(Exception): + "Submission could not be loaded" + def __init__(self, url): + self.url = url + +class SubredditError(Exception): + "Subreddit could not be reached" + def __init__(self, name): + self.name = name + +class EscapeInterrupt(Exception): + "Signal that the ESC key has been pressed" \ No newline at end of file diff --git a/rtv/helpers.py b/rtv/helpers.py new file mode 100644 index 0000000..5be4fc5 --- /dev/null +++ b/rtv/helpers.py @@ -0,0 +1,127 @@ +import sys +import os +import textwrap +import subprocess +from datetime import datetime + +from . import config + +__all__ = ['open_browser', 'clean', 'wrap_text', 'strip_textpad', + 'strip_subreddit_url', 'humanize_timestamp'] + +def open_browser(url): + """ + Call webbrowser.open_new_tab(url) and redirect stdout/stderr to devnull. + + This is a workaround to stop firefox from spewing warning messages to the + console. See http://bugs.python.org/issue22277 for a better description + of the problem. + """ + command = "import webbrowser; webbrowser.open_new_tab('%s')" % url + args = [sys.executable, '-c', command] + with open(os.devnull, 'ab+', 0) as null: + subprocess.check_call(args, stdout=null, stderr=null) + +def clean(string): + """ + Required reading! + http://nedbatchelder.com/text/unipain.html + + Python 2 input string will be a unicode type (unicode code points). Curses + will accept unicode if all of the points are in the ascii range. However, if + any of the code points are not valid ascii curses will throw a + UnicodeEncodeError: 'ascii' codec can't encode character, ordinal not in + range(128). If we encode the unicode to a utf-8 byte string and pass that to + curses, it will render correctly. + + Python 3 input string will be a string type (unicode code points). Curses + will accept that in all cases. However, the n character count in addnstr + will not be correct. If code points are passed to addnstr, curses will treat + each code point as one character and will not account for wide characters. + If utf-8 is passed in, addnstr will treat each 'byte' as a single character. + """ + + encoding = 'utf-8' if config.unicode else 'ascii' + string = string.encode(encoding, 'replace') + return string + +def wrap_text(text, width): + """ + Wrap text paragraphs to the given character width while preserving newlines. + """ + out = [] + for paragraph in text.splitlines(): + # Wrap returns an empty list when paragraph is a newline. In order to + # preserve newlines we substitute a list containing an empty string. + lines = textwrap.wrap(paragraph, width=width) or [''] + out.extend(lines) + return out + +def strip_textpad(text): + """ + Attempt to intelligently strip excess whitespace from the output of a + curses textpad. + """ + + if text is None: + return text + + # Trivial case where the textbox is only one line long. + if '\n' not in text: + return text.rstrip() + + # Allow one space at the end of the line. If there is more than one space, + # assume that a newline operation was intended by the user + stack, current_line = [], '' + for line in text.split('\n'): + if line.endswith(' '): + stack.append(current_line + line.rstrip()) + current_line = '' + else: + current_line += line + stack.append(current_line) + + # Prune empty lines at the bottom of the textbox. + for item in stack[::-1]: + if len(item) == 0: + stack.pop() + else: + break + + out = '\n'.join(stack) + return out + +def strip_subreddit_url(permalink): + """ + Strip a subreddit name from the subreddit's permalink. + + This is used to avoid submission.subreddit.url making a seperate API call. + """ + + subreddit = permalink.split('/')[4] + return '/r/{}'.format(subreddit) + +def humanize_timestamp(utc_timestamp, verbose=False): + """ + Convert a utc timestamp into a human readable relative-time. + """ + + timedelta = datetime.utcnow() - datetime.utcfromtimestamp(utc_timestamp) + + seconds = int(timedelta.total_seconds()) + if seconds < 60: + return 'moments ago' if verbose else '0min' + minutes = seconds // 60 + if minutes < 60: + return ('%d minutes ago' % minutes) if verbose else ('%dmin' % minutes) + hours = minutes // 60 + if hours < 24: + return ('%d hours ago' % hours) if verbose else ('%dhr' % hours) + days = hours // 24 + if days < 30: + return ('%d days ago' % days) if verbose else ('%dday' % days) + months = days // 30.4 + if months < 12: + return ('%d months ago' % months) if verbose else ('%dmonth' % months) + years = months // 12 + return ('%d years ago' % years) if verbose else ('%dyr' % years) \ No newline at end of file diff --git a/rtv/main.py b/rtv/main.py deleted file mode 100644 index d5d9f00..0000000 --- a/rtv/main.py +++ /dev/null @@ -1,106 +0,0 @@ -import argparse -import locale - -import praw -from requests.exceptions import ConnectionError, HTTPError -from praw.errors import InvalidUserPass - -from .errors import SubmissionURLError, SubredditNameError -from .utils import Symbol, curses_session, load_config, HELP -from .subreddit import SubredditPage -from .submission import SubmissionPage - -# Debugging -# import logging -# logging.basicConfig(level=logging.DEBUG, filename='rtv.log') - -locale.setlocale(locale.LC_ALL, '') - -AGENT = """ -desktop:https://github.com/michael-lazar/rtv:(by /u/civilization_phaze_3) -""" - -DESCRIPTION = """ -Reddit Terminal Viewer is a lightweight browser for www.reddit.com built into a -terminal window. -""" - -EPILOG = """ -Controls --------- -RTV currently supports browsing both subreddits and individual submissions. -In each mode the controls are slightly different. In subreddit mode you can -browse through the top submissions on either the front page or a specific -subreddit. In submission mode you can view the self text for a submission and -browse comments. -""" - -EPILOG += HELP - -def main(): - - parser = argparse.ArgumentParser( - prog='rtv', description=DESCRIPTION, epilog=EPILOG, - formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument('-s', dest='subreddit', help='subreddit name') - parser.add_argument('-l', dest='link', help='full link to a submission') - parser.add_argument('--unicode', help='enable unicode (experimental)', - action='store_true') - - group = parser.add_argument_group( - 'authentication (optional)', - 'Authenticating allows you to view your customized front page. ' - 'If only the username is given, the password will be prompted ' - 'securely.') - group.add_argument('-u', dest='username', help='reddit username') - group.add_argument('-p', dest='password', help='reddit password') - - args = parser.parse_args() - - # Try to fill in empty arguments with values from the config. - # Command line flags should always take priority! - for key, val in load_config().items(): - if getattr(args, key) is None: - setattr(args, key, val) - - Symbol.UNICODE = args.unicode - - if args.subreddit is None: - args.subreddit = 'front' - - try: - print('Connecting...') - reddit = praw.Reddit(user_agent=AGENT) - reddit.config.decode_html_entities = True - - if args.username: - # PRAW will prompt for password if it is None - reddit.login(args.username, args.password) - - with curses_session() as stdscr: - - if args.link: - page = SubmissionPage(stdscr, reddit, url=args.link) - page.loop() - - page = SubredditPage(stdscr, reddit, args.subreddit) - page.loop() - - except InvalidUserPass: - print('Invalid password for username: {}'.format(args.username)) - - except ConnectionError: - print('Connection timeout: Could not connect to http://www.reddit.com') - - except HTTPError: - print('HTTP Error: 404 Not Found') - - except SubmissionURLError as e: - print('Could not reach submission URL: {}'.format(e.url)) - - except SubredditNameError as e: - print('Could not reach subreddit: {}'.format(e.name)) - - except KeyboardInterrupt: - return - diff --git a/rtv/page.py b/rtv/page.py index 6fedf76..455d9c4 100644 --- a/rtv/page.py +++ b/rtv/page.py @@ -1,8 +1,11 @@ import curses -import praw +import praw.errors -from .utils import Color, Symbol, display_message +from .helpers import clean +from .curses_helpers import Color, show_notification + +__all__ = ['Navigator'] class Navigator(object): """ @@ -138,7 +141,7 @@ class BasePage(object): data['object'].upvote() data['likes'] = True except praw.errors.LoginOrScopeRequired: - display_message(self.stdscr, ['Login to vote']) + show_notification(self.stdscr, ['Login to vote']) def downvote(self): @@ -153,7 +156,7 @@ class BasePage(object): data['object'].downvote() data['likes'] = False except praw.errors.LoginOrScopeRequired: - display_message(self.stdscr, ['Login to vote']) + show_notification(self.stdscr, ['Login to vote']) def draw(self): @@ -183,7 +186,7 @@ class BasePage(object): self._header_window.bkgd(' ', attr) sub_name = self.content.name.replace('/r/front', 'Front Page ') - self._header_window.addnstr(0, 0, Symbol.clean(sub_name), n_cols-1) + self._header_window.addnstr(0, 0, clean(sub_name), n_cols-1) if self.reddit.user is not None: username = self.reddit.user.name @@ -191,7 +194,7 @@ class BasePage(object): # Only print the username if it fits in the empty space on the right if (s_col - 1) >= len(sub_name): n = (n_cols - s_col - 1) - self._header_window.addnstr(0, s_col, Symbol.clean(username), n) + self._header_window.addnstr(0, s_col, clean(username), n) self._header_window.refresh() diff --git a/rtv/submission.py b/rtv/submission.py index 7d18506..5cc35ac 100644 --- a/rtv/submission.py +++ b/rtv/submission.py @@ -2,12 +2,15 @@ import curses import sys import time -from praw.errors import APIException +import praw.errors from .content import SubmissionContent from .page import BasePage -from .utils import Color, Symbol, display_help, text_input from .workers import LoadScreen, open_browser +from .curses_helpers import (BULLET, UARROW, DARROW, Color, show_help, + text_input) + +__all__ = ['SubmissionPage'] class SubmissionPage(BasePage): @@ -125,31 +128,31 @@ class SubmissionPage(BasePage): row = offset if row in valid_rows: - text = Symbol.clean('{author} '.format(**data)) + text = clean('{author} '.format(**data)) attr = curses.A_BOLD attr |= (Color.BLUE if not data['is_author'] else Color.GREEN) win.addnstr(row, 1, text, n_cols-1, attr) if data['flair']: - text = Symbol.clean('{flair} '.format(**data)) + text = clean('{flair} '.format(**data)) attr = curses.A_BOLD | Color.YELLOW win.addnstr(text, n_cols-win.getyx()[1], attr) if data['likes'] is None: - text, attr = Symbol.BULLET, curses.A_BOLD + text, attr = BULLET, curses.A_BOLD elif data['likes']: - text, attr = Symbol.UARROW, (curses.A_BOLD | Color.GREEN) + text, attr = UARROW, (curses.A_BOLD | Color.GREEN) else: - text, attr = Symbol.DARROW, (curses.A_BOLD | Color.RED) + text, attr = DARROW, (curses.A_BOLD | Color.RED) win.addnstr(text, n_cols-win.getyx()[1], attr) - text = Symbol.clean(' {score} {created}'.format(**data)) + text = clean(' {score} {created}'.format(**data)) win.addnstr(text, n_cols-win.getyx()[1]) n_body = len(data['split_body']) for row, text in enumerate(data['split_body'], start=offset+1): if row in valid_rows: - text = Symbol.clean(text) + text = clean(text) win.addnstr(row, 1, text, n_cols-1) # Unfortunately vline() doesn't support custom color so we have to @@ -173,9 +176,9 @@ class SubmissionPage(BasePage): n_rows, n_cols = win.getmaxyx() n_cols -= 1 - text = Symbol.clean('{body}'.format(**data)) + text = clean('{body}'.format(**data)) win.addnstr(0, 1, text, n_cols-1) - text = Symbol.clean(' [{count}]'.format(**data)) + text = clean(' [{count}]'.format(**data)) win.addnstr(text, n_cols-win.getyx()[1], curses.A_BOLD) # Unfortunately vline() doesn't support custom color so we have to @@ -197,31 +200,31 @@ class SubmissionPage(BasePage): return for row, text in enumerate(data['split_title'], start=1): - text = Symbol.clean(text) + text = clean(text) win.addnstr(row, 1, text, n_cols, curses.A_BOLD) row = len(data['split_title']) + 1 attr = curses.A_BOLD | Color.GREEN - text = Symbol.clean('{author}'.format(**data)) + text = clean('{author}'.format(**data)) win.addnstr(row, 1, text, n_cols, attr) attr = curses.A_BOLD | Color.YELLOW - text = Symbol.clean(' {flair}'.format(**data)) + text = clean(' {flair}'.format(**data)) win.addnstr(text, n_cols-win.getyx()[1], attr) - text = Symbol.clean(' {created} {subreddit}'.format(**data)) + text = clean(' {created} {subreddit}'.format(**data)) win.addnstr(text, n_cols-win.getyx()[1]) row = len(data['split_title']) + 2 attr = curses.A_UNDERLINE | Color.BLUE - text = Symbol.clean('{url}'.format(**data)) + text = clean('{url}'.format(**data)) win.addnstr(row, 1, text, n_cols, attr) offset = len(data['split_title']) + 3 for row, text in enumerate(data['split_text'], start=offset): - text = Symbol.clean(text) + text = clean(text) win.addnstr(row, 1, text, n_cols) row = len(data['split_title']) + len(data['split_text']) + 3 - text = Symbol.clean('{score} {comments}'.format(**data)) + text = clean('{score} {comments}'.format(**data)) win.addnstr(row, 1, text, n_cols, curses.A_BOLD) win.border() @@ -273,7 +276,7 @@ class SubmissionPage(BasePage): data['object'].add_comment(comment_text) else: data['object'].reply(comment_text) - except APIException as e: + except praw.errors.APIException as e: display_message(self.stdscr, [e.message]) else: time.sleep(0.5) diff --git a/rtv/subreddit.py b/rtv/subreddit.py index 8a3a0dc..4fcd739 100644 --- a/rtv/subreddit.py +++ b/rtv/subreddit.py @@ -1,17 +1,20 @@ import curses import sys -from requests.exceptions import HTTPError +import requests -from .errors import SubredditNameError +from .exceptions import SubredditError from .page import BasePage from .submission import SubmissionPage from .content import SubredditContent -from .utils import Symbol, Color, text_input, display_message, display_help from .workers import LoadScreen, open_browser +from .curses_helpers import (BULLET, UARROW, DARROW, Color, text_input, + show_notification, show_help) + +__all__ = ['opened_links', 'SubredditPage'] # Used to keep track of browsing history across the current session -_opened_links = set() +opened_links = set() class SubredditPage(BasePage): @@ -79,11 +82,11 @@ class SubredditPage(BasePage): self.content = SubredditContent.from_name( self.reddit, name, self.loader) - except SubredditNameError: - display_message(self.stdscr, ['Invalid subreddit']) + except SubredditError: + show_notification(self.stdscr, ['Invalid subreddit']) - except HTTPError: - display_message(self.stdscr, ['Could not reach subreddit']) + except requests.HTTPError: + show_notification(self.stdscr, ['Could not reach subreddit']) else: self.nav.page_index, self.nav.cursor_index = 0, 0 @@ -112,8 +115,8 @@ class SubredditPage(BasePage): page.loop() if data['url'] == 'selfpost': - global _opened_links - _opened_links.add(data['url_full']) + global opened_links + opened_links.add(data['url_full']) def open_link(self): "Open a link with the webbrowser" @@ -121,8 +124,8 @@ class SubredditPage(BasePage): url = self.content.get(self.nav.absolute_index)['url_full'] open_browser(url) - global _opened_links - _opened_links.add(url) + global opened_links + opened_links.add(url) @staticmethod def draw_item(win, data, inverted=False): @@ -137,38 +140,38 @@ class SubredditPage(BasePage): n_title = len(data['split_title']) for row, text in enumerate(data['split_title'], start=offset): if row in valid_rows: - text = Symbol.clean(text) + text = clean(text) win.addnstr(row, 1, text, n_cols-1, curses.A_BOLD) row = n_title + offset if row in valid_rows: - seen = (data['url_full'] in _opened_links) + seen = (data['url_full'] in opened_links) link_color = Color.MAGENTA if seen else Color.BLUE attr = curses.A_UNDERLINE | link_color - text = Symbol.clean('{url}'.format(**data)) + text = clean('{url}'.format(**data)) win.addnstr(row, 1, text, n_cols-1, attr) row = n_title + offset + 1 if row in valid_rows: - text = Symbol.clean('{score} '.format(**data)) + text = clean('{score} '.format(**data)) win.addnstr(row, 1, text, n_cols-1) if data['likes'] is None: - text, attr = Symbol.BULLET, curses.A_BOLD + text, attr = BULLET, curses.A_BOLD elif data['likes']: - text, attr = Symbol.UARROW, curses.A_BOLD | Color.GREEN + text, attr = UARROW, curses.A_BOLD | Color.GREEN else: - text, attr = Symbol.DARROW, curses.A_BOLD | Color.RED + text, attr = DARROW, curses.A_BOLD | Color.RED win.addnstr(text, n_cols-win.getyx()[1], attr) - text = Symbol.clean(' {created} {comments}'.format(**data)) + text = clean(' {created} {comments}'.format(**data)) win.addnstr(text, n_cols-win.getyx()[1]) row = n_title + offset + 2 if row in valid_rows: - text = Symbol.clean('{author}'.format(**data)) + text = clean('{author}'.format(**data)) win.addnstr(row, 1, text, n_cols-1, curses.A_BOLD) - text = Symbol.clean(' {subreddit}'.format(**data)) + text = clean(' {subreddit}'.format(**data)) win.addnstr(text, n_cols-win.getyx()[1], Color.YELLOW) - text = Symbol.clean(' {flair}'.format(**data)) + text = clean(' {flair}'.format(**data)) win.addnstr(text, n_cols-win.getyx()[1], Color.RED) diff --git a/rtv/workers.py b/rtv/workers.py deleted file mode 100644 index 9bdeeaf..0000000 --- a/rtv/workers.py +++ /dev/null @@ -1,95 +0,0 @@ -import time -import sys -import os -import threading -import subprocess - -def open_browser(url): - """ - Call webbrowser.open_new_tab(url) and redirect stdout/stderr to devnull. - - This is a workaround to stop firefox from spewing warning messages to the - console. See http://bugs.python.org/issue22277 for a better description - of the problem. - """ - command = "import webbrowser; webbrowser.open_new_tab('%s')" % url - args = [sys.executable, '-c', command] - with open(os.devnull, 'ab+', 0) as null: - subprocess.check_call(args, stdout=null, stderr=null) - - -class LoadScreen(object): - """ - Display a loading dialog while waiting for a blocking action to complete. - - This class spins off a seperate thread to animate the loading screen in the - background. - - Usage: - #>>> loader = LoadScreen(stdscr) - #>>> with loader(...): - #>>> blocking_request(...) - """ - - def __init__(self, stdscr): - - self._stdscr = stdscr - - self._args = None - self._animator = None - self._is_running = None - - def __call__( - self, - delay=0.5, - interval=0.4, - message='Downloading', - trail='...'): - - self._args = (delay, interval, message, trail) - return self - - def __enter__(self): - - self._animator = threading.Thread(target=self.animate, args=self._args) - self._animator.daemon = True - - self._is_running = True - self._animator.start() - - def __exit__(self, exc_type, exc_val, exc_tb): - - self._is_running = False - self._animator.join() - - # Check for timeout error - - def animate(self, delay, interval, message, trail): - - # Delay before starting animation to avoid wasting resources if the - # wait time is very short - start = time.time() - while (time.time() - start) < delay: - if not self._is_running: - return - - message_len = len(message) + len(trail) - n_rows, n_cols = self._stdscr.getmaxyx() - s_row = (n_rows - 3) // 2 - s_col = (n_cols - message_len - 1) // 2 - window = self._stdscr.derwin(3, message_len+2, s_row, s_col) - - while True: - for i in range(len(trail)+1): - - if not self._is_running: - window.clear() - window = None - self._stdscr.refresh() - return - - window.erase() - window.border() - window.addstr(1, 1, message + trail[:i]) - window.refresh() - time.sleep(interval) \ No newline at end of file diff --git a/setup.py b/setup.py index e7cd765..5db6e13 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,5 @@ from setuptools import setup - -__version__ = '1.0.post1' +from version import __version__ as version setup( name='rtv', @@ -13,8 +12,9 @@ setup( license='MIT', keywords='reddit terminal praw curses', packages=['rtv'], + include_package_data=True, install_requires=['praw>=2.1.6', 'six', 'requests'], - entry_points={'console_scripts': ['rtv=rtv.main:main']}, + entry_points={'console_scripts': ['rtv=rtv.__main__:main']}, classifiers=[ 'Intended Audience :: End Users/Desktop', 'Environment :: Console :: Curses', diff --git a/version.py b/version.py new file mode 120000 index 0000000..301d642 --- /dev/null +++ b/version.py @@ -0,0 +1 @@ +rtv/__version__.py \ No newline at end of file