Major refactor, package cleanup. Untested.

This commit is contained in:
Michael Lazar
2015-03-20 02:12:01 -07:00
parent 9ecf7e10bc
commit 7d9c8ad0d4
17 changed files with 526 additions and 481 deletions

View File

@@ -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'

View File

@@ -1,7 +1,105 @@
# Entry point for rtv module import os
# Run by moving into the top level directory (the one with setup.py) import sys
# and typing "python -m rtv" 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())

1
rtv/__version__.py Normal file
View File

@@ -0,0 +1 @@
__version__ = '1.0.post1'

5
rtv/config.py Normal file
View File

@@ -0,0 +1,5 @@
"""
Global configuration settings
"""
unicode = False

View File

@@ -1,57 +1,12 @@
import textwrap import textwrap
from datetime import datetime
from contextlib import contextmanager
import praw import praw
import six
import requests 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): __all__ = ['SubredditContent', 'SubmissionContent']
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
class BaseContent(object): class BaseContent(object):
@@ -149,7 +104,6 @@ class BaseContent(object):
return data return data
class SubmissionContent(BaseContent): class SubmissionContent(BaseContent):
""" """
Grab a submission from PRAW and lazily store comments to an internal Grab a submission from PRAW and lazily store comments to an internal
@@ -159,7 +113,7 @@ class SubmissionContent(BaseContent):
def __init__( def __init__(
self, self,
submission, submission,
loader=default_loader, loader,
indent_size=2, indent_size=2,
max_indent_level=4): max_indent_level=4):
@@ -178,7 +132,7 @@ class SubmissionContent(BaseContent):
cls, cls,
reddit, reddit,
url, url,
loader=default_loader, loader,
indent_size=2, indent_size=2,
max_indent_level=4): max_indent_level=4):
@@ -186,7 +140,7 @@ class SubmissionContent(BaseContent):
with loader(): with loader():
submission = reddit.get_submission(url, comment_sort='hot') submission = reddit.get_submission(url, comment_sort='hot')
except praw.errors.APIException: except praw.errors.APIException:
raise SubmissionURLError(url) raise SubmissionError(url)
return cls(submission, loader, indent_size, max_indent_level) return cls(submission, loader, indent_size, max_indent_level)
@@ -202,8 +156,8 @@ class SubmissionContent(BaseContent):
elif index == -1: elif index == -1:
data = self._submission_data data = self._submission_data
data['split_title'] = textwrap.wrap(data['title'], width=n_cols-2) data['split_title'] = textwrap.wrap(data['title'], width=n_cols-2)
data['split_text'] = split_text(data['text'], width=n_cols-2) data['split_text'] = wrap_text(data['text'], width=n_cols-2)
data['n_rows'] = (len(data['split_title']) + len(data['split_text']) + 5) data['n_rows'] = len(data['split_title'])+len(data['split_text'])+5
data['offset'] = 0 data['offset'] = 0
else: else:
@@ -212,7 +166,8 @@ class SubmissionContent(BaseContent):
data['offset'] = indent_level * self.indent_size data['offset'] = indent_level * self.indent_size
if data['type'] == 'Comment': 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 data['n_rows'] = len(data['split_body']) + 1
else: else:
data['n_rows'] = 1 data['n_rows'] = 1
@@ -257,7 +212,8 @@ class SubmissionContent(BaseContent):
elif data['type'] == 'MoreComments': elif data['type'] == 'MoreComments':
with self._loader(): with self._loader():
comments = data['object'].comments(update=False) 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] comment_data = [self.strip_praw_comment(c) for c in comments]
self._comment_data[index:index+1] = comment_data self._comment_data[index:index+1] = comment_data
@@ -281,6 +237,9 @@ class SubredditContent(BaseContent):
@classmethod @classmethod
def from_name(cls, reddit, name, loader, order='hot'): def from_name(cls, reddit, name, loader, order='hot'):
if name is None:
name = 'front'
name = name.strip(' /') # Strip leading and trailing backslashes name = name.strip(' /') # Strip leading and trailing backslashes
if name.startswith('r/'): if name.startswith('r/'):
name = name[2:] name = name[2:]
@@ -306,7 +265,7 @@ class SubredditContent(BaseContent):
elif order == 'controversial': elif order == 'controversial':
submissions = reddit.get_controversial(limit=None) submissions = reddit.get_controversial(limit=None)
else: else:
raise SubredditNameError(display_name) raise SubredditError(display_name)
else: else:
subreddit = reddit.get_subreddit(name) subreddit = reddit.get_subreddit(name)
@@ -321,7 +280,7 @@ class SubredditContent(BaseContent):
elif order == 'controversial': elif order == 'controversial':
submissions = subreddit.get_controversial(limit=None) submissions = subreddit.get_controversial(limit=None)
else: else:
raise SubredditNameError(display_name) raise SubredditError(display_name)
# Verify that content exists for the given submission generator. # Verify that content exists for the given submission generator.
# This is necessary because PRAW loads submissions lazily, and # This is necessary because PRAW loads submissions lazily, and
@@ -331,7 +290,7 @@ class SubredditContent(BaseContent):
try: try:
content.get(0) content.get(0)
except (praw.errors.APIException, requests.HTTPError): except (praw.errors.APIException, requests.HTTPError):
raise SubredditNameError(display_name) raise SubredditError(display_name)
return content return content

View File

@@ -1,72 +1,154 @@
import os import os
import time
import threading
import curses import curses
from curses import textpad, ascii from curses import textpad, ascii
from contextlib import contextmanager
import six from .docs import HELP
from six.moves import configparser 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 = """ ESCAPE = 27
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 # Curses does define constants for these (e.g. curses.ACS_BULLET)
`RIGHT` or `l` : View comments for the selected submission # However, they rely on using the curses.addch() function, which has been
`/` : Open a prompt to switch subreddits # 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 def show_notification(stdscr, message):
`LEFT` or `h` : Return to subreddit mode
`RIGHT` or `l` : Fold the selected comment, or load additional comments
"""
class Symbol(object):
UNICODE = False
ESCAPE = 27
# 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')
@classmethod
def clean(cls, string):
""" """
Required reading! Overlay a message box on the center of the screen and wait for user input.
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:
message (list): List of strings, one per line.
""" """
encoding = 'utf-8' if cls.UNICODE else 'ascii' n_rows, n_cols = stdscr.getmaxyx()
string = string.encode(encoding, 'replace')
return string box_width = max(map(len, message)) + 2
box_height = len(message) + 2
# Make sure the window is large enough to fit the message
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 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='...'):
"""
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.
"""
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): class Color(object):
"""
Color attributes for curses.
"""
COLORS = { _colors = {
'RED': (curses.COLOR_RED, -1), 'RED': (curses.COLOR_RED, -1),
'GREEN': (curses.COLOR_GREEN, -1), 'GREEN': (curses.COLOR_GREEN, -1),
'YELLOW': (curses.COLOR_YELLOW, -1), 'YELLOW': (curses.COLOR_YELLOW, -1),
@@ -76,12 +158,6 @@ class Color(object):
'WHITE': (curses.COLOR_WHITE, -1), '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 @classmethod
def init(cls): def init(cls):
""" """
@@ -94,25 +170,15 @@ class Color(object):
# Assign the terminal's default (background) color to code -1 # Assign the terminal's default (background) color to code -1
curses.use_default_colors() 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]) curses.init_pair(index, code[0], code[1])
setattr(cls, attr, curses.color_pair(index)) setattr(cls, attr, curses.color_pair(index))
def load_config(): @classmethod
""" def get_level(cls, level):
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') levels = [cls.MAGENTA, cls.CYAN, cls.GREEN, cls.YELLOW]
config = configparser.ConfigParser() return levels[level % len(levels)]
config.read(config_path)
defaults = {}
if config.has_section('rtv'):
defaults = dict(config.items('rtv'))
return defaults
def text_input(window, allow_resize=True): 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 # Turn insert_mode off to avoid the recursion error described here
# http://bugs.python.org/issue13051 # http://bugs.python.org/issue13051
textbox = textpad.Textbox(window, insert_mode=False) textbox = textpad.Textbox(window, insert_mode=False)
# Strip whitespace from the textbox 'smarter' than textpad.Textbox() does.
textbox.stripspaces = 0 textbox.stripspaces = 0
def validate(ch): def validate(ch):
"Filters characters for special key sequences" "Filters characters for special key sequences"
if ch == ESCAPE:
if ch == Symbol.ESCAPE: raise EscapeInterrupt
raise EscapePressed
if (not allow_resize) and (ch == curses.KEY_RESIZE): if (not allow_resize) and (ch == curses.KEY_RESIZE):
raise EscapePressed raise EscapeInterrupt
# Fix backspace for iterm # Fix backspace for iterm
if ch == ascii.DEL: if ch == ascii.DEL:
ch = curses.KEY_BACKSPACE ch = curses.KEY_BACKSPACE
return ch return ch
# Wrapping in an exception block so that we can distinguish when the user # 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. # input.
try: try:
out = textbox.edit(validate=validate) out = textbox.edit(validate=validate)
except EscapePressed: except EscapeInterrupt:
out = None out = None
curses.curs_set(0) curses.curs_set(0)
return strip_textpad(out)
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)
@contextmanager @contextmanager
def curses_session(): def curses_session():
"""
Setup terminal and initialize curses.
"""
try: try:
# Curses must wait for some time after the Escape key is pressed to # Curses must wait for some time after the Escape key is pressed to
@@ -274,6 +267,7 @@ def curses_session():
yield stdscr yield stdscr
finally: finally:
if stdscr is not None: if stdscr is not None:
stdscr.keypad(0) stdscr.keypad(0)
curses.echo() curses.echo()

46
rtv/docs.py Normal file
View File

@@ -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
"""

View File

@@ -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

12
rtv/exceptions.py Normal file
View File

@@ -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"

127
rtv/helpers.py Normal file
View File

@@ -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)

View File

@@ -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

View File

@@ -1,8 +1,11 @@
import curses 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): class Navigator(object):
""" """
@@ -138,7 +141,7 @@ class BasePage(object):
data['object'].upvote() data['object'].upvote()
data['likes'] = True data['likes'] = True
except praw.errors.LoginOrScopeRequired: except praw.errors.LoginOrScopeRequired:
display_message(self.stdscr, ['Login to vote']) show_notification(self.stdscr, ['Login to vote'])
def downvote(self): def downvote(self):
@@ -153,7 +156,7 @@ class BasePage(object):
data['object'].downvote() data['object'].downvote()
data['likes'] = False data['likes'] = False
except praw.errors.LoginOrScopeRequired: except praw.errors.LoginOrScopeRequired:
display_message(self.stdscr, ['Login to vote']) show_notification(self.stdscr, ['Login to vote'])
def draw(self): def draw(self):
@@ -183,7 +186,7 @@ class BasePage(object):
self._header_window.bkgd(' ', attr) self._header_window.bkgd(' ', attr)
sub_name = self.content.name.replace('/r/front', 'Front Page ') 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: if self.reddit.user is not None:
username = self.reddit.user.name 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 # Only print the username if it fits in the empty space on the right
if (s_col - 1) >= len(sub_name): if (s_col - 1) >= len(sub_name):
n = (n_cols - s_col - 1) 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() self._header_window.refresh()

View File

@@ -2,12 +2,15 @@ import curses
import sys import sys
import time import time
from praw.errors import APIException import praw.errors
from .content import SubmissionContent from .content import SubmissionContent
from .page import BasePage from .page import BasePage
from .utils import Color, Symbol, display_help, text_input
from .workers import LoadScreen, open_browser from .workers import LoadScreen, open_browser
from .curses_helpers import (BULLET, UARROW, DARROW, Color, show_help,
text_input)
__all__ = ['SubmissionPage']
class SubmissionPage(BasePage): class SubmissionPage(BasePage):
@@ -125,31 +128,31 @@ class SubmissionPage(BasePage):
row = offset row = offset
if row in valid_rows: if row in valid_rows:
text = Symbol.clean('{author} '.format(**data)) text = clean('{author} '.format(**data))
attr = curses.A_BOLD attr = curses.A_BOLD
attr |= (Color.BLUE if not data['is_author'] else Color.GREEN) attr |= (Color.BLUE if not data['is_author'] else Color.GREEN)
win.addnstr(row, 1, text, n_cols-1, attr) win.addnstr(row, 1, text, n_cols-1, attr)
if data['flair']: if data['flair']:
text = Symbol.clean('{flair} '.format(**data)) text = clean('{flair} '.format(**data))
attr = curses.A_BOLD | Color.YELLOW attr = curses.A_BOLD | Color.YELLOW
win.addnstr(text, n_cols-win.getyx()[1], attr) win.addnstr(text, n_cols-win.getyx()[1], attr)
if data['likes'] is None: if data['likes'] is None:
text, attr = Symbol.BULLET, curses.A_BOLD text, attr = BULLET, curses.A_BOLD
elif data['likes']: elif data['likes']:
text, attr = Symbol.UARROW, (curses.A_BOLD | Color.GREEN) text, attr = UARROW, (curses.A_BOLD | Color.GREEN)
else: 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) 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]) win.addnstr(text, n_cols-win.getyx()[1])
n_body = len(data['split_body']) n_body = len(data['split_body'])
for row, text in enumerate(data['split_body'], start=offset+1): for row, text in enumerate(data['split_body'], start=offset+1):
if row in valid_rows: if row in valid_rows:
text = Symbol.clean(text) text = clean(text)
win.addnstr(row, 1, text, n_cols-1) win.addnstr(row, 1, text, n_cols-1)
# Unfortunately vline() doesn't support custom color so we have to # 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_rows, n_cols = win.getmaxyx()
n_cols -= 1 n_cols -= 1
text = Symbol.clean('{body}'.format(**data)) text = clean('{body}'.format(**data))
win.addnstr(0, 1, text, n_cols-1) 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) win.addnstr(text, n_cols-win.getyx()[1], curses.A_BOLD)
# Unfortunately vline() doesn't support custom color so we have to # Unfortunately vline() doesn't support custom color so we have to
@@ -197,31 +200,31 @@ class SubmissionPage(BasePage):
return return
for row, text in enumerate(data['split_title'], start=1): 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) win.addnstr(row, 1, text, n_cols, curses.A_BOLD)
row = len(data['split_title']) + 1 row = len(data['split_title']) + 1
attr = curses.A_BOLD | Color.GREEN 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) win.addnstr(row, 1, text, n_cols, attr)
attr = curses.A_BOLD | Color.YELLOW 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) 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]) win.addnstr(text, n_cols-win.getyx()[1])
row = len(data['split_title']) + 2 row = len(data['split_title']) + 2
attr = curses.A_UNDERLINE | Color.BLUE 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) win.addnstr(row, 1, text, n_cols, attr)
offset = len(data['split_title']) + 3 offset = len(data['split_title']) + 3
for row, text in enumerate(data['split_text'], start=offset): for row, text in enumerate(data['split_text'], start=offset):
text = Symbol.clean(text) text = clean(text)
win.addnstr(row, 1, text, n_cols) win.addnstr(row, 1, text, n_cols)
row = len(data['split_title']) + len(data['split_text']) + 3 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.addnstr(row, 1, text, n_cols, curses.A_BOLD)
win.border() win.border()
@@ -273,7 +276,7 @@ class SubmissionPage(BasePage):
data['object'].add_comment(comment_text) data['object'].add_comment(comment_text)
else: else:
data['object'].reply(comment_text) data['object'].reply(comment_text)
except APIException as e: except praw.errors.APIException as e:
display_message(self.stdscr, [e.message]) display_message(self.stdscr, [e.message])
else: else:
time.sleep(0.5) time.sleep(0.5)

View File

@@ -1,17 +1,20 @@
import curses import curses
import sys import sys
from requests.exceptions import HTTPError import requests
from .errors import SubredditNameError from .exceptions import SubredditError
from .page import BasePage from .page import BasePage
from .submission import SubmissionPage from .submission import SubmissionPage
from .content import SubredditContent from .content import SubredditContent
from .utils import Symbol, Color, text_input, display_message, display_help
from .workers import LoadScreen, open_browser 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 # Used to keep track of browsing history across the current session
_opened_links = set() opened_links = set()
class SubredditPage(BasePage): class SubredditPage(BasePage):
@@ -79,11 +82,11 @@ class SubredditPage(BasePage):
self.content = SubredditContent.from_name( self.content = SubredditContent.from_name(
self.reddit, name, self.loader) self.reddit, name, self.loader)
except SubredditNameError: except SubredditError:
display_message(self.stdscr, ['Invalid subreddit']) show_notification(self.stdscr, ['Invalid subreddit'])
except HTTPError: except requests.HTTPError:
display_message(self.stdscr, ['Could not reach subreddit']) show_notification(self.stdscr, ['Could not reach subreddit'])
else: else:
self.nav.page_index, self.nav.cursor_index = 0, 0 self.nav.page_index, self.nav.cursor_index = 0, 0
@@ -112,8 +115,8 @@ class SubredditPage(BasePage):
page.loop() page.loop()
if data['url'] == 'selfpost': if data['url'] == 'selfpost':
global _opened_links global opened_links
_opened_links.add(data['url_full']) opened_links.add(data['url_full'])
def open_link(self): def open_link(self):
"Open a link with the webbrowser" "Open a link with the webbrowser"
@@ -121,8 +124,8 @@ class SubredditPage(BasePage):
url = self.content.get(self.nav.absolute_index)['url_full'] url = self.content.get(self.nav.absolute_index)['url_full']
open_browser(url) open_browser(url)
global _opened_links global opened_links
_opened_links.add(url) opened_links.add(url)
@staticmethod @staticmethod
def draw_item(win, data, inverted=False): def draw_item(win, data, inverted=False):
@@ -137,38 +140,38 @@ class SubredditPage(BasePage):
n_title = len(data['split_title']) n_title = len(data['split_title'])
for row, text in enumerate(data['split_title'], start=offset): for row, text in enumerate(data['split_title'], start=offset):
if row in valid_rows: if row in valid_rows:
text = Symbol.clean(text) text = clean(text)
win.addnstr(row, 1, text, n_cols-1, curses.A_BOLD) win.addnstr(row, 1, text, n_cols-1, curses.A_BOLD)
row = n_title + offset row = n_title + offset
if row in valid_rows: 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 link_color = Color.MAGENTA if seen else Color.BLUE
attr = curses.A_UNDERLINE | link_color 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) win.addnstr(row, 1, text, n_cols-1, attr)
row = n_title + offset + 1 row = n_title + offset + 1
if row in valid_rows: if row in valid_rows:
text = Symbol.clean('{score} '.format(**data)) text = clean('{score} '.format(**data))
win.addnstr(row, 1, text, n_cols-1) win.addnstr(row, 1, text, n_cols-1)
if data['likes'] is None: if data['likes'] is None:
text, attr = Symbol.BULLET, curses.A_BOLD text, attr = BULLET, curses.A_BOLD
elif data['likes']: elif data['likes']:
text, attr = Symbol.UARROW, curses.A_BOLD | Color.GREEN text, attr = UARROW, curses.A_BOLD | Color.GREEN
else: 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) 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]) win.addnstr(text, n_cols-win.getyx()[1])
row = n_title + offset + 2 row = n_title + offset + 2
if row in valid_rows: 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) 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) 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) win.addnstr(text, n_cols-win.getyx()[1], Color.RED)

View File

@@ -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)

View File

@@ -1,6 +1,5 @@
from setuptools import setup from setuptools import setup
from version import __version__ as version
__version__ = '1.0.post1'
setup( setup(
name='rtv', name='rtv',
@@ -13,8 +12,9 @@ setup(
license='MIT', license='MIT',
keywords='reddit terminal praw curses', keywords='reddit terminal praw curses',
packages=['rtv'], packages=['rtv'],
include_package_data=True,
install_requires=['praw>=2.1.6', 'six', 'requests'], 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=[ classifiers=[
'Intended Audience :: End Users/Desktop', 'Intended Audience :: End Users/Desktop',
'Environment :: Console :: Curses', 'Environment :: Console :: Curses',

1
version.py Symbolic link
View File

@@ -0,0 +1 @@
rtv/__version__.py