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
# 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())

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

View File

@@ -1,34 +1,14 @@
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
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
"""
class Symbol(object):
UNICODE = False
__all__ = ['ESCAPE', 'UARROW', 'DARROW', 'BULLET', 'show_notification',
'show_help', 'LoadScreen', 'Color', 'text_input', 'curses_session']
ESCAPE = 27
@@ -41,32 +21,134 @@ class Symbol(object):
DARROW = u'\u25bc'.encode('utf-8')
BULLET = u'\u2022'.encode('utf-8')
@classmethod
def clean(cls, string):
def show_notification(stdscr, message):
"""
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.
Overlay a message box on the center of the screen and wait for user input.
Params:
message (list): List of strings, one per line.
"""
encoding = 'utf-8' if cls.UNICODE else 'ascii'
string = string.encode(encoding, 'replace')
return string
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
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):
"""
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,6 +267,7 @@ def curses_session():
yield stdscr
finally:
if stdscr is not None:
stdscr.keypad(0)
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 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()

View File

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

View File

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

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
__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',

1
version.py Symbolic link
View File

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