Rename rtv to tuir

This commit is contained in:
John Helmert
2019-06-09 19:31:35 -05:00
parent 481780fffa
commit 7e9455b4ca
76 changed files with 352 additions and 359 deletions

27
tuir/__init__.py Normal file
View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
r"""
______ _ __ __ ______ ____
/_ __/__ _________ ___ (_)___ ____ _/ / / / / / _/ / __/___ _____
/ / / _ \/ ___/ __ `__ \/ / __ \/ __ `/ / / / / // / / /_/ __ \/ ___/
/ / / __/ / / / / / / / / / / / /_/ / / / /_/ // / / __/ /_/ / /
/_/ \___/_/ /_/ /_/ /_/_/_/ /_/\__,_/_/ \____/___/ /_/ \____/_/
____ __ ___ __
/ __ \___ ____/ /___/ (_) /_
/ /_/ / _ \/ __ / __ / / __/
/ _, _/ __/ /_/ / /_/ / / /_
/_/ |_|\___/\__,_/\__,_/_/\__/
(TUIR)
"""
from __future__ import unicode_literals
from .__version__ import __version__
__title__ = 'Terminal UI for Reddit'
__author__ = 'Michael Lazar'
__license__ = 'The MIT License (MIT)'
__copyright__ = '(c) 2016 Michael Lazar, portions (c) 2019 John Helmert III'

280
tuir/__main__.py Executable file
View File

@@ -0,0 +1,280 @@
# -*- coding: utf-8 -*-
# pylint: disable=wrong-import-position
from __future__ import unicode_literals
from __future__ import print_function
import os
import sys
import locale
import logging
import warnings
import six
import requests
# Need to check for curses compatibility before performing the tuir imports
try:
import curses
except ImportError:
if sys.platform == 'win32':
sys.exit('Fatal Error: This program is not compatible with Windows '
'Operating Systems.')
else:
sys.exit('Fatal Error: Your python distribution appears to be missing '
'_curses.so.\nWas it compiled without support for curses?')
# If we want to override the $BROWSER variable that the python webbrowser
# references, it needs to be done before the webbrowser module is imported
# for the first time.
webbrowser_import_warning = ('webbrowser' in sys.modules)
TUIR_BROWSER, BROWSER = os.environ.get('TUIR_BROWSER'), os.environ.get('BROWSER')
if TUIR_BROWSER:
os.environ['BROWSER'] = TUIR_BROWSER
from . import docs
from . import packages
from .packages import praw
from .config import Config, copy_default_config, copy_default_mailcap
from .theme import Theme
from .oauth import OAuthHelper
from .terminal import Terminal
from .content import RequestHeaderRateLimiter
from .objects import curses_session, patch_webbrowser
from .subreddit_page import SubredditPage
from .submission_page import SubmissionPage
from .exceptions import ConfigError, SubredditError, SubmissionError
from .__version__ import __version__
_logger = logging.getLogger(__name__)
# Pycharm debugging note:
# You can use pycharm to debug a curses application by launching tuir in a
# console window (python -m tuir) and using pycharm to attach to the remote
# process. On Ubuntu, you may need to allow ptrace permissions by setting
# ptrace_scope to 0 in /etc/sysctl.d/10-ptrace.conf.
# http://blog.mellenthin.de/archives/2010/10/18/gdb-attach-fails
def main():
"""Main entry point"""
# Squelch SSL warnings
logging.captureWarnings(True)
if six.PY3:
# These ones get triggered even when capturing warnings is turned on
warnings.simplefilter('ignore', ResourceWarning) # pylint:disable=E0602
# Set the terminal title
if os.getenv('DISPLAY'):
title = 'tuir {0}'.format(__version__)
sys.stdout.write('\x1b]2;{0}\x07'.format(title))
sys.stdout.flush()
args = Config.get_args()
fargs, bindings = Config.get_file(args.get('config'))
# Apply the file config first, then overwrite with any command line args
config = Config()
config.update(**fargs)
config.update(**args)
# If key bindings are supplied in the config file, overwrite the defaults
if bindings:
config.keymap.set_bindings(bindings)
if config['copy_config']:
return copy_default_config()
if config['copy_mailcap']:
return copy_default_mailcap()
if config['list_themes']:
return Theme.print_themes()
# Load the browsing history from previous sessions
config.load_history()
# Load any previously saved auth session token
config.load_refresh_token()
if config['clear_auth']:
config.delete_refresh_token()
if config['log']:
# Log request headers to the file (print hack only works on python 3.x)
# from http import client
# _http_logger = logging.getLogger('http.client')
# client.HTTPConnection.debuglevel = 2
# def print_to_file(*args, **_):
# if args[0] != "header:":
# _http_logger.info(' '.join(args))
# client.print = print_to_file
logging.basicConfig(
level=logging.DEBUG,
filename=config['log'],
format='%(asctime)s:%(levelname)s:%(filename)s:%(lineno)d:%(message)s')
else:
# Add an empty handler so the logger doesn't complain
logging.root.addHandler(logging.NullHandler())
# Make sure the locale is UTF-8 for unicode support
default_locale = locale.setlocale(locale.LC_ALL, '')
try:
encoding = locale.getlocale()[1] or locale.getdefaultlocale()[1]
except ValueError:
# http://stackoverflow.com/a/19961403
# OS X on some terminals will set the LC_CTYPE to "UTF-8"
# (as opposed to something like "en_US.UTF-8") and python
# doesn't know how to handle it.
_logger.warning('Error parsing system locale: `%s`,'
' falling back to utf-8', default_locale)
encoding = 'UTF-8'
if not encoding or encoding.lower() != 'utf-8':
text = ('System encoding was detected as (%s) instead of UTF-8'
', falling back to ascii only mode' % encoding)
warnings.warn(text)
config['ascii'] = True
if packages.__praw_bundled__:
praw_info = 'packaged, commit {}'.format(packages.__praw_hash__[:12])
else:
praw_info = 'system installed v{}'.format(praw.__version__)
# Update the webbrowser module's default behavior
patch_webbrowser()
if webbrowser_import_warning:
_logger.warning('webbrowser module was unexpectedly imported before'
'$BROWSER could be overwritten')
# Construct the reddit user agent
user_agent = docs.AGENT.format(version=__version__)
debug_info = [
'tuir version: tuir {}'.format(__version__),
'tuir module path: {}'.format(os.path.abspath(__file__)),
'python version: {}'.format(sys.version.replace('\n', ' ')),
'python executable: {}'.format(sys.executable),
'praw version: {}'.format(praw_info),
'locale, encoding: {}, {}'.format(default_locale, encoding),
'Environment Variables']
for name, value in [
('BROWSER', BROWSER),
('DISPLAY', os.getenv('DISPLAY')),
('EDITOR', os.getenv('EDITOR')),
('LANG', os.getenv('LANG')),
('PAGER', os.getenv('PAGER')),
('TUIR_BROWSER', TUIR_BROWSER),
('TUIR_EDITOR', os.getenv('TUIR_EDITOR')),
('TUIR_PAGER', os.getenv('TUIR_PAGER')),
('TUIR_URLVIEWER', os.getenv('TUIR_URLVIEWER')),
('TERM', os.getenv('TERM')),
('VISUAL', os.getenv('VISUAL')),
('XDG_CONFIG_HOME', os.getenv('XDG_CONFIG_HOME')),
('XDG_DATA_HOME', os.getenv('XDG_DATA_HOME')),
]:
debug_info.append(' {:<16}: {}'.format(name, value or ''))
debug_info.append('')
debug_text = '\n'.join(debug_info)
_logger.info(debug_text)
if config['debug_info']:
print(debug_text)
return
try:
with curses_session() as stdscr:
term = Terminal(stdscr, config)
if config['monochrome'] or config['theme'] == 'monochrome':
_logger.info('Using monochrome theme')
theme = Theme(use_color=False)
elif config['theme'] and config['theme'] != 'default':
_logger.info('Loading theme: %s', config['theme'])
theme = Theme.from_name(config['theme'])
else:
# Set to None to let the terminal figure out which theme
# to use depending on if colors are supported or not
theme = None
term.set_theme(theme)
with term.loader('Initializing', catch_exception=False):
reddit = praw.Reddit(user_agent=user_agent,
decode_html_entities=False,
disable_update_check=True,
timeout=10, # 10 second request timeout
handler=RequestHeaderRateLimiter())
# Dial the request cache up from 30 seconds to 5 minutes
# I'm trying this out to make navigation back and forth
# between pages quicker, it may still need to be fine tuned.
reddit.config.api_request_delay = 300
# Authorize on launch if the refresh token is present
oauth = OAuthHelper(reddit, term, config)
if config['autologin'] and config.refresh_token:
oauth.authorize(autologin=True)
# Open the supplied submission link before opening the subreddit
if config['link']:
# Expand shortened urls like https://redd.it/
# Praw won't accept the shortened versions, add the reddit
# headers to avoid a 429 response from reddit.com
url = requests.head(
config['link'],
headers=reddit.http.headers,
allow_redirects=True
).url
page = None
with term.loader('Loading submission'):
try:
page = SubmissionPage(reddit, term, config, oauth, url)
except Exception as e:
_logger.exception(e)
raise SubmissionError('Unable to load {0}'.format(url))
while page:
page = page.loop()
page = None
name = config['subreddit']
with term.loader('Loading subreddit'):
try:
page = SubredditPage(reddit, term, config, oauth, name)
except Exception as e:
# If we can't load the subreddit that was requested, try
# to load the "popular" page instead so at least the
# application still launches. This used to use the user's
# front page, but some users have an empty front page.
_logger.exception(e)
page = SubredditPage(reddit, term, config, oauth, 'popular')
raise SubredditError('Unable to load {0}'.format(name))
# Launch the subreddit page
while page:
page = page.loop()
except ConfigError as e:
_logger.exception(e)
print(e)
except Exception as e:
_logger.exception(e)
import traceback
exit_message = '\n'.join([
debug_text,
traceback.format_exc(),
'tuir has crashed. Please report this traceback at:',
'https://gitlab.com/ajak/tuir/issues\n'])
sys.stderr.write(exit_message)
return 1 # General error exception code
except KeyboardInterrupt:
pass
finally:
# Try to save the browsing history
config.save_history()
# Ensure sockets are closed to prevent a ResourceWarning
if 'reddit' in locals():
reddit.handler.http.close()
sys.exit(main())

4
tuir/__version__.py Normal file
View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
__version__ = '1.27.0'

25
tuir/clipboard.py Normal file
View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import sys
import subprocess
def _subprocess_copy(text, args_list):
p = subprocess.Popen(args_list, stdin=subprocess.PIPE, close_fds=True)
p.communicate(input=text.encode('utf-8'))
def copy(text, cmd):
"""
Copy text to OS clipboard.
"""
# If no command is specified (i.e. the config option is empty) try
# to find a reasonable default based on the operating system
if cmd is None:
if sys.platform == 'darwin':
cmd = 'pbcopy w'
else: # For Linux, BSD, cygwin, etc.
cmd = 'xclip'
_subprocess_copy(text, cmd.split())

303
tuir/config.py Normal file
View File

@@ -0,0 +1,303 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
import codecs
import shutil
import argparse
from functools import partial
import six
from six.moves import configparser
from . import docs, __version__
from .objects import KeyMap
PACKAGE = os.path.dirname(__file__)
HOME = os.path.expanduser('~')
TEMPLATES = os.path.join(PACKAGE, 'templates')
DEFAULT_CONFIG = os.path.join(TEMPLATES, 'tuir.cfg')
DEFAULT_MAILCAP = os.path.join(TEMPLATES, 'mailcap')
DEFAULT_THEMES = os.path.join(PACKAGE, 'themes')
XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config'))
XDG_DATA_HOME = os.getenv('XDG_DATA_HOME', os.path.join(HOME, '.local', 'share'))
CONFIG = os.path.join(XDG_CONFIG_HOME, 'tuir', 'tuir.cfg')
MAILCAP = os.path.join(HOME, '.mailcap')
TOKEN = os.path.join(XDG_DATA_HOME, 'tuir', 'refresh-token')
HISTORY = os.path.join(XDG_DATA_HOME, 'tuir', 'history.log')
THEMES = os.path.join(XDG_CONFIG_HOME, 'tuir', 'themes')
def build_parser():
parser = argparse.ArgumentParser(
prog='tuir', description=docs.SUMMARY,
epilog=docs.CONTROLS,
usage=docs.USAGE,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
'link', metavar='URL', nargs='?',
help='[optional] Full URL of a submission to open')
parser.add_argument(
'-s', dest='subreddit',
help='Name of the subreddit that will be loaded on start')
parser.add_argument(
'-l', dest='link_deprecated',
help=argparse.SUPPRESS) # Deprecated, use the positional arg instead
parser.add_argument(
'--log', metavar='FILE', action='store',
help='Log HTTP requests to the given file')
parser.add_argument(
'--config', metavar='FILE', action='store',
help='Load configuration settings from the given file')
parser.add_argument(
'--ascii', action='store_const', const=True,
help='Enable ascii-only mode')
parser.add_argument(
'--monochrome', action='store_const', const=True,
help='Disable color')
parser.add_argument(
'--theme', metavar='FILE', action='store',
help='Color theme to use, see --list-themes for valid options')
parser.add_argument(
'--list-themes', metavar='FILE', action='store_const', const=True,
help='List all of the available color themes')
parser.add_argument(
'--non-persistent', dest='persistent', action='store_const', const=False,
help='Forget the authenticated user when the program exits')
parser.add_argument(
'--no-autologin', dest='autologin', action='store_const', const=False,
help='Do not authenticate automatically on startup')
parser.add_argument(
'--clear-auth', dest='clear_auth', action='store_const', const=True,
help='Remove any saved user data before launching')
parser.add_argument(
'--copy-config', dest='copy_config', action='store_const', const=True,
help='Copy the default configuration to {HOME}/.config/tuir/tuir.cfg')
parser.add_argument(
'--copy-mailcap', dest='copy_mailcap', action='store_const', const=True,
help='Copy an example mailcap configuration to {HOME}/.mailcap')
parser.add_argument(
'--enable-media', dest='enable_media', action='store_const', const=True,
help='Open external links using programs defined in the mailcap config')
parser.add_argument(
'-V', '--version', action='version', version='tuir ' + __version__)
parser.add_argument(
'--no-flash', dest='flash', action='store_const', const=False,
help='Disable screen flashing')
parser.add_argument(
'--debug-info', dest='debug_info', action='store_const', const=True,
help='Show system and environment information and exit')
return parser
def copy_default_mailcap(filename=MAILCAP):
"""
Copy the example mailcap configuration to the specified file.
"""
return _copy_settings_file(DEFAULT_MAILCAP, filename, 'mailcap')
def copy_default_config(filename=CONFIG):
"""
Copy the default tuir user configuration to the specified file.
"""
return _copy_settings_file(DEFAULT_CONFIG, filename, 'config')
def _copy_settings_file(source, destination, name):
"""
Copy a file from the repo to the user's home directory.
"""
if os.path.exists(destination):
try:
ch = six.moves.input(
'File %s already exists, overwrite? y/[n]):' % destination)
if ch not in ('Y', 'y'):
return
except KeyboardInterrupt:
return
filepath = os.path.dirname(destination)
if not os.path.exists(filepath):
os.makedirs(filepath)
print('Copying default %s to %s' % (name, destination))
shutil.copy(source, destination)
os.chmod(destination, 0o664)
class OrderedSet(object):
"""
A simple implementation of an ordered set. A set is used to check
for membership, and a list is used to maintain ordering.
"""
def __init__(self, elements=None):
elements = elements or []
self._set = set(elements)
self._list = elements
def __contains__(self, item):
return item in self._set
def __len__(self):
return len(self._list)
def __getitem__(self, item):
return self._list[item]
def add(self, item):
self._set.add(item)
self._list.append(item)
class Config(object):
"""
This class manages the loading and saving of configs and other files.
"""
def __init__(self, history_file=HISTORY, token_file=TOKEN, **kwargs):
self.history_file = history_file
self.token_file = token_file
self.config = kwargs
default, bindings = self.get_file(DEFAULT_CONFIG)
self.default = default
self.keymap = KeyMap(bindings)
# `refresh_token` and `history` are saved/loaded at separate locations,
# so they are treated differently from the rest of the config options.
self.refresh_token = None
self.history = OrderedSet()
def __getitem__(self, item):
if item in self.config:
return self.config[item]
else:
return self.default.get(item, None)
def __setitem__(self, key, value):
self.config[key] = value
def __delitem__(self, key):
self.config.pop(key, None)
def update(self, **kwargs):
self.config.update(kwargs)
def load_refresh_token(self):
if os.path.exists(self.token_file):
with open(self.token_file) as fp:
self.refresh_token = fp.read().strip()
else:
self.refresh_token = None
def save_refresh_token(self):
self._ensure_filepath(self.token_file)
with open(self.token_file, 'w+') as fp:
fp.write(self.refresh_token)
def delete_refresh_token(self):
if os.path.exists(self.token_file):
os.remove(self.token_file)
self.refresh_token = None
def load_history(self):
if os.path.exists(self.history_file):
with codecs.open(self.history_file, encoding='utf-8') as fp:
self.history = OrderedSet([line.strip() for line in fp])
else:
self.history = OrderedSet()
def save_history(self):
self._ensure_filepath(self.history_file)
with codecs.open(self.history_file, 'w+', encoding='utf-8') as fp:
fp.writelines('\n'.join(self.history[-self['history_size']:]))
def delete_history(self):
if os.path.exists(self.history_file):
os.remove(self.history_file)
self.history = OrderedSet()
@staticmethod
def get_args():
"""
Load settings from the command line.
"""
parser = build_parser()
args = vars(parser.parse_args())
# Overwrite the deprecated "-l" option into the link variable
if args['link_deprecated'] and args['link'] is None:
args['link'] = args['link_deprecated']
args.pop('link_deprecated', None)
# Filter out argument values that weren't supplied
return {key: val for key, val in args.items() if val is not None}
@classmethod
def get_file(cls, filename=None):
"""
Load settings from an tuir configuration file.
"""
if filename is None:
filename = CONFIG
config = configparser.ConfigParser()
if os.path.exists(filename):
with codecs.open(filename, encoding='utf-8') as fp:
config.read_file(fp)
return cls._parse_tuir_file(config)
@staticmethod
def _parse_tuir_file(config):
tuir = {}
if config.has_section('tuir'):
tuir = dict(config.items('tuir'))
# convert non-string params to their typed representation
params = {
'ascii': partial(config.getboolean, 'tuir'),
'monochrome': partial(config.getboolean, 'tuir'),
'persistent': partial(config.getboolean, 'tuir'),
'autologin': partial(config.getboolean, 'tuir'),
'clear_auth': partial(config.getboolean, 'tuir'),
'enable_media': partial(config.getboolean, 'tuir'),
'history_size': partial(config.getint, 'tuir'),
'oauth_redirect_port': partial(config.getint, 'tuir'),
'oauth_scope': lambda x: tuir[x].split(','),
'max_comment_cols': partial(config.getint, 'tuir'),
'max_pager_cols': partial(config.getint, 'tuir'),
'hide_username': partial(config.getboolean, 'tuir'),
'flash': partial(config.getboolean, 'tuir'),
'force_new_browser_window': partial(config.getboolean, 'tuir')
}
for key, func in params.items():
if key in tuir:
tuir[key] = func(key)
bindings = {}
if config.has_section('bindings'):
bindings = dict(config.items('bindings'))
for name, keys in bindings.items():
bindings[name] = [key.strip() for key in keys.split(',')]
return tuir, bindings
@staticmethod
def _ensure_filepath(filename):
"""
Ensure that the directory exists before trying to write to the file.
"""
filepath = os.path.dirname(filename)
if not os.path.exists(filepath):
os.makedirs(filepath)

1189
tuir/content.py Normal file

File diff suppressed because it is too large Load Diff

229
tuir/docs.py Normal file
View File

@@ -0,0 +1,229 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
AGENT = """\
desktop:https://gitlab.com/ajak/tuir:{version}\
(by /u/ajak__)\
"""
SUMMARY = """
TUIR (Terminal UI for Reddit) is a terminal interface to view and interact with reddit.
"""
USAGE = """\
tuir [URL] [-s SUBREDDIT]
$ tuir https://www.reddit.com/r/programming/comments/7h9l31
$ tuir -s linux
"""
CONTROLS = """
Move the cursor using the arrow keys or vim style movement.
Press `?` to open the help screen.
"""
HELP = """\
====================================
Terminal UI for Reddit
https://github.com/ajak/tuir
====================================
[Basic Commands]
j : Move the cursor down
k : Move the cursor up
l : View the currently selected item
h : Return to the previous view
m : Move the cursor up one page
n : Move the cursor down one page
gg : Jump to the top of the page
G : Jump to the bottom of the page
1-7 : Sort submissions by category
r : Refresh the content on the current page
u : Login to your reddit account
q : Quit
Q : Force quit
y : Copy submission permalink to clipboard
Y : Copy submission link to clipboard
F2 : Cycle to the previous color theme
F3 : Cycle to the next color theme
? : Show the help screen
/ : Open a prompt to select a subreddit
[Authenticated Commands]
a : Upvote
z : Downvote
c : Compose a new submission or comment
C : Compose a new private message
e : Edit the selected submission or comment
d : Delete the selected submission or comment
i : View your inbox (see Inbox Mode)
s : View your subscribed subreddits (see Subscription Mode)
S : View your subscribed multireddits (see Subscription Mode)
u : Logout of your reddit account
w : Save the selected submission or comment
[Subreddit Mode]
l : View the comments for the selected submission (see Submission Mode)
o : Open the selected submission link using your web browser
SPACE : Mark the selected submission as hidden
p : Toggle between the currently viewed subreddit and /r/front
f : Open a prompt to search the current subreddit for a text string
[Submission Mode]
h : Close the submission and return to the previous page
l : View the selected comment using the system's pager
o : Open a link in the comment using your web browser
SPACE : Fold or expand the selected comment and its children
b : Send the comment text to the system's urlviewer application
J : Move the cursor down the the next comment at the same indentation
K : Move the cursor up to the parent comment
[Subscription Mode]
h : Close your subscriptions and return to the previous page
l : Open the selected subreddit or multireddit
[Inbox Mode]
h : Close your inbox and return to the previous page
l : View the context of the selected comment
o : Open the submission of the selected comment
c : Reply to the selected comment or message
w : Mark the selected comment or message as seen
[Prompt]
The / key opens a text prompt at the bottom of the screen. You can use this
to type in the name of the subreddit that you want to open. The following
text formats are recognized:
/python - Open a subreddit, shorthand
/r/python - Open a subreddit
/r/python/new - Open a subreddit, sorted by category
/r/python/controversial-year - Open a subreddit, sorted by category and time
/r/python+linux+commandline - Open multiple subreddits merged together
/comments/30rwj2 - Open a submission, shorthand
/r/python/comments/30rwj2 - Open a submission
/r/front - Open your front page
/u/me - View your submissions
/u/me/saved - View your saved content
/u/me/hidden - View your hidden content
/u/me/upvoted - View your upvoted content
/u/me/downvoted - View your downvoted content
/u/spez - View a user's submissions and comments
/u/spez/submitted - View a user's submissions
/u/spez/comments - View a user's comments
/u/multi-mod/m/android - Open a user's curated multireddit
/domain/python.org - Search for links for the given domain
"""
BANNER_SUBREDDIT = """
[1]hot [2]top [3]rising [4]new [5]controversial [6]gilded
"""
BANNER_SUBMISSION = """
[1]hot [2]top [3]rising [4]new [5]controversial
"""
BANNER_SEARCH = """
[1]relevance [2]top [3]comments [4]new
"""
BANNER_INBOX = """
[1]all [2]unread [3]messages [4]comments [5]posts [6]mentions [7]sent
"""
FOOTER_SUBREDDIT = """
[?]Help [q]Quit [l]Comments [/]Prompt [u]Login [o]Open [c]Post [a/z]Vote [r]Refresh
"""
FOOTER_SUBMISSION = """
[?]Help [q]Quit [h]Return [space]Fold/Expand [o]Open [c]Comment [a/z]Vote [r]Refresh
"""
FOOTER_SUBSCRIPTION = """
[?]Help [q]Quit [h]Return [l]Select Subreddit [r]Refresh
"""
FOOTER_INBOX = """
[?]Help [l]View Context [o]Open Submission [c]Reply [w]Mark Read [r]Refresh
"""
TOKEN = "INSTRUCTIONS"
REPLY_FILE = """<!--{token}
Replying to {{author}}'s {{type}}:
{{content}}
Enter your reply below this instruction block,
an empty message will abort the comment.
{token}-->
""".format(token=TOKEN)
COMMENT_EDIT_FILE = """<!--{token}
Editing comment #{{id}}.
The comment is shown below, update it and save the file.
{token}-->
{{content}}
""".format(token=TOKEN)
SUBMISSION_FILE = """<!--{token}
Submitting a selfpost to {{name}}.
Enter your submission below this instruction block:
- The first line will be interpreted as the title
- The following lines will be interpreted as the body
- An empty message will abort the submission
{token}-->
""".format(token=TOKEN)
SUBMISSION_EDIT_FILE = """<!--{token}
Editing submission #{{id}}.
The submission is shown below, update it and save the file.
{token}-->
{{content}}
""".format(token=TOKEN)
MESSAGE_FILE = """<!--{token}
Compose a new private message
Enter your message below this instruction block:
- The first line should contain the recipient's reddit name
- The second line should contain the message subject
- Subsequent lines will be interpreted as the message body
{token}-->
""".format(token=TOKEN)
OAUTH_ACCESS_DENIED = """\
<h1 style="color: red">Access Denied</h1><hr>
<p><span style="font-weight: bold">Terminal UI for Reddit</span> was
denied access and will continue to operate in unauthenticated mode,
you can close this window.</p>
"""
OAUTH_ERROR = """\
<h1 style="color: red">Error</h1><hr>
<p>{error}</p>
"""
OAUTH_INVALID = """\
<h1>Wait...</h1><hr>
<p>This page is supposed to be a Reddit OAuth callback.
You can't just come here hands in your pocket!</p>
"""
OAUTH_SUCCESS = """\
<h1 style="color: green">Access Granted</h1><hr>
<p><span style="font-weight: bold">Terminal UI for Reddit</span>
will now log in, you can close this window.</p>
"""
TIME_ORDER_MENU = """
Links from:
[1] Past hour
[2] Past 24 hours
[3] Past week
[4] Past month
[5] Past year
[6] All time
"""

63
tuir/exceptions.py Normal file
View File

@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
class EscapeInterrupt(Exception):
"Signal that the ESC key has been pressed"
class ConfigError(Exception):
"There was a problem with the configuration"
class TUIRError(Exception):
"Base TUIR error class"
class AccountError(TUIRError):
"Could not access user account"
class SubmissionError(TUIRError):
"Submission could not be loaded"
class SubredditError(TUIRError):
"Subreddit could not be loaded"
class NoSubmissionsError(TUIRError):
"No submissions for the given page"
def __init__(self, name):
self.name = name
message = '`{0}` has no submissions'.format(name)
super(NoSubmissionsError, self).__init__(message)
class SubscriptionError(TUIRError):
"Content could not be fetched"
class InboxError(TUIRError):
"Content could not be fetched"
class ProgramError(TUIRError):
"Problem executing an external program"
class BrowserError(TUIRError):
"Could not open a web browser tab"
class TemporaryFileError(TUIRError):
"Indicates that an error has occurred and the file should not be deleted"
class MailcapEntryNotFound(TUIRError):
"A valid mailcap entry could not be coerced from the given url"
class InvalidRefreshToken(TUIRError):
"The refresh token is corrupt and cannot be used to login"

204
tuir/inbox_page.py Normal file
View File

@@ -0,0 +1,204 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from . import docs
from .content import InboxContent
from .page import Page, PageController, logged_in
from .objects import Navigator, Command
class InboxController(PageController):
character_map = {}
class InboxPage(Page):
BANNER = docs.BANNER_INBOX
FOOTER = docs.FOOTER_INBOX
name = 'inbox'
def __init__(self, reddit, term, config, oauth, content_type='all'):
super(InboxPage, self).__init__(reddit, term, config, oauth)
self.controller = InboxController(self, keymap=config.keymap)
self.content = InboxContent.from_user(reddit, term.loader, content_type)
self.nav = Navigator(self.content.get)
self.content_type = content_type
def handle_selected_page(self):
"""
Open the subscription and submission pages subwindows, but close the
current page if any other type of page is selected.
"""
if not self.selected_page:
pass
if self.selected_page.name in ('subscription', 'submission'):
# Launch page in a subwindow
self.selected_page = self.selected_page.loop()
elif self.selected_page.name in ('subreddit', 'inbox'):
# Replace the current page
self.active = False
else:
raise RuntimeError(self.selected_page.name)
@logged_in
def refresh_content(self, order=None, name=None):
"""
Re-download all inbox content and reset the page index
"""
self.content_type = order or self.content_type
with self.term.loader():
self.content = InboxContent.from_user(
self.reddit, self.term.loader, self.content_type)
if not self.term.loader.exception:
self.nav = Navigator(self.content.get)
@InboxController.register(Command('SORT_1'))
def load_content_inbox(self):
self.refresh_content(order='all')
@InboxController.register(Command('SORT_2'))
def load_content_unread_messages(self):
self.refresh_content(order='unread')
@InboxController.register(Command('SORT_3'))
def load_content_messages(self):
self.refresh_content(order='messages')
@InboxController.register(Command('SORT_4'))
def load_content_comment_replies(self):
self.refresh_content(order='comments')
@InboxController.register(Command('SORT_5'))
def load_content_post_replies(self):
self.refresh_content(order='posts')
@InboxController.register(Command('SORT_6'))
def load_content_username_mentions(self):
self.refresh_content(order='mentions')
@InboxController.register(Command('SORT_7'))
def load_content_sent_messages(self):
self.refresh_content(order='sent')
@InboxController.register(Command('INBOX_MARK_READ'))
@logged_in
def mark_seen(self):
"""
Mark the selected message or comment as seen.
"""
data = self.get_selected_item()
if data['is_new']:
with self.term.loader('Marking as read'):
data['object'].mark_as_read()
if not self.term.loader.exception:
data['is_new'] = False
else:
with self.term.loader('Marking as unread'):
data['object'].mark_as_unread()
if not self.term.loader.exception:
data['is_new'] = True
@InboxController.register(Command('INBOX_REPLY'))
@logged_in
def inbox_reply(self):
"""
Reply to the selected private message or comment from the inbox.
"""
self.reply()
@InboxController.register(Command('INBOX_EXIT'))
def close_inbox(self):
"""
Close inbox and return to the previous page.
"""
self.active = False
@InboxController.register(Command('INBOX_VIEW_CONTEXT'))
@logged_in
def view_context(self):
"""
View the context surrounding the selected comment.
"""
url = self.get_selected_item().get('context')
if url:
self.selected_page = self.open_submission_page(url)
@InboxController.register(Command('INBOX_OPEN_SUBMISSION'))
@logged_in
def open_submission(self):
"""
Open the full submission and comment tree for the selected comment.
"""
url = self.get_selected_item().get('submission_permalink')
if url:
self.selected_page = self.open_submission_page(url)
def _draw_item(self, win, data, inverted):
n_rows, n_cols = win.getmaxyx()
n_cols -= 1 # Leave space for the cursor in the first column
# Handle the case where the window is not large enough to fit the data.
valid_rows = range(0, n_rows)
offset = 0 if not inverted else -(data['n_rows'] - n_rows)
row = offset
if row in valid_rows:
if data['is_new']:
attr = self.term.attr('New')
self.term.add_line(win, '[new]', row, 1, attr)
self.term.add_space(win)
attr = self.term.attr('MessageSubject')
self.term.add_line(win, '{subject}'.format(**data), attr=attr)
self.term.add_space(win)
else:
attr = self.term.attr('MessageSubject')
self.term.add_line(win, '{subject}'.format(**data), row, 1, attr)
self.term.add_space(win)
if data['link_title']:
attr = self.term.attr('MessageLink')
self.term.add_line(win, '{link_title}'.format(**data), attr=attr)
row = offset + 1
if row in valid_rows:
# reddit.user might be ``None`` if the user logs out while viewing
# this page
if data['author'] == getattr(self.reddit.user, 'name', None):
self.term.add_line(win, 'to ', row, 1)
text = '{recipient}'.format(**data)
else:
self.term.add_line(win, 'from ', row, 1)
text = '{author}'.format(**data)
attr = self.term.attr('MessageAuthor')
self.term.add_line(win, text, attr=attr)
self.term.add_space(win)
if data['distinguished']:
attr = self.term.attr('Distinguished')
text = '[{distinguished}]'.format(**data)
self.term.add_line(win, text, attr=attr)
self.term.add_space(win)
attr = self.term.attr('Created')
text = 'sent {created_long}'.format(**data)
self.term.add_line(win, text, attr=attr)
self.term.add_space(win)
if data['subreddit_name']:
attr = self.term.attr('MessageSubreddit')
text = 'via {subreddit_name}'.format(**data)
self.term.add_line(win, text, attr=attr)
self.term.add_space(win)
attr = self.term.attr('MessageText')
for row, text in enumerate(data['split_body'], start=offset + 2):
if row in valid_rows:
self.term.add_line(win, text, row, 1, attr=attr)
attr = self.term.attr('CursorBlock')
for y in range(n_rows):
self.term.addch(win, y, 0, str(' '), attr)

485
tuir/mime_parsers.py Normal file
View File

@@ -0,0 +1,485 @@
import re
import logging
import mimetypes
import requests
from bs4 import BeautifulSoup
_logger = logging.getLogger(__name__)
class BaseMIMEParser(object):
"""
BaseMIMEParser can be sub-classed to define custom handlers for determining
the MIME type of external urls.
"""
pattern = re.compile(r'.*$')
@staticmethod
def get_mimetype(url):
"""
Guess based on the file extension.
Args:
url (text): Web url that was linked to by a reddit submission.
Returns:
modified_url (text): The url (or filename) that will be used when
constructing the command to run.
content_type (text): The mime-type that will be used when
constructing the command to run. If the mime-type is unknown,
return None and the program will fallback to using the web
browser.
"""
filename = url.split('?')[0]
filename = filename.split('#')[0]
content_type, _ = mimetypes.guess_type(filename)
return url, content_type
class OpenGraphMIMEParser(BaseMIMEParser):
"""
Open graph protocol is used on many web pages.
<meta property="og:image" content="https://xxxx.jpg?ig_cache_key=xxxxx" />
<meta property="og:video:secure_url" content="https://xxxxx.mp4" />
If the page is a video page both of the above tags will be present and
priority is given to video content.
see http://ogp.me
"""
pattern = re.compile(r'.*$')
@staticmethod
def get_mimetype(url):
page = requests.get(url)
soup = BeautifulSoup(page.content, 'html.parser')
for og_type in ['video', 'image']:
prop = 'og:' + og_type + ':secure_url'
tag = soup.find('meta', attrs={'property': prop})
if not tag:
prop = 'og:' + og_type
tag = soup.find('meta', attrs={'property': prop})
if tag:
return BaseMIMEParser.get_mimetype(tag.get('content'))
return url, None
class VideoTagMIMEParser(BaseMIMEParser):
"""
<video width="320" height="240" controls>
<source src="movie.mp4" res="HD" type="video/mp4">
<source src="movie.mp4" res="SD" type="video/mp4">
<source src="movie.ogg" type="video/ogg">
</video>
"""
pattern = re.compile(r'.*$')
@staticmethod
def get_mimetype(url):
page = requests.get(url)
soup = BeautifulSoup(page.content, 'html.parser')
# TODO: Handle pages with multiple videos
video = soup.find('video')
source = None
if video:
source = video.find('source', attr={'res': 'HD'})
source = source or video.find('source', attr={'type': 'video/mp4'})
source = source or video.find('source')
if source:
return source.get('src'), source.get('type')
else:
return url, None
class GfycatMIMEParser(BaseMIMEParser):
"""
Gfycat provides a primitive json api to generate image links. URLs can be
downloaded as either gif, mp4, webm, or mjpg. Mp4 was selected because it's
fast and works with VLC.
https://gfycat.com/api
https://gfycat.com/UntidyAcidicIberianemeraldlizard -->
https://giant.gfycat.com/UntidyAcidicIberianemeraldlizard.webm
"""
pattern = re.compile(r'https?://(www\.)?gfycat\.com/[^.]+$')
@staticmethod
def get_mimetype(url):
identifier = url.split('/')[-1]
api_url = 'https://api.gfycat.com/v1/gfycats/{}'.format(identifier)
resp = requests.get(api_url)
image_url = resp.json()['gfyItem']['mp4Url']
return image_url, 'video/mp4'
class YoutubeMIMEParser(BaseMIMEParser):
"""
Youtube videos can be streamed with vlc or downloaded with youtube-dl.
Assign a custom mime-type so they can be referenced in mailcap.
"""
pattern = re.compile(
r'(?:https?://)?(m\.)?(?:youtu\.be/|(?:www\.)?youtube\.com/watch'
r'(?:\.php)?\'?.*v=)([a-zA-Z0-9\-_]+)')
@staticmethod
def get_mimetype(url):
return url, 'video/x-youtube'
class VimeoMIMEParser(BaseMIMEParser):
"""
Vimeo videos can be streamed with vlc or downloaded with youtube-dl.
Assign a custom mime-type so they can be referenced in mailcap.
"""
pattern = re.compile(r'https?://(www\.)?vimeo\.com/\d+$')
@staticmethod
def get_mimetype(url):
return url, 'video/x-youtube'
class GifvMIMEParser(BaseMIMEParser):
"""
Special case for .gifv, which is a custom video format for imgur serves
as html with a special <video> frame. Note that attempting for download as
.webm also returns this html page. However, .mp4 appears to return the raw
video file.
"""
pattern = re.compile(r'.*[.]gifv$')
@staticmethod
def get_mimetype(url):
modified_url = url[:-4] + 'mp4'
return modified_url, 'video/mp4'
class RedditUploadsMIMEParser(BaseMIMEParser):
"""
Reddit uploads do not have a file extension, but we can grab the mime-type
from the page header.
"""
pattern = re.compile(r'https://i\.reddituploads\.com/.+$')
@staticmethod
def get_mimetype(url):
page = requests.head(url)
content_type = page.headers.get('Content-Type', '')
content_type = content_type.split(';')[0] # Strip out the encoding
return url, content_type
class RedditVideoMIMEParser(BaseMIMEParser):
"""
Reddit hosted videos/gifs.
Media uses MPEG-DASH format (.mpd)
"""
pattern = re.compile(r'https://v\.redd\.it/.+$')
@staticmethod
def get_mimetype(url):
request_url = url + '/DASHPlaylist.mpd'
page = requests.get(request_url)
soup = BeautifulSoup(page.content, 'html.parser')
if not soup.find('representation', attrs={'mimetype': 'audio/mp4'}):
reps = soup.find_all('representation', attrs={'mimetype': 'video/mp4'})
reps = sorted(reps, reverse=True, key=lambda t: int(t.get('bandwidth')))
if reps:
url_suffix = reps[0].find('baseurl')
if url_suffix:
return url + '/' + url_suffix.text, 'video/mp4'
return request_url, 'video/x-youtube'
class ImgurApiMIMEParser(BaseMIMEParser):
"""
Imgur now provides a json API exposing its entire infrastructure. Each Imgur
page has an associated hash and can either contain an album, a gallery,
or single image.
The default client token for TUIR is shared among users and allows a maximum
global number of requests per day of 12,500. If we find that this limit is
not sufficient for all of tuir's traffic, this method will be revisited.
Reference:
https://apidocs.imgur.com
"""
CLIENT_ID = None
pattern = re.compile(
r'https?://(w+\.)?(m\.)?imgur\.com/'
r'((?P<domain>a|album|gallery)/)?(?P<hash>[a-zA-Z0-9]+)$')
@classmethod
def get_mimetype(cls, url):
endpoint = 'https://api.imgur.com/3/{domain}/{page_hash}'
headers = {'authorization': 'Client-ID {0}'.format(cls.CLIENT_ID)}
m = cls.pattern.match(url)
page_hash = m.group('hash')
if m.group('domain') in ('a', 'album'):
domain = 'album'
else:
# This could be a gallery or a single image, but there doesn't
# seem to be a way to reliably distinguish between the two.
# Assume a gallery, which appears to be more common, and fallback
# to an image request upon failure.
domain = 'gallery'
if not cls.CLIENT_ID:
return cls.fallback(url, domain)
api_url = endpoint.format(domain=domain, page_hash=page_hash)
r = requests.get(api_url, headers=headers)
if domain == 'gallery' and r.status_code != 200:
# Not a gallery, try to download using the image endpoint
api_url = endpoint.format(domain='image', page_hash=page_hash)
r = requests.get(api_url, headers=headers)
if r.status_code != 200:
_logger.warning('Imgur API failure, status %s', r.status_code)
return cls.fallback(url, domain)
data = r.json().get('data')
if not data:
_logger.warning('Imgur API failure, resp %s', r.json())
return cls.fallback(url, domain)
if 'images' in data and len(data['images']) > 1:
# TODO: handle imgur albums with mixed content, i.e. jpeg and gifv
link = ' '.join([d['link'] for d in data['images'] if not d['animated']])
mime = 'image/x-imgur-album'
else:
data = data['images'][0] if 'images' in data else data
# this handles single image galleries
link = data['mp4'] if data['animated'] else data['link']
mime = 'video/mp4' if data['animated'] else data['type']
link = link.replace('http://', 'https://')
return link, mime
@classmethod
def fallback(cls, url, domain):
"""
Attempt to use one of the scrapers if the API doesn't work
"""
if domain == 'album':
# The old Imgur album scraper has stopped working and I haven't
# put in the effort to figure out why
return url, None
else:
return ImgurScrapeMIMEParser.get_mimetype(url)
class ImgurScrapeMIMEParser(BaseMIMEParser):
"""
The majority of imgur links don't point directly to the image, so we need
to open the provided url and scrape the page for the link.
Scrape the actual image url from an imgur landing page. Imgur intentionally
obscures this on most reddit links in order to draw more traffic for their
advertisements.
There are a couple of <meta> tags that supply the relevant info:
<meta name="twitter:image" content="https://i.imgur.com/xrqQ4LEh.jpg">
<meta property="og:image" content="http://i.imgur.com/xrqQ4LE.jpg?fb">
<link rel="image_src" href="http://i.imgur.com/xrqQ4LE.jpg">
"""
pattern = re.compile(r'https?://(w+\.)?(m\.)?imgur\.com/[^.]+$')
@staticmethod
def get_mimetype(url):
page = requests.get(url)
soup = BeautifulSoup(page.content, 'html.parser')
tag = soup.find('meta', attrs={'name': 'twitter:image'})
if tag:
url = tag.get('content')
if GifvMIMEParser.pattern.match(url):
return GifvMIMEParser.get_mimetype(url)
return BaseMIMEParser.get_mimetype(url)
class InstagramMIMEParser(OpenGraphMIMEParser):
"""
Instagram uses the Open Graph protocol
"""
pattern = re.compile(r'https?://(www\.)?instagr((am\.com)|\.am)/p/[^.]+$')
class StreamableMIMEParser(OpenGraphMIMEParser):
"""
Streamable uses the Open Graph protocol
"""
pattern = re.compile(r'https?://(www\.)?streamable\.com/[^.]+$')
class LiveleakMIMEParser(BaseMIMEParser):
"""
https://www.liveleak.com/view?i=12c_3456789
<video>
<source src="https://cdn.liveleak.com/..mp4" res="HD" type="video/mp4">
<source src="https://cdn.liveleak.com/..mp4" res="SD" type="video/mp4">
</video>
Sometimes only one video source is available
"""
pattern = re.compile(r'https?://((www|m)\.)?liveleak\.com/view\?i=\w+$')
@staticmethod
def get_mimetype(url):
page = requests.get(url)
soup = BeautifulSoup(page.content, 'html.parser')
urls = []
videos = soup.find_all('video')
for vid in videos:
source = vid.find('source', attr={'res': 'HD'})
source = source or vid.find('source')
if source:
urls.append((source.get('src'), source.get('type')))
# TODO: Handle pages with multiple videos
if urls:
return urls[0]
def filter_iframe(t):
return t.name == 'iframe' and 'youtube.com' in t['src']
iframe = soup.find_all(filter_iframe)
if iframe:
return YoutubeMIMEParser.get_mimetype(iframe[0]['src'].strip('/'))
return url, None
class ClippitUserMIMEParser(BaseMIMEParser):
"""
Clippit uses a video player container
"""
pattern = re.compile(r'https?://(www\.)?clippituser\.tv/c/.+$')
@staticmethod
def get_mimetype(url):
page = requests.get(url)
soup = BeautifulSoup(page.content, 'html.parser')
tag = soup.find(id='player-container')
if tag:
quality = ['data-{}-file'.format(_) for _ in ['hd', 'sd']]
new_url = tag.get(quality[0])
if new_url:
return new_url, 'video/mp4'
return url, None
class GifsMIMEParser(OpenGraphMIMEParser):
"""
Gifs.com uses the Open Graph protocol
"""
pattern = re.compile(r'https?://(www\.)?gifs\.com/gif/.+$')
class GiphyMIMEParser(OpenGraphMIMEParser):
"""
Giphy.com uses the Open Graph protocol
"""
pattern = re.compile(r'https?://(www\.)?giphy\.com/gifs/.+$')
class ImgflipMIMEParser(OpenGraphMIMEParser):
"""
imgflip.com uses the Open Graph protocol
"""
pattern = re.compile(r'https?://(www\.)?imgflip\.com/i/.+$')
class LivememeMIMEParser(OpenGraphMIMEParser):
"""
livememe.com uses the Open Graph protocol
"""
pattern = re.compile(r'https?://(www\.)?livememe\.com/[^.]+$')
class MakeamemeMIMEParser(OpenGraphMIMEParser):
"""
makeameme.com uses the Open Graph protocol
"""
pattern = re.compile(r'https?://(www\.)?makeameme\.org/meme/.+$')
class FlickrMIMEParser(OpenGraphMIMEParser):
"""
Flickr uses the Open Graph protocol
"""
# TODO: handle albums/photosets (https://www.flickr.com/services/api)
pattern = re.compile(r'https?://(www\.)?flickr\.com/photos/[^/]+/[^/]+/?$')
class StreamjaMIMEParser(VideoTagMIMEParser):
"""
Embedded HTML5 video element
"""
pattern = re.compile(r'https?://(www\.)?streamja\.com/[^/]+/?$')
class WorldStarHipHopMIMEParser(BaseMIMEParser):
"""
<video>
<source src="https://hw-mobile.worldstarhiphop.com/..mp4" type="video/mp4">
<source src="" type="video/mp4">
</video>
Sometimes only one video source is available
"""
pattern = re.compile(r'https?://((www|m)\.)?worldstarhiphop\.com/videos/video.php\?v=\w+$')
@staticmethod
def get_mimetype(url):
page = requests.get(url)
soup = BeautifulSoup(page.content, 'html.parser')
def filter_source(t):
return t.name == 'source' and t['src'] and t['type'] == 'video/mp4'
source = soup.find_all(filter_source)
if source:
return source[0]['src'], 'video/mp4'
def filter_iframe(t):
return t.name == 'iframe' and 'youtube.com' in t['src']
iframe = soup.find_all(filter_iframe)
if iframe:
return YoutubeMIMEParser.get_mimetype(iframe[0]['src'])
return url, None
# Parsers should be listed in the order they will be checked
parsers = [
StreamjaMIMEParser,
ClippitUserMIMEParser,
StreamableMIMEParser,
InstagramMIMEParser,
GfycatMIMEParser,
ImgurApiMIMEParser,
RedditUploadsMIMEParser,
RedditVideoMIMEParser,
YoutubeMIMEParser,
VimeoMIMEParser,
LiveleakMIMEParser,
FlickrMIMEParser,
GifsMIMEParser,
GiphyMIMEParser,
ImgflipMIMEParser,
LivememeMIMEParser,
MakeamemeMIMEParser,
WorldStarHipHopMIMEParser,
GifvMIMEParser,
BaseMIMEParser]

248
tuir/oauth.py Normal file
View File

@@ -0,0 +1,248 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
import time
import uuid
import string
import codecs
import logging
import threading
# pylint: disable=import-error
from six.moves.urllib.parse import urlparse, parse_qs
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
from . import docs
from .config import TEMPLATES
from .exceptions import InvalidRefreshToken
from .packages.praw.errors import HTTPException, OAuthException
_logger = logging.getLogger(__name__)
INDEX = os.path.join(TEMPLATES, 'index.html')
class OAuthHTTPServer(HTTPServer):
def handle_error(self, request, client_address):
"""
The default HTTPServer's error handler prints the request traceback
to stdout, which breaks the curses display.
Override it to log to a file instead.
"""
_logger.exception('Error processing request in OAuth HTTP Server')
class OAuthHandler(BaseHTTPRequestHandler):
# params are stored as a global because we don't have control over what
# gets passed into the handler __init__. These will be accessed by the
# OAuthHelper class.
params = {'state': None, 'code': None, 'error': None}
shutdown_on_request = True
def do_GET(self):
"""
Accepts GET requests to http://localhost:6500/, and stores the query
params in the global dict. If shutdown_on_request is true, stop the
server after the first successful request.
The http request may contain the following query params:
- state : unique identifier, should match what we passed to reddit
- code : code that can be exchanged for a refresh token
- error : if provided, the OAuth error that occurred
"""
parsed_path = urlparse(self.path)
if parsed_path.path != '/':
self.send_error(404)
qs = parse_qs(parsed_path.query)
self.params['state'] = qs['state'][0] if 'state' in qs else None
self.params['code'] = qs['code'][0] if 'code' in qs else None
self.params['error'] = qs['error'][0] if 'error' in qs else None
body = self.build_body()
# send_response also sets the Server and Date headers
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=UTF-8')
self.send_header('Content-Length', len(body))
self.end_headers()
self.wfile.write(body)
if self.shutdown_on_request:
# Shutdown the server after serving the request
# http://stackoverflow.com/a/22533929
thread = threading.Thread(target=self.server.shutdown)
thread.daemon = True
thread.start()
def log_message(self, fmt, *args):
"""
Redirect logging to our own handler instead of stdout
"""
_logger.debug(fmt, *args)
def build_body(self, template_file=INDEX):
"""
Params:
template_file (text): Path to an index.html template
Returns:
body (bytes): THe utf-8 encoded document body
"""
if self.params['error'] == 'access_denied':
message = docs.OAUTH_ACCESS_DENIED
elif self.params['error'] is not None:
message = docs.OAUTH_ERROR.format(error=self.params['error'])
elif self.params['state'] is None or self.params['code'] is None:
message = docs.OAUTH_INVALID
else:
message = docs.OAUTH_SUCCESS
with codecs.open(template_file, 'r', 'utf-8') as fp:
index_text = fp.read()
body = string.Template(index_text).substitute(message=message)
body = codecs.encode(body, 'utf-8')
return body
class OAuthHelper(object):
params = OAuthHandler.params
def __init__(self, reddit, term, config):
self.term = term
self.reddit = reddit
self.config = config
# Wait to initialize the server, we don't want to reserve the port
# unless we know that the server needs to be used.
self.server = None
self.reddit.set_oauth_app_info(
self.config['oauth_client_id'],
self.config['oauth_client_secret'],
self.config['oauth_redirect_uri'])
# Reddit's mobile website works better on terminal browsers
if not self.term.display:
if '.compact' not in self.reddit.config.API_PATHS['authorize']:
self.reddit.config.API_PATHS['authorize'] += '.compact'
def authorize(self, autologin=False):
self.params.update(state=None, code=None, error=None)
# If we already have a token, request new access credentials
if self.config.refresh_token:
with self.term.loader('Logging in'):
try:
self.reddit.refresh_access_information(
self.config.refresh_token)
except (HTTPException, OAuthException) as e:
# Reddit didn't accept the refresh-token
# This appears to throw a generic 400 error instead of the
# more specific invalid_token message that it used to send
if isinstance(e, HTTPException):
if e._raw.status_code != 400:
# No special handling if the error is something
# temporary like a 5XX.
raise e
# Otherwise we know the token is bad, so we can remove it.
_logger.exception(e)
self.clear_oauth_data()
raise InvalidRefreshToken(
' Invalid user credentials!\n'
'The cached refresh token has been removed')
else:
if not autologin:
# Only show the welcome message if explicitly logging
# in, not when TUIR first launches.
message = 'Welcome {}!'.format(self.reddit.user.name)
self.term.show_notification(message)
return
state = uuid.uuid4().hex
authorize_url = self.reddit.get_authorize_url(
state, scope=self.config['oauth_scope'], refreshable=True)
if self.server is None:
address = ('', self.config['oauth_redirect_port'])
self.server = OAuthHTTPServer(address, OAuthHandler)
if self.term.display:
# Open a background browser (e.g. firefox) which is non-blocking.
# The server will block until it responds to its first request,
# at which point we can check the callback params.
OAuthHandler.shutdown_on_request = True
with self.term.loader('Opening browser for authorization'):
self.term.open_browser(authorize_url)
self.server.serve_forever()
if self.term.loader.exception:
# Don't need to call server.shutdown() because serve_forever()
# is wrapped in a try-finally that doees it for us.
return
else:
# Open the terminal webbrowser in a background thread and wait
# while for the user to close the process. Once the process is
# closed, the iloop is stopped and we can check if the user has
# hit the callback URL.
OAuthHandler.shutdown_on_request = False
with self.term.loader('Redirecting to reddit', delay=0):
# This load message exists to provide user feedback
time.sleep(1)
thread = threading.Thread(target=self.server.serve_forever)
thread.daemon = True
thread.start()
try:
self.term.open_browser(authorize_url)
except Exception as e:
# If an exception is raised it will be seen by the thread
# so we don't need to explicitly shutdown() the server
_logger.exception(e)
self.term.show_notification('Browser Error', style='Error')
else:
self.server.shutdown()
finally:
thread.join()
if self.params['error'] == 'access_denied':
self.term.show_notification('Denied access', style='Error')
return
elif self.params['error']:
self.term.show_notification('Authentication error', style='Error')
return
elif self.params['state'] is None:
# Something went wrong but it's not clear what happened
return
elif self.params['state'] != state:
self.term.show_notification('UUID mismatch', style='Error')
return
with self.term.loader('Logging in'):
info = self.reddit.get_access_information(self.params['code'])
if self.term.loader.exception:
return
message = 'Welcome {}!'.format(self.reddit.user.name)
self.term.show_notification(message)
self.config.refresh_token = info['refresh_token']
if self.config['persistent']:
self.config.save_refresh_token()
def clear_oauth_data(self):
self.reddit.clear_authentication()
self.config.delete_refresh_token()

709
tuir/objects.py Normal file
View File

@@ -0,0 +1,709 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import re
import os
import sys
import time
import signal
import inspect
import weakref
import logging
import threading
import webbrowser
import curses
import curses.ascii
from contextlib import contextmanager
import six
import requests
from . import exceptions
from .packages import praw
_logger = logging.getLogger(__name__)
def patch_webbrowser():
"""
Some custom patches on top of the python webbrowser module to fix
user reported bugs and limitations of the module.
"""
# https://bugs.python.org/issue31014
# https://github.com/michael-lazar/rtv/issues/588
def register_patch(name, klass, instance=None, update_tryorder=None, preferred=False):
"""
Wrapper around webbrowser.register() that detects if the function was
invoked with the legacy function signature. If so, the signature is
fixed before passing it along to the underlying function.
Examples:
register(name, klass, instance, -1)
register(name, klass, instance, update_tryorder=-1)
register(name, klass, instance, preferred=True)
"""
if update_tryorder is not None:
preferred = (update_tryorder == -1)
return webbrowser._register(name, klass, instance, preferred=preferred)
if sys.version_info[:2] >= (3, 7):
webbrowser._register = webbrowser.register
webbrowser.register = register_patch
# Add support for browsers that aren't defined in the python standard library
webbrowser.register('surf', None, webbrowser.BackgroundBrowser('surf'))
webbrowser.register('vimb', None, webbrowser.BackgroundBrowser('vimb'))
webbrowser.register('qutebrowser', None, webbrowser.BackgroundBrowser('qutebrowser'))
# Fix the opera browser, see https://github.com/michael-lazar/rtv/issues/476.
# By default, opera will open a new tab in the current window, which is
# what we want to do anyway.
webbrowser.register('opera', None, webbrowser.BackgroundBrowser('opera'))
# https://bugs.python.org/issue31348
# Use MacOS actionscript when opening the program defined in by $BROWSER
if sys.platform == 'darwin' and 'BROWSER' in os.environ:
_userchoices = os.environ["BROWSER"].split(os.pathsep)
for cmdline in reversed(_userchoices):
if cmdline in ('safari', 'firefox', 'chrome', 'default'):
browser = webbrowser.MacOSXOSAScript(cmdline)
webbrowser.register(cmdline, None, browser, update_tryorder=-1)
@contextmanager
def curses_session():
"""
Setup terminal and initialize curses. Most of this copied from
curses.wrapper in order to convert the wrapper into a context manager.
"""
try:
# Curses must wait for some time after the Escape key is pressed to
# check if it is the beginning of an escape sequence indicating a
# special key. The default wait time is 1 second, which means that
# http://stackoverflow.com/questions/27372068
os.environ['ESCDELAY'] = '25'
# Initialize curses
stdscr = curses.initscr()
# Turn off echoing of keys, and enter cbreak mode, where no buffering
# is performed on keyboard input
curses.noecho()
curses.cbreak()
# In keypad mode, escape sequences for special keys (like the cursor
# keys) will be interpreted and a special value like curses.KEY_LEFT
# will be returned
stdscr.keypad(1)
# Start color, too. Harmless if the terminal doesn't have color; user
# can test with has_color() later on. The try/catch works around a
# minor bit of over-conscientiousness in the curses module -- the error
# return from C start_color() is ignorable.
try:
curses.start_color()
curses.use_default_colors()
except:
_logger.warning('Curses failed to initialize color support')
# Hide the blinking cursor
try:
curses.curs_set(0)
except:
_logger.warning('Curses failed to initialize the cursor mode')
yield stdscr
finally:
if 'stdscr' in locals():
stdscr.keypad(0)
curses.echo()
curses.nocbreak()
curses.endwin()
class LoadScreen(object):
"""
Display a loading dialog while waiting for a blocking action to complete.
This class spins off a separate thread to animate the loading screen in the
background. The loading thread also takes control of stdscr.getch(). If
an exception occurs in the main thread while the loader is active, the
exception will be caught, attached to the loader object, and displayed as
a notification. The attached exception can be used to trigger context
sensitive actions. For example, if the connection hangs while opening a
submission, the user may press ctrl-c to raise a KeyboardInterrupt. In this
case we would *not* want to refresh the current page.
>>> with self.terminal.loader(...) as loader:
>>> # Perform a blocking request to load content
>>> blocking_request(...)
>>>
>>> if loader.exception is None:
>>> # Only run this if the load was successful
>>> self.refresh_content()
When a loader is nested inside of itself, the outermost loader takes
priority and all of the nested loaders become no-ops. Call arguments given
to nested loaders will be ignored, and errors will propagate to the parent.
>>> with self.terminal.loader(...) as loader:
>>>
>>> # Additional loaders will be ignored
>>> with self.terminal.loader(...):
>>> raise KeyboardInterrupt()
>>>
>>> # This code will not be executed because the inner loader doesn't
>>> # catch the exception
>>> assert False
>>>
>>> # The exception is finally caught by the outer loader
>>> assert isinstance(terminal.loader.exception, KeyboardInterrupt)
"""
EXCEPTION_MESSAGES = [
(exceptions.TUIRError, '{0}'),
(praw.errors.OAuthException, 'OAuth Error'),
(praw.errors.OAuthScopeRequired, 'Not logged in'),
(praw.errors.LoginRequired, 'Not logged in'),
(praw.errors.InvalidCaptcha, 'Error, captcha required'),
(praw.errors.InvalidSubreddit, '{0.args[0]}'),
(praw.errors.PRAWException, '{0.__class__.__name__}'),
(requests.exceptions.Timeout, 'HTTP request timed out'),
(requests.exceptions.RequestException, '{0.__class__.__name__}'),
]
def __init__(self, terminal):
self.exception = None
self.catch_exception = None
self.depth = 0
self._terminal = weakref.proxy(terminal)
self._args = None
self._animator = None
self._is_running = None
def __call__(
self,
message='Downloading',
trail='...',
delay=0.5,
interval=0.4,
catch_exception=True):
"""
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.
catch_exception (bool): If an exception occurs while the loader is
active, this flag determines whether it is caught or allowed to
bubble up.
"""
if self.depth > 0:
return self
self.exception = None
self.catch_exception = catch_exception
self._args = (delay, interval, message, trail)
return self
def __enter__(self):
self.depth += 1
if self.depth > 1:
return self
self._animator = threading.Thread(target=self.animate, args=self._args)
self._animator.daemon = True
self._is_running = True
self._animator.start()
return self
def __exit__(self, exc_type, e, exc_tb):
self.depth -= 1
if self.depth > 0:
return
self._is_running = False
self._animator.join()
if e is None or not self.catch_exception:
# Skip exception handling
return
self.exception = e
exc_name = type(e).__name__
_logger.info('Loader caught: %s - %s', exc_name, e)
if isinstance(e, KeyboardInterrupt):
# Don't need to print anything for this one, just swallow it
return True
for e_type, message in self.EXCEPTION_MESSAGES:
# Some exceptions we want to swallow and display a notification
if isinstance(e, e_type):
msg = message.format(e)
self._terminal.show_notification(msg, style='Error')
return True
def animate(self, delay, interval, message, trail):
# The animation starts with a configurable delay before drawing on the
# screen. This is to prevent very short loading sections from
# flickering on the screen before immediately disappearing.
with self._terminal.no_delay():
start = time.time()
while (time.time() - start) < delay:
# Pressing escape triggers a keyboard interrupt
if self._terminal.getch() == self._terminal.ESCAPE:
os.kill(os.getpid(), signal.SIGINT)
self._is_running = False
if not self._is_running:
return
time.sleep(0.01)
# Build the notification window. Note that we need to use
# curses.newwin() instead of stdscr.derwin() so the text below the
# notification window does not got erased when we cover it up.
message_len = len(message) + len(trail)
n_rows, n_cols = self._terminal.stdscr.getmaxyx()
v_offset, h_offset = self._terminal.stdscr.getbegyx()
s_row = (n_rows - 3) // 2 + v_offset
s_col = (n_cols - message_len - 1) // 2 + h_offset
window = curses.newwin(3, message_len + 2, s_row, s_col)
window.bkgd(str(' '), self._terminal.attr('NoticeLoading'))
# Animate the loading prompt until the stopping condition is triggered
# when the context manager exits.
with self._terminal.no_delay():
while True:
for i in range(len(trail) + 1):
if not self._is_running:
window.erase()
del window
self._terminal.stdscr.touchwin()
self._terminal.stdscr.refresh()
return
window.erase()
window.border()
self._terminal.add_line(window, message + trail[:i], 1, 1)
window.refresh()
# Break up the designated sleep interval into smaller
# chunks so we can more responsively check for interrupts.
for _ in range(int(interval / 0.01)):
# Pressing escape triggers a keyboard interrupt
if self._terminal.getch() == self._terminal.ESCAPE:
os.kill(os.getpid(), signal.SIGINT)
self._is_running = False
break
time.sleep(0.01)
class Navigator(object):
"""
Handles the math behind cursor movement and screen paging.
This class determines how cursor movements effect the currently displayed
page. For example, if scrolling down the page, items are drawn from the
bottom up. This ensures that the item at the very bottom of the screen
(the one selected by cursor) will be fully drawn and not cut off. Likewise,
when scrolling up the page, items are drawn from the top down. If the
cursor is moved around without hitting the top or bottom of the screen, the
current mode is preserved.
"""
def __init__(
self,
valid_page_cb,
page_index=0,
cursor_index=0,
inverted=False,
top_item_height=None):
"""
Params:
valid_page_callback (func): This function, usually `Content.get`,
takes a page index and raises an IndexError if that index falls
out of bounds. This is used to determine the upper and lower
bounds of the page, i.e. when to stop scrolling.
page_index (int): Initial page index.
cursor_index (int): Initial cursor index, relative to the page.
inverted (bool): Whether the page scrolling is reversed of not.
normal - The page is drawn from the top of the screen,
starting with the page index, down to the bottom of
the screen.
inverted - The page is drawn from the bottom of the screen,
starting with the page index, up to the top of the
screen.
top_item_height (int): If this is set to a non-null value
The number of columns that the top-most item
should utilize if non-inverted. This is used for a special mode
where all items are drawn non-inverted except for the top one.
"""
self.page_index = page_index
self.cursor_index = cursor_index
self.inverted = inverted
self.top_item_height = top_item_height
self._page_cb = valid_page_cb
@property
def step(self):
return 1 if not self.inverted else -1
@property
def position(self):
return self.page_index, self.cursor_index, self.inverted
@property
def absolute_index(self):
"""
Return the index of the currently selected item.
"""
return self.page_index + (self.step * self.cursor_index)
def move(self, direction, n_windows):
"""
Move the cursor up or down by the given increment.
Params:
direction (int): `1` will move the cursor down one item and `-1`
will move the cursor up one item.
n_windows (int): The number of items that are currently being drawn
on the screen.
Returns:
valid (bool): Indicates whether or not the attempted cursor move is
allowed. E.g. When the cursor is on the last comment,
attempting to scroll down any further would not be valid.
redraw (bool): Indicates whether or not the screen needs to be
redrawn.
"""
assert direction in (-1, 1)
valid, redraw = True, False
forward = ((direction * self.step) > 0)
if forward:
if self.page_index < 0:
if self._is_valid(0):
# Special case - advance the page index if less than zero
self.page_index = 0
self.cursor_index = 0
redraw = True
else:
valid = False
else:
self.cursor_index += 1
if not self._is_valid(self.absolute_index):
# Move would take us out of bounds
self.cursor_index -= 1
valid = False
elif self.cursor_index >= (n_windows - 1):
# Flip the orientation and reset the cursor
self.flip(self.cursor_index)
self.cursor_index = 0
self.top_item_height = None
redraw = True
else:
if self.cursor_index > 0:
self.cursor_index -= 1
if self.top_item_height and self.cursor_index == 0:
# Selecting the partially displayed item
self.top_item_height = None
redraw = True
else:
self.page_index -= self.step
if self._is_valid(self.absolute_index):
# We have reached the beginning of the page - move the
# index
self.top_item_height = None
redraw = True
else:
self.page_index += self.step
valid = False # Revert
return valid, redraw
def move_page(self, direction, n_windows):
"""
Move the page down (positive direction) or up (negative direction).
Paging down:
The post on the bottom of the page becomes the post at the top of
the page and the cursor is moved to the top.
Paging up:
The post at the top of the page becomes the post at the bottom of
the page and the cursor is moved to the bottom.
"""
assert direction in (-1, 1)
assert n_windows >= 0
# top of subreddit/submission page or only one
# submission/reply on the screen: act as normal move
if (self.absolute_index < 0) | (n_windows == 0):
valid, redraw = self.move(direction, n_windows)
else:
# first page
if self.absolute_index < n_windows and direction < 0:
self.page_index = -1
self.cursor_index = 0
self.inverted = False
# not submission mode: starting index is 0
if not self._is_valid(self.absolute_index):
self.page_index = 0
valid = True
else:
# flip to the direction of movement
if ((direction > 0) & (self.inverted is True)) \
| ((direction < 0) & (self.inverted is False)):
self.page_index += (self.step * (n_windows - 1))
self.inverted = not self.inverted
self.cursor_index \
= (n_windows - (direction < 0)) - self.cursor_index
valid = False
adj = 0
# check if reached the bottom
while not valid:
n_move = n_windows - adj
if n_move == 0:
break
self.page_index += n_move * direction
valid = self._is_valid(self.absolute_index)
if not valid:
self.page_index -= n_move * direction
adj += 1
redraw = True
return valid, redraw
def flip(self, n_windows):
"""
Flip the orientation of the page.
"""
assert n_windows >= 0
self.page_index += (self.step * n_windows)
self.cursor_index = n_windows
self.inverted = not self.inverted
self.top_item_height = None
def _is_valid(self, page_index):
"""
Check if a page index will cause entries to fall outside valid range.
"""
try:
self._page_cb(page_index)
except IndexError:
return False
else:
return True
class Controller(object):
"""
Event handler for triggering functions with curses keypresses.
Register a keystroke to a class method using the @register decorator.
>>> @Controller.register('a', 'A')
>>> def func(self, *args)
>>> ...
Register a KeyBinding that can be defined later by the config file
>>> @Controller.register(Command("UPVOTE"))
>>> def upvote(self, *args)
>> ...
Bind the controller to a class instance and trigger a key. Additional
arguments will be passed to the function.
>>> controller = Controller(self)
>>> controller.trigger('a', *args)
"""
character_map = {}
def __init__(self, instance, keymap=None):
self.instance = instance
# Build a list of parent controllers that follow the object's MRO
# to check if any parent controllers have registered the keypress
self.parents = inspect.getmro(type(self))[:-1]
# Keep track of last key press for doubles like `gg`
self.last_char = None
if not keymap:
return
# Go through the controller and all of it's parents and look for
# Command objects in the character map. Use the keymap the lookup the
# keys associated with those command objects and add them to the
# character map.
for controller in self.parents:
for command, func in controller.character_map.copy().items():
if isinstance(command, Command):
for key in keymap.get(command):
val = keymap.parse(key)
# If a double key press is defined, the first half
# must be unbound
if isinstance(val, tuple):
if controller.character_map.get(val[0]) is not None:
raise exceptions.ConfigError(
"Invalid configuration! `%s` is bound to "
"duplicate commands in the "
"%s" % (key, controller.__name__))
# Mark the first half of the double with None so
# that no other command can use it
controller.character_map[val[0]] = None
# Check if the key is already programmed to trigger a
# different function.
if controller.character_map.get(val, func) != func:
raise exceptions.ConfigError(
"Invalid configuration! `%s` is bound to "
"duplicate commands in the "
"%s" % (key, controller.__name__))
controller.character_map[val] = func
def trigger(self, char, *args, **kwargs):
if isinstance(char, six.string_types) and len(char) == 1:
char = ord(char)
func = None
# Check if the controller (or any of the controller's parents) have
# registered a function to the given key
for controller in self.parents:
func = controller.character_map.get((self.last_char, char))
if func:
break
func = controller.character_map.get(char)
if func:
break
if func:
self.last_char = None
return func(self.instance, *args, **kwargs)
else:
self.last_char = char
return None
@classmethod
def register(cls, *chars):
def inner(f):
for char in chars:
if isinstance(char, six.string_types) and len(char) == 1:
cls.character_map[ord(char)] = f
else:
cls.character_map[char] = f
return f
return inner
class Command(object):
"""
Minimal class that should be used to wrap abstract commands that may be
implemented as one or more physical keystrokes.
E.g. Command("REFRESH") can be represented by the KeyMap to be triggered
by either `r` or `F5`
"""
def __init__(self, val):
self.val = val.upper()
def __repr__(self):
return 'Command(%s)' % self.val
def __eq__(self, other):
return repr(self) == repr(other)
def __ne__(self, other):
return not self == other
def __hash__(self):
return hash(repr(self))
class KeyMap(object):
"""
Mapping between commands and the keys that they represent.
"""
def __init__(self, bindings):
self._keymap = None
self.set_bindings(bindings)
def set_bindings(self, bindings):
new_keymap = {}
for command, keys in bindings.items():
if not isinstance(command, Command):
command = Command(command)
new_keymap[command] = keys
if not self._keymap:
self._keymap = new_keymap
else:
self._keymap.update(new_keymap)
def get(self, command):
if not isinstance(command, Command):
command = Command(command)
try:
return self._keymap[command]
except KeyError:
raise exceptions.ConfigError('Invalid configuration! `%s` key is '
'undefined' % command.val)
@classmethod
def parse(cls, key):
"""
Parse a key represented by a string and return its character code.
"""
try:
if isinstance(key, int):
return key
elif re.match('[<]KEY_.*[>]', key):
# Curses control character
return getattr(curses, key[1:-1])
elif re.match('[<].*[>]', key):
# Ascii control character
return getattr(curses.ascii, key[1:-1])
elif key.startswith('0x'):
# Ascii hex code
return int(key, 16)
elif len(key) == 2:
# Double presses
return tuple(cls.parse(k) for k in key)
else:
# Ascii character
code = ord(key)
if 0 <= code <= 255:
return code
# Python 3.3 has a curses.get_wch() function that we can use
# for unicode keys, but Python 2.7 is limited to ascii.
raise exceptions.ConfigError('Invalid configuration! `%s` is '
'not in the ascii range' % key)
except (AttributeError, ValueError, TypeError):
raise exceptions.ConfigError('Invalid configuration! "%s" is not a '
'valid key' % key)

26
tuir/packages/__init__.py Normal file
View File

@@ -0,0 +1,26 @@
"""
This stub allows the user to fallback to their system installation of
praw if the bundled package is missing. This technique was inspired by the
requests library and how it handles dependencies.
Reference:
https://github.com/kennethreitz/requests/blob/master/requests/packages/__init__.py
"""
from __future__ import absolute_import
import sys
__praw_hash__ = '1656ec224e574eed9cda4efcb497825d54b4d926'
__praw_bundled__ = True
try:
from . import praw
except ImportError:
import praw
if not praw.__version__.startswith('3.'):
raise RuntimeError('Invalid PRAW version ({0}) detected, '
'tuir requires PRAW version 3'.format(praw.__version__))
sys.modules['%s.praw' % __name__] = praw
__praw_bundled__ = False

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
"""Internal helper functions used by praw.decorators."""
import inspect
from requests.compat import urljoin
import six
import sys
def _get_captcha(reddit_session, captcha_id):
"""Prompt user for captcha solution and return a prepared result."""
url = urljoin(reddit_session.config['captcha'],
captcha_id + '.png')
sys.stdout.write('Captcha URL: {0}\nCaptcha: '.format(url))
sys.stdout.flush()
raw = sys.stdin.readline()
if not raw: # stdin has reached the end of file
# Trigger exception raising next time through. The request is
# cached so this will not require and extra request and delay.
sys.stdin.close()
return None
return {'iden': captcha_id, 'captcha': raw.strip()}
def _is_mod_of_all(user, subreddit):
mod_subs = user.get_cached_moderated_reddits()
subs = six.text_type(subreddit).lower().split('+')
return all(sub in mod_subs for sub in subs)
def _make_func_args(function):
if six.PY3 and not hasattr(sys, 'pypy_version_info'):
# CPython3 uses inspect.signature(), not inspect.getargspec()
# see #551 and #541 for more info
func_items = inspect.signature(function).parameters.items()
func_args = [name for name, param in func_items
if param.kind == param.POSITIONAL_OR_KEYWORD]
else:
func_args = inspect.getargspec(function).args
return func_args

View File

@@ -0,0 +1,294 @@
# This file is part of PRAW.
#
# PRAW is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# PRAW is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# PRAW. If not, see <http://www.gnu.org/licenses/>.
"""
Decorators.
They mainly do two things: ensure API guidelines are followed and
prevent unnecessary failed API requests by testing that the call can be made
first. Also, they can limit the length of output strings and parse json
response for certain errors.
"""
from __future__ import print_function, unicode_literals
import decorator
import six
import sys
from functools import wraps
from .decorator_helpers import (
_get_captcha,
_is_mod_of_all,
_make_func_args
)
from . import errors
from warnings import filterwarnings, warn
# Enable deprecation warnings from this module
filterwarnings('default', category=DeprecationWarning,
module='^praw\.decorators$')
def alias_function(function, class_name):
"""Create a RedditContentObject function mapped to a BaseReddit function.
The BaseReddit classes define the majority of the API's functions. The
first argument for many of these functions is the RedditContentObject that
they operate on. This factory returns functions appropriate to be called on
a RedditContent object that maps to the corresponding BaseReddit function.
"""
@wraps(function)
def wrapped(self, *args, **kwargs):
func_args = _make_func_args(function)
if 'subreddit' in func_args and func_args.index('subreddit') != 1:
# Only happens for search
kwargs['subreddit'] = self
return function(self.reddit_session, *args, **kwargs)
else:
return function(self.reddit_session, self, *args, **kwargs)
# Only grab the short-line doc and add a link to the complete doc
if wrapped.__doc__ is not None:
wrapped.__doc__ = wrapped.__doc__.split('\n', 1)[0]
wrapped.__doc__ += ('\n\nSee :meth:`.{0}.{1}` for complete usage. '
'Note that you should exclude the subreddit '
'parameter when calling this convenience method.'
.format(class_name, function.__name__))
# Don't hide from sphinx as this is a parameter modifying decorator
return wrapped
def deprecated(msg=''):
"""Deprecate decorated method."""
@decorator.decorator
def wrap(function, *args, **kwargs):
if not kwargs.pop('disable_warning', False):
warn(msg, DeprecationWarning)
return function(*args, **kwargs)
return wrap
@decorator.decorator
def limit_chars(function, *args, **kwargs):
"""Truncate the string returned from a function and return the result."""
output_chars_limit = args[0].reddit_session.config.output_chars_limit
output_string = function(*args, **kwargs)
if -1 < output_chars_limit < len(output_string):
output_string = output_string[:output_chars_limit - 3] + '...'
return output_string
@decorator.decorator
def oauth_generator(function, *args, **kwargs):
"""Set the _use_oauth keyword argument to True when appropriate.
This is needed because generator functions may be called at anytime, and
PRAW relies on the Reddit._use_oauth value at original call time to know
when to make OAuth requests.
Returned data is not modified.
"""
if getattr(args[0], '_use_oauth', False):
kwargs['_use_oauth'] = True
return function(*args, **kwargs)
@decorator.decorator
def raise_api_exceptions(function, *args, **kwargs):
"""Raise client side exception(s) when present in the API response.
Returned data is not modified.
"""
try:
return_value = function(*args, **kwargs)
except errors.HTTPException as exc:
if exc._raw.status_code != 400: # pylint: disable=W0212
raise # Unhandled HTTPErrors
try: # Attempt to convert v1 errors into older format (for now)
data = exc._raw.json() # pylint: disable=W0212
assert len(data) == 2
return_value = {'errors': [(data['reason'],
data['explanation'], '')]}
except Exception:
raise exc
if isinstance(return_value, dict):
if return_value.get('error') == 304: # Not modified exception
raise errors.NotModified(return_value)
elif return_value.get('errors'):
error_list = []
for error_type, msg, value in return_value['errors']:
if error_type in errors.ERROR_MAPPING:
if error_type == 'RATELIMIT':
args[0].evict(args[1])
error_class = errors.ERROR_MAPPING[error_type]
else:
error_class = errors.APIException
error_list.append(error_class(error_type, msg, value,
return_value))
if len(error_list) == 1:
raise error_list[0]
else:
raise errors.ExceptionList(error_list)
return return_value
@decorator.decorator
def require_captcha(function, *args, **kwargs):
"""Return a decorator for methods that require captchas."""
raise_captcha_exception = kwargs.pop('raise_captcha_exception', False)
captcha_id = None
# Get a handle to the reddit session
if hasattr(args[0], 'reddit_session'):
reddit_session = args[0].reddit_session
else:
reddit_session = args[0]
while True:
try:
if captcha_id:
captcha_answer = _get_captcha(reddit_session, captcha_id)
# When the method is being decorated, all of its default
# parameters become part of this *args tuple. This means that
# *args currently contains a None where the captcha answer
# needs to go. If we put the captcha in the **kwargs,
# we get a TypeError for having two values of the same param.
func_args = _make_func_args(function)
if 'captcha' in func_args:
captcha_index = func_args.index('captcha')
args = list(args)
args[captcha_index] = captcha_answer
else:
kwargs['captcha'] = captcha_answer
return function(*args, **kwargs)
except errors.InvalidCaptcha as exception:
if raise_captcha_exception or \
not hasattr(sys.stdin, 'closed') or sys.stdin.closed:
raise
captcha_id = exception.response['captcha']
def restrict_access(scope, mod=None, login=None, oauth_only=False,
generator_called=False):
"""Restrict function access unless the user has the necessary permissions.
Raises one of the following exceptions when appropriate:
* LoginRequired
* LoginOrOAuthRequired
* the scope attribute will provide the necessary scope name
* ModeratorRequired
* ModeratorOrOAuthRequired
* the scope attribute will provide the necessary scope name
:param scope: Indicate the scope that is required for the API call. None or
False must be passed to indicate that no scope handles the API call.
All scopes save for `read` imply login=True. Scopes with 'mod' in their
name imply mod=True.
:param mod: Indicate that a moderator is required. Implies login=True.
:param login: Indicate that a login is required.
:param oauth_only: Indicate that only OAuth is supported for the function.
:param generator_called: Indicate that the function consists solely of
exhausting one or more oauth_generator wrapped generators. This is
because the oauth_generator itself will determine whether or not to
use the oauth domain.
Returned data is not modified.
This decorator assumes that all mod required functions fit one of these
categories:
* have the subreddit as the first argument (Reddit instance functions) or
have a subreddit keyword argument
* are called upon a subreddit object (Subreddit RedditContentObject)
* are called upon a RedditContent object with attribute subreddit
"""
if not scope and oauth_only:
raise TypeError('`scope` must be set when `oauth_only` is set')
mod = mod is not False and (mod or scope and 'mod' in scope)
login = login is not False and (login or mod or scope and scope != 'read')
@decorator.decorator
def wrap(function, *args, **kwargs):
if args[0] is None: # Occurs with (un)friend
assert login
raise errors.LoginRequired(function.__name__)
# This segment of code uses hasattr to determine what instance type
# the function was called on. We could use isinstance if we wanted
# to import the types at runtime (decorators is used by all the
# types).
if mod:
if hasattr(args[0], 'reddit_session'):
# Defer access until necessary for RedditContentObject.
# This is because scoped sessions may not require this
# attribute to exist, thus it might not be set.
from .objects import Subreddit
subreddit = args[0] if isinstance(args[0], Subreddit) \
else False
else:
subreddit = kwargs.get(
'subreddit', args[1] if len(args) > 1 else None)
if subreddit is None: # Try the default value
defaults = six.get_function_defaults(function)
subreddit = defaults[0] if defaults else None
else:
subreddit = None
obj = getattr(args[0], 'reddit_session', args[0])
# This function sets _use_oauth for one time use only.
# Verify that statement is actually true.
assert not obj._use_oauth # pylint: disable=W0212
if scope and obj.has_scope(scope):
obj._use_oauth = not generator_called # pylint: disable=W0212
elif oauth_only:
raise errors.OAuthScopeRequired(function.__name__, scope)
elif login and obj.is_logged_in():
if subreddit is False:
# Now fetch the subreddit attribute. There is no good
# reason for it to not be set during a logged in session.
subreddit = args[0].subreddit
if mod and not _is_mod_of_all(obj.user, subreddit):
if scope:
raise errors.ModeratorOrScopeRequired(
function.__name__, scope)
raise errors.ModeratorRequired(function.__name__)
elif login:
if scope:
raise errors.LoginOrScopeRequired(function.__name__, scope)
raise errors.LoginRequired(function.__name__)
try:
return function(*args, **kwargs)
finally:
obj._use_oauth = False # pylint: disable=W0212
return wrap
@decorator.decorator
def require_oauth(function, *args, **kwargs):
"""Verify that the OAuth functions can be used prior to use.
Returned data is not modified.
"""
if not args[0].has_oauth_app_info:
err_msg = ("The OAuth app config parameters client_id, client_secret "
"and redirect_url must be specified to use this function.")
raise errors.OAuthAppRequired(err_msg)
return function(*args, **kwargs)

View File

@@ -0,0 +1,475 @@
# This file is part of PRAW.
#
# PRAW is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# PRAW is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# PRAW. If not, see <http://www.gnu.org/licenses/>.
"""
Error classes.
Includes two main exceptions: ClientException, when something goes
wrong on our end, and APIExeception for when something goes wrong on the
server side. A number of classes extend these two main exceptions for more
specific exceptions.
"""
from __future__ import print_function, unicode_literals
import inspect
import six
import sys
class PRAWException(Exception):
"""The base PRAW Exception class.
Ideally, this can be caught to handle any exception from PRAW.
"""
class ClientException(PRAWException):
"""Base exception class for errors that don't involve the remote API."""
def __init__(self, message=None):
"""Construct a ClientException.
:param message: The error message to display.
"""
if not message:
message = 'Clientside error'
super(ClientException, self).__init__()
self.message = message
def __str__(self):
"""Return the message of the error."""
return self.message
class OAuthScopeRequired(ClientException):
"""Indicates that an OAuth2 scope is required to make the function call.
The attribute `scope` will contain the name of the necessary scope.
"""
def __init__(self, function, scope, message=None):
"""Contruct an OAuthScopeRequiredClientException.
:param function: The function that requires a scope.
:param scope: The scope required for the function.
:param message: A custom message to associate with the
exception. Default: `function` requires the OAuth2 scope `scope`
"""
if not message:
message = '`{0}` requires the OAuth2 scope `{1}`'.format(function,
scope)
super(OAuthScopeRequired, self).__init__(message)
self.scope = scope
class LoginRequired(ClientException):
"""Indicates that a logged in session is required.
This exception is raised on a preemptive basis, whereas NotLoggedIn occurs
in response to a lack of credentials on a privileged API call.
"""
def __init__(self, function, message=None):
"""Construct a LoginRequired exception.
:param function: The function that requires login-based authentication.
:param message: A custom message to associate with the exception.
Default: `function` requires a logged in session
"""
if not message:
message = '`{0}` requires a logged in session'.format(function)
super(LoginRequired, self).__init__(message)
class LoginOrScopeRequired(OAuthScopeRequired, LoginRequired):
"""Indicates that either a logged in session or OAuth2 scope is required.
The attribute `scope` will contain the name of the necessary scope.
"""
def __init__(self, function, scope, message=None):
"""Construct a LoginOrScopeRequired exception.
:param function: The function that requires authentication.
:param scope: The scope that is required if not logged in.
:param message: A custom message to associate with the exception.
Default: `function` requires a logged in session or the OAuth2
scope `scope`
"""
if not message:
message = ('`{0}` requires a logged in session or the '
'OAuth2 scope `{1}`').format(function, scope)
super(LoginOrScopeRequired, self).__init__(function, scope, message)
class ModeratorRequired(LoginRequired):
"""Indicates that a moderator of the subreddit is required."""
def __init__(self, function):
"""Construct a ModeratorRequired exception.
:param function: The function that requires moderator access.
"""
message = ('`{0}` requires a moderator '
'of the subreddit').format(function)
super(ModeratorRequired, self).__init__(message)
class ModeratorOrScopeRequired(LoginOrScopeRequired, ModeratorRequired):
"""Indicates that a moderator of the sub or OAuth2 scope is required.
The attribute `scope` will contain the name of the necessary scope.
"""
def __init__(self, function, scope):
"""Construct a ModeratorOrScopeRequired exception.
:param function: The function that requires moderator authentication or
a moderator scope..
:param scope: The scope that is required if not logged in with
moderator access..
"""
message = ('`{0}` requires a moderator of the subreddit or the '
'OAuth2 scope `{1}`').format(function, scope)
super(ModeratorOrScopeRequired, self).__init__(function, scope,
message)
class OAuthAppRequired(ClientException):
"""Raised when an OAuth client cannot be initialized.
This occurs when any one of the OAuth config values are not set.
"""
class HTTPException(PRAWException):
"""Base class for HTTP related exceptions."""
def __init__(self, _raw, message=None):
"""Construct a HTTPException.
:params _raw: The internal request library response object. This object
is mapped to attribute `_raw` whose format may change at any time.
"""
if not message:
message = 'HTTP error'
super(HTTPException, self).__init__()
self._raw = _raw
self.message = message
def __str__(self):
"""Return the message of the error."""
return self.message
class Forbidden(HTTPException):
"""Raised when the user does not have permission to the entity."""
class NotFound(HTTPException):
"""Raised when the requested entity is not found."""
class InvalidComment(PRAWException):
"""Indicate that the comment is no longer available on reddit."""
ERROR_TYPE = 'DELETED_COMMENT'
def __str__(self):
"""Return the message of the error."""
return self.ERROR_TYPE
class InvalidSubmission(PRAWException):
"""Indicates that the submission is no longer available on reddit."""
ERROR_TYPE = 'DELETED_LINK'
def __str__(self):
"""Return the message of the error."""
return self.ERROR_TYPE
class InvalidSubreddit(PRAWException):
"""Indicates that an invalid subreddit name was supplied."""
ERROR_TYPE = 'SUBREDDIT_NOEXIST'
def __str__(self):
"""Return the message of the error."""
return self.ERROR_TYPE
class RedirectException(PRAWException):
"""Raised when a redirect response occurs that is not expected."""
def __init__(self, request_url, response_url, message=None):
"""Construct a RedirectException.
:param request_url: The url requested.
:param response_url: The url being redirected to.
:param message: A custom message to associate with the exception.
"""
if not message:
message = ('Unexpected redirect '
'from {0} to {1}').format(request_url, response_url)
super(RedirectException, self).__init__()
self.request_url = request_url
self.response_url = response_url
self.message = message
def __str__(self):
"""Return the message of the error."""
return self.message
class OAuthException(PRAWException):
"""Base exception class for OAuth API calls.
Attribute `message` contains the error message.
Attribute `url` contains the url that resulted in the error.
"""
def __init__(self, message, url):
"""Construct a OAuthException.
:param message: The message associated with the exception.
:param url: The url that resulted in error.
"""
super(OAuthException, self).__init__()
self.message = message
self.url = url
def __str__(self):
"""Return the message along with the url."""
return self.message + " on url {0}".format(self.url)
class OAuthInsufficientScope(OAuthException):
"""Raised when the current OAuth scope is not sufficient for the action.
This indicates the access token is valid, but not for the desired action.
"""
class OAuthInvalidGrant(OAuthException):
"""Raised when the code to retrieve access information is not valid."""
class OAuthInvalidToken(OAuthException):
"""Raised when the current OAuth access token is not valid."""
class APIException(PRAWException):
"""Base exception class for the reddit API error message exceptions.
All exceptions of this type should have their own subclass.
"""
def __init__(self, error_type, message, field='', response=None):
"""Construct an APIException.
:param error_type: The error type set on reddit's end.
:param message: The associated message for the error.
:param field: The input field associated with the error, or ''.
:param response: The HTTP response that resulted in the exception.
"""
super(APIException, self).__init__()
self.error_type = error_type
self.message = message
self.field = field
self.response = response
def __str__(self):
"""Return a string containing the error message and field."""
if hasattr(self, 'ERROR_TYPE'):
return '`{0}` on field `{1}`'.format(self.message, self.field)
else:
return '({0}) `{1}` on field `{2}`'.format(self.error_type,
self.message,
self.field)
class ExceptionList(APIException):
"""Raised when more than one exception occurred."""
def __init__(self, errors):
"""Construct an ExceptionList.
:param errors: The list of errors.
"""
super(ExceptionList, self).__init__(None, None)
self.errors = errors
def __str__(self):
"""Return a string representation for all the errors."""
ret = '\n'
for i, error in enumerate(self.errors):
ret += '\tError {0}) {1}\n'.format(i, six.text_type(error))
return ret
class AlreadySubmitted(APIException):
"""An exception to indicate that a URL was previously submitted."""
ERROR_TYPE = 'ALREADY_SUB'
class AlreadyModerator(APIException):
"""Used to indicate that a user is already a moderator of a subreddit."""
ERROR_TYPE = 'ALREADY_MODERATOR'
class BadCSS(APIException):
"""An exception to indicate bad CSS (such as invalid) was used."""
ERROR_TYPE = 'BAD_CSS'
class BadCSSName(APIException):
"""An exception to indicate a bad CSS name (such as invalid) was used."""
ERROR_TYPE = 'BAD_CSS_NAME'
class BadUsername(APIException):
"""An exception to indicate an invalid username was used."""
ERROR_TYPE = 'BAD_USERNAME'
class InvalidCaptcha(APIException):
"""An exception for when an incorrect captcha error is returned."""
ERROR_TYPE = 'BAD_CAPTCHA'
class InvalidEmails(APIException):
"""An exception for when invalid emails are provided."""
ERROR_TYPE = 'BAD_EMAILS'
class InvalidFlairTarget(APIException):
"""An exception raised when an invalid user is passed as a flair target."""
ERROR_TYPE = 'BAD_FLAIR_TARGET'
class InvalidInvite(APIException):
"""Raised when attempting to accept a nonexistent moderator invite."""
ERROR_TYPE = 'NO_INVITE_FOUND'
class InvalidUser(APIException):
"""An exception for when a user doesn't exist."""
ERROR_TYPE = 'USER_DOESNT_EXIST'
class InvalidUserPass(APIException):
"""An exception for failed logins."""
ERROR_TYPE = 'WRONG_PASSWORD'
class InsufficientCreddits(APIException):
"""Raised when there are not enough creddits to complete the action."""
ERROR_TYPE = 'INSUFFICIENT_CREDDITS'
class NotLoggedIn(APIException):
"""An exception for when a Reddit user isn't logged in."""
ERROR_TYPE = 'USER_REQUIRED'
class NotModified(APIException):
"""An exception raised when reddit returns {'error': 304}.
This error indicates that the requested content was not modified and is
being requested too frequently. Such an error usually occurs when multiple
instances of PRAW are running concurrently or in rapid succession.
"""
def __init__(self, response):
"""Construct an instance of the NotModified exception.
This error does not have an error_type, message, nor field.
"""
super(NotModified, self).__init__(None, None, response=response)
def __str__(self):
"""Return: That page has not been modified."""
return 'That page has not been modified.'
class RateLimitExceeded(APIException):
"""An exception for when something has happened too frequently.
Contains a `sleep_time` attribute for the number of seconds that must
transpire prior to the next request.
"""
ERROR_TYPE = 'RATELIMIT'
class SubredditExists(APIException):
"""An exception to indicate that a subreddit name is not available."""
ERROR_TYPE = 'SUBREDDIT_EXISTS'
class UsernameExists(APIException):
"""An exception to indicate that a username is not available."""
ERROR_TYPE = 'USERNAME_TAKEN'
def _build_error_mapping():
def predicate(obj):
return inspect.isclass(obj) and hasattr(obj, 'ERROR_TYPE')
tmp = {}
for _, obj in inspect.getmembers(sys.modules[__name__], predicate):
tmp[obj.ERROR_TYPE] = obj
return tmp
ERROR_MAPPING = _build_error_mapping()

View File

@@ -0,0 +1,243 @@
"""Provides classes that handle request dispatching."""
from __future__ import print_function, unicode_literals
import socket
import sys
import time
from functools import wraps
from .errors import ClientException
from .helpers import normalize_url
from requests import Session
from six import text_type
from six.moves import cPickle # pylint: disable=F0401
from threading import Lock
from timeit import default_timer as timer
class RateLimitHandler(object):
"""The base handler that provides thread-safe rate limiting enforcement.
While this handler is threadsafe, PRAW is not thread safe when the same
`Reddit` instance is being utilized from multiple threads.
"""
last_call = {} # Stores a two-item list: [lock, previous_call_time]
rl_lock = Lock() # lock used for adding items to last_call
@staticmethod
def rate_limit(function):
"""Return a decorator that enforces API request limit guidelines.
We are allowed to make a API request every api_request_delay seconds as
specified in praw.ini. This value may differ from reddit to reddit. For
reddit.com it is 2. Any function decorated with this will be forced to
delay _rate_delay seconds from the calling of the last function
decorated with this before executing.
This decorator must be applied to a RateLimitHandler class method or
instance method as it assumes `rl_lock` and `last_call` are available.
"""
@wraps(function)
def wrapped(cls, _rate_domain, _rate_delay, **kwargs):
cls.rl_lock.acquire()
lock_last = cls.last_call.setdefault(_rate_domain, [Lock(), 0])
with lock_last[0]: # Obtain the domain specific lock
cls.rl_lock.release()
# Sleep if necessary, then perform the request
now = timer()
delay = lock_last[1] + _rate_delay - now
if delay > 0:
now += delay
time.sleep(delay)
lock_last[1] = now
return function(cls, **kwargs)
return wrapped
@classmethod
def evict(cls, urls): # pylint: disable=W0613
"""Method utilized to evict entries for the given urls.
:param urls: An iterable containing normalized urls.
:returns: The number of items removed from the cache.
By default this method returns False as a cache need not be present.
"""
return 0
def __del__(self):
"""Cleanup the HTTP session."""
if self.http:
try:
self.http.close()
except: # Never fail pylint: disable=W0702
pass
def __init__(self):
"""Establish the HTTP session."""
self.http = Session() # Each instance should have its own session
def request(self, request, proxies, timeout, verify, **_):
"""Responsible for dispatching the request and returning the result.
Network level exceptions should be raised and only
``requests.Response`` should be returned.
:param request: A ``requests.PreparedRequest`` object containing all
the data necessary to perform the request.
:param proxies: A dictionary of proxy settings to be utilized for the
request.
:param timeout: Specifies the maximum time that the actual HTTP request
can take.
:param verify: Specifies if SSL certificates should be validated.
``**_`` should be added to the method call to ignore the extra
arguments intended for the cache handler.
"""
settings = self.http.merge_environment_settings(
request.url, proxies, False, verify, None
)
return self.http.send(request, timeout=timeout, allow_redirects=False,
**settings)
RateLimitHandler.request = RateLimitHandler.rate_limit(
RateLimitHandler.request)
class DefaultHandler(RateLimitHandler):
"""Extends the RateLimitHandler to add thread-safe caching support."""
ca_lock = Lock()
cache = {}
cache_hit_callback = None
timeouts = {}
@staticmethod
def with_cache(function):
"""Return a decorator that interacts with a handler's cache.
This decorator must be applied to a DefaultHandler class method or
instance method as it assumes `cache`, `ca_lock` and `timeouts` are
available.
"""
@wraps(function)
def wrapped(cls, _cache_key, _cache_ignore, _cache_timeout, **kwargs):
def clear_timeouts():
"""Clear the cache of timed out results."""
for key in list(cls.timeouts):
if timer() - cls.timeouts[key] > _cache_timeout:
del cls.timeouts[key]
del cls.cache[key]
if _cache_ignore:
return function(cls, **kwargs)
with cls.ca_lock:
clear_timeouts()
if _cache_key in cls.cache:
if cls.cache_hit_callback:
cls.cache_hit_callback(_cache_key)
return cls.cache[_cache_key]
# Releasing the lock before actually making the request allows for
# the possibility of more than one thread making the same request
# to get through. Without having domain-specific caching (under the
# assumption only one request to a domain can be made at a
# time), there isn't a better way to handle this.
result = function(cls, **kwargs)
# The handlers don't call `raise_for_status` so we need to ignore
# status codes that will result in an exception that should not be
# cached.
if result.status_code not in (200, 302):
return result
with cls.ca_lock:
cls.timeouts[_cache_key] = timer()
cls.cache[_cache_key] = result
return result
return wrapped
@classmethod
def clear_cache(cls):
"""Remove all items from the cache."""
with cls.ca_lock:
cls.cache = {}
cls.timeouts = {}
@classmethod
def evict(cls, urls):
"""Remove items from cache matching URLs.
Return the number of items removed.
"""
if isinstance(urls, text_type):
urls = [urls]
urls = set(normalize_url(url) for url in urls)
retval = 0
with cls.ca_lock:
for key in list(cls.cache):
if key[0] in urls:
retval += 1
del cls.cache[key]
del cls.timeouts[key]
return retval
DefaultHandler.request = DefaultHandler.with_cache(RateLimitHandler.request)
class MultiprocessHandler(object):
"""A PRAW handler to interact with the PRAW multi-process server."""
def __init__(self, host='localhost', port=10101):
"""Construct an instance of the MultiprocessHandler."""
self.host = host
self.port = port
def _relay(self, **kwargs):
"""Send the request through the server and return the HTTP response."""
retval = None
delay_time = 2 # For connection retries
read_attempts = 0 # For reading from socket
while retval is None: # Evict can return False
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock_fp = sock.makefile('rwb') # Used for pickle
try:
sock.connect((self.host, self.port))
cPickle.dump(kwargs, sock_fp, cPickle.HIGHEST_PROTOCOL)
sock_fp.flush()
retval = cPickle.load(sock_fp)
except: # pylint: disable=W0702
exc_type, exc, _ = sys.exc_info()
socket_error = exc_type is socket.error
if socket_error and exc.errno == 111: # Connection refused
sys.stderr.write('Cannot connect to multiprocess server. I'
's it running? Retrying in {0} seconds.\n'
.format(delay_time))
time.sleep(delay_time)
delay_time = min(64, delay_time * 2)
elif exc_type is EOFError or socket_error and exc.errno == 104:
# Failure during socket READ
if read_attempts >= 3:
raise ClientException('Successive failures reading '
'from the multiprocess server.')
sys.stderr.write('Lost connection with multiprocess server'
' during read. Trying again.\n')
read_attempts += 1
else:
raise
finally:
sock_fp.close()
sock.close()
if isinstance(retval, Exception):
raise retval # pylint: disable=E0702
return retval
def evict(self, urls):
"""Forward the eviction to the server and return its response."""
return self._relay(method='evict', urls=urls)
def request(self, **kwargs):
"""Forward the request to the server and return its HTTP response."""
return self._relay(method='request', **kwargs)

View File

@@ -0,0 +1,481 @@
# This file is part of PRAW.
#
# PRAW is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# PRAW is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# PRAW. If not, see <http://www.gnu.org/licenses/>.
"""
Helper functions.
The functions here provide functionality that is often needed by programs using
PRAW, but which isn't part of reddit's API.
"""
from __future__ import unicode_literals
import six
import sys
import time
from collections import deque
from functools import partial
from timeit import default_timer as timer
from .errors import HTTPException, PRAWException
from operator import attrgetter
BACKOFF_START = 4 # Minimum number of seconds to sleep during errors
KEEP_ITEMS = 128 # On each iteration only remember the first # items
# for conversion between broken reddit timestamps and unix timestamps
REDDIT_TIMESTAMP_OFFSET = 28800
def comment_stream(reddit_session, subreddit, limit=None, verbosity=1):
"""Indefinitely yield new comments from the provided subreddit.
Comments are yielded from oldest to newest.
:param reddit_session: The reddit_session to make requests from. In all the
examples this is assigned to the variable ``r``.
:param subreddit: Either a subreddit object, or the name of a
subreddit. Use `all` to get the comment stream for all comments made to
reddit.
:param limit: The maximum number of comments to fetch in a single
iteration. When None, fetch all available comments (reddit limits this
to 1000 (or multiple of 1000 for multi-subreddits). If this number is
too small, comments may be missed.
:param verbosity: A number that controls the amount of output produced to
stderr. <= 0: no output; >= 1: output the total number of comments
processed and provide the short-term number of comments processed per
second; >= 2: output when additional delays are added in order to avoid
subsequent unexpected http errors. >= 3: output debugging information
regarding the comment stream. (Default: 1)
"""
get_function = partial(reddit_session.get_comments,
six.text_type(subreddit))
return _stream_generator(get_function, limit, verbosity)
def submission_stream(reddit_session, subreddit, limit=None, verbosity=1):
"""Indefinitely yield new submissions from the provided subreddit.
Submissions are yielded from oldest to newest.
:param reddit_session: The reddit_session to make requests from. In all the
examples this is assigned to the variable ``r``.
:param subreddit: Either a subreddit object, or the name of a
subreddit. Use `all` to get the submissions stream for all submissions
made to reddit.
:param limit: The maximum number of submissions to fetch in a single
iteration. When None, fetch all available submissions (reddit limits
this to 1000 (or multiple of 1000 for multi-subreddits). If this number
is too small, submissions may be missed. Since there isn't a limit to
the number of submissions that can be retrieved from r/all, the limit
will be set to 1000 when limit is None.
:param verbosity: A number that controls the amount of output produced to
stderr. <= 0: no output; >= 1: output the total number of submissions
processed and provide the short-term number of submissions processed
per second; >= 2: output when additional delays are added in order to
avoid subsequent unexpected http errors. >= 3: output debugging
information regarding the submission stream. (Default: 1)
"""
if six.text_type(subreddit).lower() == "all":
if limit is None:
limit = 1000
if not hasattr(subreddit, 'reddit_session'):
subreddit = reddit_session.get_subreddit(subreddit)
return _stream_generator(subreddit.get_new, limit, verbosity)
def valid_redditors(redditors, sub):
"""Return a verified list of valid Redditor instances.
:param redditors: A list comprised of Redditor instances and/or strings
that are to be verified as actual redditor accounts.
:param sub: A Subreddit instance that the authenticated account has
flair changing permission on.
Note: Flair will be unset for all valid redditors in `redditors` on the
subreddit `sub`. A valid redditor is defined as a redditor that is
registered on reddit.
"""
simplified = list(set(six.text_type(x).lower() for x in redditors))
return [sub.reddit_session.get_redditor(simplified[i], fetch=False)
for (i, resp) in enumerate(sub.set_flair_csv(
({'user': x, 'flair_text': x} for x in simplified)))
if resp['ok']]
def submissions_between(reddit_session,
subreddit,
lowest_timestamp=None,
highest_timestamp=None,
newest_first=True,
extra_cloudsearch_fields=None,
verbosity=1):
"""Yield submissions between two timestamps.
If both ``highest_timestamp`` and ``lowest_timestamp`` are unspecified,
yields all submissions in the ``subreddit``.
Submissions are yielded from newest to oldest(like in the "new" queue).
:param reddit_session: The reddit_session to make requests from. In all the
examples this is assigned to the variable ``r``.
:param subreddit: Either a subreddit object, or the name of a
subreddit. Use `all` to get the submissions stream for all submissions
made to reddit.
:param lowest_timestamp: The lower bound for ``created_utc`` atributed of
submissions.
(Default: subreddit's created_utc or 0 when subreddit == "all").
:param highest_timestamp: The upper bound for ``created_utc`` attribute
of submissions. (Default: current unix time)
NOTE: both highest_timestamp and lowest_timestamp are proper
unix timestamps(just like ``created_utc`` attributes)
:param newest_first: If set to true, yields submissions
from newest to oldest. Otherwise yields submissions
from oldest to newest
:param extra_cloudsearch_fields: Allows extra filtering of results by
parameters like author, self. Full list is available here:
https://www.reddit.com/wiki/search
:param verbosity: A number that controls the amount of output produced to
stderr. <= 0: no output; >= 1: output the total number of submissions
processed; >= 2: output debugging information regarding
the search queries. (Default: 1)
"""
def debug(msg, level):
if verbosity >= level:
sys.stderr.write(msg + '\n')
def format_query_field(k, v):
if k in ["nsfw", "self"]:
# even though documentation lists "no" and "yes"
# as possible values, in reality they don't work
if v not in [0, 1, "0", "1"]:
raise PRAWException("Invalid value for the extra"
"field {}. Only '0' and '1' are"
"valid values.".format(k))
return "{}:{}".format(k, v)
return "{}:'{}'".format(k, v)
if extra_cloudsearch_fields is None:
extra_cloudsearch_fields = {}
extra_query_part = " ".join(
[format_query_field(k, v) for (k, v)
in sorted(extra_cloudsearch_fields.items())]
)
if highest_timestamp is None:
highest_timestamp = int(time.time()) + REDDIT_TIMESTAMP_OFFSET
else:
highest_timestamp = int(highest_timestamp) + REDDIT_TIMESTAMP_OFFSET
if lowest_timestamp is not None:
lowest_timestamp = int(lowest_timestamp) + REDDIT_TIMESTAMP_OFFSET
elif not isinstance(subreddit, six.string_types):
lowest_timestamp = int(subreddit.created)
elif subreddit not in ("all", "contrib", "mod", "friend"):
lowest_timestamp = int(reddit_session.get_subreddit(subreddit).created)
else:
lowest_timestamp = 0
original_highest_timestamp = highest_timestamp
original_lowest_timestamp = lowest_timestamp
# When making timestamp:X..Y queries, reddit misses submissions
# inside X..Y range, but they can be found inside Y..Z range
# It is not clear what is the value of Z should be, but it seems
# like the difference is usually about ~1 hour or less
# To be sure, let's set the workaround offset to 2 hours
out_of_order_submissions_workaround_offset = 7200
highest_timestamp += out_of_order_submissions_workaround_offset
lowest_timestamp -= out_of_order_submissions_workaround_offset
# Those parameters work ok, but there may be a better set of parameters
window_size = 60 * 60
search_limit = 100
min_search_results_in_window = 50
window_adjustment_ratio = 1.25
backoff = BACKOFF_START
processed_submissions = 0
prev_win_increased = False
prev_win_decreased = False
while highest_timestamp >= lowest_timestamp:
try:
if newest_first:
t1 = max(highest_timestamp - window_size, lowest_timestamp)
t2 = highest_timestamp
else:
t1 = lowest_timestamp
t2 = min(lowest_timestamp + window_size, highest_timestamp)
search_query = 'timestamp:{}..{}'.format(t1, t2)
if extra_query_part:
search_query = "(and {} {})".format(search_query,
extra_query_part)
debug(search_query, 3)
search_results = list(reddit_session.search(search_query,
subreddit=subreddit,
limit=search_limit,
syntax='cloudsearch',
sort='new'))
debug("Received {0} search results for query {1}"
.format(len(search_results), search_query),
2)
backoff = BACKOFF_START
except HTTPException as exc:
debug("{0}. Sleeping for {1} seconds".format(exc, backoff), 2)
time.sleep(backoff)
backoff *= 2
continue
if len(search_results) >= search_limit:
power = 2 if prev_win_decreased else 1
window_size = int(window_size / window_adjustment_ratio**power)
prev_win_decreased = True
debug("Decreasing window size to {0} seconds".format(window_size),
2)
# Since it is possible that there are more submissions
# in the current window, we have to re-do the request
# with reduced window
continue
else:
prev_win_decreased = False
search_results = [s for s in search_results
if original_lowest_timestamp <= s.created and
s.created <= original_highest_timestamp]
for submission in sorted(search_results,
key=attrgetter('created_utc', 'id'),
reverse=newest_first):
yield submission
processed_submissions += len(search_results)
debug('Total processed submissions: {}'
.format(processed_submissions), 1)
if newest_first:
highest_timestamp -= (window_size + 1)
else:
lowest_timestamp += (window_size + 1)
if len(search_results) < min_search_results_in_window:
power = 2 if prev_win_increased else 1
window_size = int(window_size * window_adjustment_ratio**power)
prev_win_increased = True
debug("Increasing window size to {0} seconds"
.format(window_size), 2)
else:
prev_win_increased = False
def _stream_generator(get_function, limit=None, verbosity=1):
def debug(msg, level):
if verbosity >= level:
sys.stderr.write(msg + '\n')
def b36_id(item):
return int(item.id, 36)
seen = BoundedSet(KEEP_ITEMS * 16)
before = None
count = 0 # Count is incremented to bypass the cache
processed = 0
backoff = BACKOFF_START
while True:
items = []
sleep = None
start = timer()
try:
i = None
params = {'uniq': count}
count = (count + 1) % 100
if before:
params['before'] = before
gen = enumerate(get_function(limit=limit, params=params))
for i, item in gen:
if b36_id(item) in seen:
if i == 0:
if before is not None:
# reddit sent us out of order data -- log it
debug('(INFO) {0} already seen with before of {1}'
.format(item.fullname, before), 3)
before = None
break
if i == 0: # Always the first item in the generator
before = item.fullname
if b36_id(item) not in seen:
items.append(item)
processed += 1
if verbosity >= 1 and processed % 100 == 0:
sys.stderr.write(' Items: {0} \r'
.format(processed))
sys.stderr.flush()
if i < KEEP_ITEMS:
seen.add(b36_id(item))
else: # Generator exhausted
if i is None: # Generator yielded no items
assert before is not None
# Try again without before as the before item may be too
# old or no longer exist.
before = None
backoff = BACKOFF_START
except HTTPException as exc:
sleep = (backoff, '{0}. Sleeping for {{0}} seconds.'.format(exc),
2)
backoff *= 2
# Provide rate limit
if verbosity >= 1:
rate = len(items) / (timer() - start)
sys.stderr.write(' Items: {0} ({1:.2f} ips) \r'
.format(processed, rate))
sys.stderr.flush()
# Yield items from oldest to newest
for item in items[::-1]:
yield item
# Sleep if necessary
if sleep:
sleep_time, msg, msg_level = sleep # pylint: disable=W0633
debug(msg.format(sleep_time), msg_level)
time.sleep(sleep_time)
def chunk_sequence(sequence, chunk_length, allow_incomplete=True):
"""Given a sequence, divide it into sequences of length `chunk_length`.
:param allow_incomplete: If True, allow final chunk to be shorter if the
given sequence is not an exact multiple of `chunk_length`.
If False, the incomplete chunk will be discarded.
"""
(complete, leftover) = divmod(len(sequence), chunk_length)
if not allow_incomplete:
leftover = 0
chunk_count = complete + min(leftover, 1)
chunks = []
for x in range(chunk_count):
left = chunk_length * x
right = left + chunk_length
chunks.append(sequence[left:right])
return chunks
def convert_id36_to_numeric_id(id36):
"""Convert strings representing base36 numbers into an integer."""
if not isinstance(id36, six.string_types) or id36.count("_") > 0:
raise ValueError("must supply base36 string, not fullname (e.g. use "
"xxxxx, not t3_xxxxx)")
return int(id36, 36)
def convert_numeric_id_to_id36(numeric_id):
"""Convert an integer into its base36 string representation.
This method has been cleaned up slightly to improve readability. For more
info see:
https://github.com/reddit/reddit/blob/master/r2/r2/lib/utils/_utils.pyx
https://www.reddit.com/r/redditdev/comments/n624n/submission_ids_question/
https://en.wikipedia.org/wiki/Base36
"""
# base36 allows negative numbers, but reddit does not
if not isinstance(numeric_id, six.integer_types) or numeric_id < 0:
raise ValueError("must supply a positive int/long")
# Alphabet used for base 36 conversion
alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'
alphabet_len = len(alphabet)
# Temp assign
current_number = numeric_id
base36 = []
# Current_number must be greater than alphabet length to while/divmod
if 0 <= current_number < alphabet_len:
return alphabet[current_number]
# Break up into chunks
while current_number != 0:
current_number, rem = divmod(current_number, alphabet_len)
base36.append(alphabet[rem])
# String is built in reverse order
return ''.join(reversed(base36))
def flatten_tree(tree, nested_attr='replies', depth_first=False):
"""Return a flattened version of the passed in tree.
:param nested_attr: The attribute name that contains the nested items.
Defaults to ``replies`` which is suitable for comments.
:param depth_first: When true, add to the list in a depth-first manner
rather than the default breadth-first manner.
"""
stack = deque(tree)
extend = stack.extend if depth_first else stack.extendleft
retval = []
while stack:
item = stack.popleft()
nested = getattr(item, nested_attr, None)
if nested:
extend(nested)
retval.append(item)
return retval
def normalize_url(url):
"""Return url after stripping trailing .json and trailing slashes."""
if url.endswith('.json'):
url = url[:-5]
if url.endswith('/'):
url = url[:-1]
return url
class BoundedSet(object):
"""A set with a maximum size that evicts the oldest items when necessary.
This class does not implement the complete set interface.
"""
def __init__(self, max_items):
"""Construct an instance of the BoundedSet."""
self.max_items = max_items
self._fifo = []
self._set = set()
def __contains__(self, item):
"""Test if the BoundedSet contains item."""
return item in self._set
def add(self, item):
"""Add an item to the set discarding the oldest item if necessary."""
if item in self._set:
self._fifo.remove(item)
elif len(self._set) == self.max_items:
self._set.remove(self._fifo.pop(0))
self._fifo.append(item)
self._set.add(item)

View File

@@ -0,0 +1,271 @@
# This file is part of PRAW.
#
# PRAW is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# PRAW is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# PRAW. If not, see <http://www.gnu.org/licenses/>.
"""Internal helper functions.
The functions in this module are not to be relied upon by third-parties.
"""
from __future__ import print_function, unicode_literals
import os
import re
import six
import sys
from requests import Request, codes, exceptions
from requests.compat import urljoin
from .decorators import restrict_access
from .errors import (ClientException, HTTPException, Forbidden, NotFound,
InvalidSubreddit, OAuthException,
OAuthInsufficientScope, OAuthInvalidToken,
RedirectException)
from warnings import warn
try:
from OpenSSL import __version__ as _opensslversion
_opensslversionlist = [int(minor) if minor.isdigit() else minor
for minor in _opensslversion.split('.')]
except ImportError:
_opensslversionlist = [0, 15]
MIN_PNG_SIZE = 67
MIN_JPEG_SIZE = 128
MAX_IMAGE_SIZE = 512000
JPEG_HEADER = b'\xff\xd8\xff'
PNG_HEADER = b'\x89\x50\x4e\x47\x0d\x0a\x1a\x0a'
RE_REDIRECT = re.compile('(rand(om|nsfw))|about/sticky')
def _get_redditor_listing(subpath=''):
"""Return function to generate Redditor listings."""
def _listing(self, sort='new', time='all', *args, **kwargs):
"""Return a get_content generator for some RedditContentObject type.
:param sort: Specify the sort order of the results if applicable
(one of ``'hot'``, ``'new'``, ``'top'``, ``'controversial'``).
:param time: Specify the time-period to return submissions if
applicable (one of ``'hour'``, ``'day'``, ``'week'``,
``'month'``, ``'year'``, ``'all'``).
The additional parameters are passed directly into
:meth:`.get_content`. Note: the `url` parameter cannot be altered.
"""
kwargs.setdefault('params', {})
kwargs['params'].setdefault('sort', sort)
kwargs['params'].setdefault('t', time)
url = urljoin(self._url, subpath) # pylint: disable=W0212
return self.reddit_session.get_content(url, *args, **kwargs)
return _listing
def _get_sorter(subpath='', **defaults):
"""Return function to generate specific subreddit Submission listings."""
@restrict_access(scope='read')
def _sorted(self, *args, **kwargs):
"""Return a get_content generator for some RedditContentObject type.
The additional parameters are passed directly into
:meth:`.get_content`. Note: the `url` parameter cannot be altered.
"""
if not kwargs.get('params'):
kwargs['params'] = {}
for key, value in six.iteritems(defaults):
kwargs['params'].setdefault(key, value)
url = urljoin(self._url, subpath) # pylint: disable=W0212
return self.reddit_session.get_content(url, *args, **kwargs)
return _sorted
def _image_type(image):
size = os.path.getsize(image.name)
if size < MIN_PNG_SIZE:
raise ClientException('png image is too small.')
if size > MAX_IMAGE_SIZE:
raise ClientException('`image` is too big. Max: {0} bytes'
.format(MAX_IMAGE_SIZE))
first_bytes = image.read(MIN_PNG_SIZE)
image.seek(0)
if first_bytes.startswith(PNG_HEADER):
return 'png'
elif first_bytes.startswith(JPEG_HEADER):
if size < MIN_JPEG_SIZE:
raise ClientException('jpeg image is too small.')
return 'jpg'
raise ClientException('`image` must be either jpg or png.')
def _modify_relationship(relationship, unlink=False, is_sub=False):
"""Return a function for relationship modification.
Used to support friending (user-to-user), as well as moderating,
contributor creating, and banning (user-to-subreddit).
"""
# The API uses friend and unfriend to manage all of these relationships.
url_key = 'unfriend' if unlink else 'friend'
if relationship == 'friend':
access = {'scope': None, 'login': True}
elif relationship == 'moderator':
access = {'scope': 'modothers'}
elif relationship in ['banned', 'contributor', 'muted']:
access = {'scope': 'modcontributors'}
elif relationship in ['wikibanned', 'wikicontributor']:
access = {'scope': ['modcontributors', 'modwiki']}
else:
access = {'scope': None, 'mod': True}
@restrict_access(**access)
def do_relationship(thing, user, **kwargs):
data = {'name': six.text_type(user),
'type': relationship}
data.update(kwargs)
if is_sub:
data['r'] = six.text_type(thing)
else:
data['container'] = thing.fullname
session = thing.reddit_session
if relationship == 'moderator':
session.evict(session.config['moderators'].format(
subreddit=six.text_type(thing)))
url = session.config[url_key]
return session.request_json(url, data=data)
return do_relationship
def _prepare_request(reddit_session, url, params, data, auth, files,
method=None):
"""Return a requests Request object that can be "prepared"."""
# Requests using OAuth for authorization must switch to using the oauth
# domain.
if getattr(reddit_session, '_use_oauth', False):
bearer = 'bearer {0}'.format(reddit_session.access_token)
headers = {'Authorization': bearer}
config = reddit_session.config
for prefix in (config.api_url, config.permalink_url):
if url.startswith(prefix):
if config.log_requests >= 1:
msg = 'substituting {0} for {1} in url\n'.format(
config.oauth_url, prefix)
sys.stderr.write(msg)
url = config.oauth_url + url[len(prefix):]
break
else:
headers = {}
headers.update(reddit_session.http.headers)
if method:
pass
elif data or files:
method = 'POST'
else:
method = 'GET'
# Log the request if logging is enabled
if reddit_session.config.log_requests >= 1:
sys.stderr.write('{0}: {1}\n'.format(method, url))
if reddit_session.config.log_requests >= 2:
if params:
sys.stderr.write('params: {0}\n'.format(params))
if data:
sys.stderr.write('data: {0}\n'.format(data))
if auth:
sys.stderr.write('auth: {0}\n'.format(auth))
# Prepare request
request = Request(method=method, url=url, headers=headers, params=params,
auth=auth, cookies=reddit_session.http.cookies)
if method == 'GET':
return request
# Most POST requests require adding `api_type` and `uh` to the data.
if data is True:
data = {}
if isinstance(data, dict):
if not auth:
data.setdefault('api_type', 'json')
if reddit_session.modhash:
data.setdefault('uh', reddit_session.modhash)
else:
request.headers.setdefault('Content-Type', 'application/json')
request.data = data
request.files = files
return request
def _raise_redirect_exceptions(response):
"""Return the new url or None if there are no redirects.
Raise exceptions if appropriate.
"""
if response.status_code not in [301, 302, 307]:
return None
new_url = urljoin(response.url, response.headers['location'])
if 'reddits/search' in new_url: # Handle non-existent subreddit
subreddit = new_url.rsplit('=', 1)[1]
raise InvalidSubreddit('`{0}` is not a valid subreddit'
.format(subreddit))
elif not RE_REDIRECT.search(response.url):
raise RedirectException(response.url, new_url)
return new_url
def _raise_response_exceptions(response):
"""Raise specific errors on some status codes."""
if not response.ok and 'www-authenticate' in response.headers:
msg = response.headers['www-authenticate']
if 'insufficient_scope' in msg:
raise OAuthInsufficientScope('insufficient_scope', response.url)
elif 'invalid_token' in msg:
raise OAuthInvalidToken('invalid_token', response.url)
else:
raise OAuthException(msg, response.url)
if response.status_code == codes.forbidden: # pylint: disable=E1101
raise Forbidden(_raw=response)
elif response.status_code == codes.not_found: # pylint: disable=E1101
raise NotFound(_raw=response)
else:
try:
response.raise_for_status() # These should all be directly mapped
except exceptions.HTTPError as exc:
raise HTTPException(_raw=exc.response)
def _to_reddit_list(arg):
"""Return an argument converted to a reddit-formatted list.
The returned format is a comma deliminated list. Each element is a string
representation of an object. Either given as a string or as an object that
is then converted to its string representation.
"""
if (isinstance(arg, six.string_types) or not (
hasattr(arg, "__getitem__") or hasattr(arg, "__iter__"))):
return six.text_type(arg)
else:
return ','.join(six.text_type(a) for a in arg)
def _warn_pyopenssl():
"""Warn the user against faulty versions of pyOpenSSL."""
if _opensslversionlist < [0, 15]: # versions >= 0.15 are fine
warn(RuntimeWarning(
"pyOpenSSL {0} may be incompatible with praw if validating"
"ssl certificates, which is on by default.\nSee https://"
"github.com/praw/pull/625 for more information".format(
_opensslversion)
))

View File

@@ -0,0 +1,102 @@
"""Provides a request server to be used with the multiprocess handler."""
from __future__ import print_function, unicode_literals
import socket
import sys
from optparse import OptionParser
from . import __version__
from .handlers import DefaultHandler
from requests import Session
from six.moves import cPickle, socketserver # pylint: disable=F0401
from threading import Lock
class ThreadingTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
# pylint: disable=R0903,W0232
"""A TCP server that creates new threads per connection."""
allow_reuse_address = True
@staticmethod
def handle_error(_, client_addr):
"""Mute tracebacks of common errors."""
exc_type, exc_value, _ = sys.exc_info()
if exc_type is socket.error and exc_value[0] == 32:
pass
elif exc_type is cPickle.UnpicklingError:
sys.stderr.write('Invalid connection from {0}\n'
.format(client_addr[0]))
else:
raise
class RequestHandler(socketserver.StreamRequestHandler):
# pylint: disable=W0232
"""A class that handles incoming requests.
Requests to the same domain are cached and rate-limited.
"""
ca_lock = Lock() # lock around cache and timeouts
cache = {} # caches requests
http = Session() # used to make requests
last_call = {} # Stores a two-item list: [lock, previous_call_time]
rl_lock = Lock() # lock used for adding items to last_call
timeouts = {} # store the time items in cache were entered
do_evict = DefaultHandler.evict # Add in the evict method
@staticmethod
def cache_hit_callback(key):
"""Output when a cache hit occurs."""
print('HIT {0} {1}'.format('POST' if key[1][1] else 'GET', key[0]))
@DefaultHandler.with_cache
@DefaultHandler.rate_limit
def do_request(self, request, proxies, timeout, **_):
"""Dispatch the actual request and return the result."""
print('{0} {1}'.format(request.method, request.url))
response = self.http.send(request, proxies=proxies, timeout=timeout,
allow_redirects=False)
response.raw = None # Make pickleable
return response
def handle(self):
"""Parse the RPC, make the call, and pickle up the return value."""
data = cPickle.load(self.rfile) # pylint: disable=E1101
method = data.pop('method')
try:
retval = getattr(self, 'do_{0}'.format(method))(**data)
except Exception as e:
# All exceptions should be passed to the client
retval = e
cPickle.dump(retval, self.wfile, # pylint: disable=E1101
cPickle.HIGHEST_PROTOCOL)
def run():
"""The entry point from the praw-multiprocess utility."""
parser = OptionParser(version='%prog {0}'.format(__version__))
parser.add_option('-a', '--addr', default='localhost',
help=('The address or host to listen on. Specify -a '
'0.0.0.0 to listen on all addresses. '
'Default: localhost'))
parser.add_option('-p', '--port', type='int', default='10101',
help=('The port to listen for requests on. '
'Default: 10101'))
options, _ = parser.parse_args()
try:
server = ThreadingTCPServer((options.addr, options.port),
RequestHandler)
except (socket.error, socket.gaierror) as exc: # Handle bind errors
print(exc)
sys.exit(1)
print('Listening on {0} port {1}'.format(options.addr, options.port))
try:
server.serve_forever() # pylint: disable=E1101
except KeyboardInterrupt:
server.socket.close() # pylint: disable=E1101
RequestHandler.http.close()
print('Goodbye!')

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,79 @@
[DEFAULT]
# The domain name PRAW will use to interact with the reddit site via its API.
api_domain: api.reddit.com
# Time, a float, in seconds, required between calls. See:
# http://code.reddit.com/wiki/API
api_request_delay: 2.0
# A boolean to indicate whether or not to check for package updates.
check_for_updates: True
# Time, a float, in seconds, to save the results of a get/post request.
cache_timeout: 30
# Log the API calls
# 0: no logging
# 1: log only the request URIs
# 2: log the request URIs as well as any POST data
log_requests: 0
# The domain name PRAW will use for oauth-related requests.
oauth_domain: oauth.reddit.com
# Whether or not to use HTTPS for oauth connections. This should only be
# changed for development environments.
oauth_https: True
# OAuth grant type: either `authorization_code` or `password`
oauth_grant_type: authorization_code
# The maximum length of unicode representations of Comment, Message and
# Submission objects. This is mainly used to fit them within a terminal window
# line. A negative value means no limit.
output_chars_limit: 80
# The domain name PRAW will use when permalinks are requested.
permalink_domain: www.reddit.com
# The domain name to use for short urls.
short_domain: redd.it
# A boolean to indicate if json_dict, which contains the original API response,
# should be stored on every object in the json_dict attribute. Default is
# False as memory usage will double if enabled.
store_json_result: False
# Maximum time, a float, in seconds, before a single HTTP request times
# out. urllib2.URLError is raised upon timeout.
timeout: 45
# A boolean to indicate if SSL certificats should be validated. The
# default is True.
validate_certs: True
# Object to kind mappings
comment_kind: t1
message_kind: t4
redditor_kind: t2
submission_kind: t3
subreddit_kind: t5
[reddit]
# Uses the default settings
[reddit_oauth_test]
oauth_client_id: stJlUSUbPQe5lQ
oauth_client_secret: iU-LsOzyJH7BDVoq-qOWNEq2zuI
oauth_redirect_uri: https://127.0.0.1:65010/authorize_callback
[local_example]
api_domain: reddit.local
api_request_delay: 0
log_requests: 0
message_kind: t7
permalink_domain: reddit.local
short_domain:
submission_kind: t6
subreddit_kind: t5

View File

@@ -0,0 +1,45 @@
# This file is part of PRAW.
#
# PRAW is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# PRAW is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# PRAW. If not, see <http://www.gnu.org/licenses/>.
"""Provides the code to load PRAW's configuration file `praw.ini`."""
from __future__ import print_function, unicode_literals
import os
import sys
from six.moves import configparser
def _load_configuration():
"""Attempt to load settings from various praw.ini files."""
config = configparser.RawConfigParser()
module_dir = os.path.dirname(sys.modules[__name__].__file__)
if 'APPDATA' in os.environ: # Windows
os_config_path = os.environ['APPDATA']
elif 'XDG_CONFIG_HOME' in os.environ: # Modern Linux
os_config_path = os.environ['XDG_CONFIG_HOME']
elif 'HOME' in os.environ: # Legacy Linux
os_config_path = os.path.join(os.environ['HOME'], '.config')
else:
os_config_path = None
locations = [os.path.join(module_dir, 'praw.ini'), 'praw.ini']
if os_config_path is not None:
locations.insert(1, os.path.join(os_config_path, 'praw.ini'))
if not config.read(locations):
raise Exception('Could not find config file in any of: {0}'
.format(locations))
return config
CONFIG = _load_configuration()
del _load_configuration

892
tuir/page.py Normal file
View File

@@ -0,0 +1,892 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import re
import os
import sys
import time
import logging
from functools import wraps
import six
from kitchen.text.display import textual_width
from . import docs
from .clipboard import copy as clipboard_copy
from .objects import Controller, Command
from .exceptions import TemporaryFileError, ProgramError
from .__version__ import __version__
_logger = logging.getLogger(__name__)
def logged_in(f):
"""
Decorator for Page methods that require the user to be authenticated.
"""
@wraps(f)
def wrapped_method(self, *args, **kwargs):
if not self.reddit.is_oauth_session():
self.term.show_notification('Not logged in')
return None
return f(self, *args, **kwargs)
return wrapped_method
class PageController(Controller):
character_map = {}
class Page(object):
BANNER = None
FOOTER = None
def __init__(self, reddit, term, config, oauth):
self.reddit = reddit
self.term = term
self.config = config
self.oauth = oauth
self.content = None
self.nav = None
self.controller = None
self.active = True
self.selected_page = None
self._row = 0
self._subwindows = None
def refresh_content(self, order=None, name=None):
raise NotImplementedError
def _draw_item(self, win, data, inverted):
raise NotImplementedError
def get_selected_item(self):
"""
Return the content dictionary that is currently selected by the cursor.
"""
return self.content.get(self.nav.absolute_index)
def loop(self):
"""
Main control loop runs the following steps:
1. Re-draw the screen
2. Wait for user to press a key (includes terminal resizing)
3. Trigger the method registered to the input key
4. Check if there are any nested pages that need to be looped over
The loop will run until self.active is set to False from within one of
the methods.
"""
self.active = True
# This needs to be called once before the main loop, in case a subpage
# was pre-selected before the loop started. This happens in __main__.py
# with ``page.open_submission(url=url)``
while self.selected_page and self.active:
self.handle_selected_page()
while self.active:
self.draw()
ch = self.term.stdscr.getch()
self.controller.trigger(ch)
while self.selected_page and self.active:
self.handle_selected_page()
return self.selected_page
def handle_selected_page(self):
"""
Some commands will result in an action that causes a new page to open.
Examples include selecting a submission, viewing subscribed subreddits,
or opening the user's inbox. With these commands, the newly selected
page will be pre-loaded and stored in ``self.selected_page`` variable.
It's up to each page type to determine what to do when another page is
selected.
- It can start a nested page.loop(). This would allow the user to
return to their previous screen after exiting the sub-page. For
example, this is what happens when opening an individual submission
from within a subreddit page. When the submission is closed, the
user resumes the subreddit that they were previously viewing.
- It can close the current self.loop() and bubble the selected page up
one level in the loop stack. For example, this is what happens when
the user opens their subscriptions and selects a subreddit. The
subscription page loop is closed and the selected subreddit is
bubbled up to the root level loop.
Care should be taken to ensure the user can never enter an infinite
nested loop, as this could lead to memory leaks and recursion errors.
# Example of an unsafe nested loop
subreddit_page.loop()
-> submission_page.loop()
-> subreddit_page.loop()
-> submission_page.loop()
...
"""
raise NotImplementedError
@PageController.register(Command('REFRESH'))
def reload_page(self):
"""
Clear the PRAW cache to force the page the re-fetch content from reddit.
"""
self.reddit.handler.clear_cache()
self.refresh_content()
@PageController.register(Command('EXIT'))
def exit(self):
"""
Prompt and exit the application.
"""
if self.term.prompt_y_or_n('Do you really want to quit? (y/n): '):
sys.exit()
@PageController.register(Command('FORCE_EXIT'))
def force_exit(self):
"""
Immediately exit the application.
"""
sys.exit()
@PageController.register(Command('PREVIOUS_THEME'))
def previous_theme(self):
"""
Cycle to preview the previous theme from the internal list of themes.
"""
theme = self.term.theme_list.previous(self.term.theme)
while not self.term.check_theme(theme):
theme = self.term.theme_list.previous(theme)
self.term.set_theme(theme)
self.draw()
message = self.term.theme.display_string
self.term.show_notification(message, timeout=1)
@PageController.register(Command('NEXT_THEME'))
def next_theme(self):
"""
Cycle to preview the next theme from the internal list of themes.
"""
theme = self.term.theme_list.next(self.term.theme)
while not self.term.check_theme(theme):
theme = self.term.theme_list.next(theme)
self.term.set_theme(theme)
self.draw()
message = self.term.theme.display_string
self.term.show_notification(message, timeout=1)
@PageController.register(Command('HELP'))
def show_help(self):
"""
Open the help documentation in the system pager.
"""
self.term.open_pager(docs.HELP.strip())
@PageController.register(Command('MOVE_UP'))
def move_cursor_up(self):
"""
Move the cursor up one selection.
"""
self._move_cursor(-1)
self.clear_input_queue()
@PageController.register(Command('MOVE_DOWN'))
def move_cursor_down(self):
"""
Move the cursor down one selection.
"""
self._move_cursor(1)
self.clear_input_queue()
@PageController.register(Command('PAGE_UP'))
def move_page_up(self):
"""
Move the cursor up approximately the number of entries on the page.
"""
self._move_page(-1)
self.clear_input_queue()
@PageController.register(Command('PAGE_DOWN'))
def move_page_down(self):
"""
Move the cursor down approximately the number of entries on the page.
"""
self._move_page(1)
self.clear_input_queue()
@PageController.register(Command('PAGE_TOP'))
def move_page_top(self):
"""
Move the cursor to the first item on the page.
"""
self.nav.page_index = self.content.range[0]
self.nav.cursor_index = 0
self.nav.inverted = False
@PageController.register(Command('PAGE_BOTTOM'))
def move_page_bottom(self):
"""
Move the cursor to the last item on the page.
"""
# If the page is empty, don't try to go to the bottom, tuir will
# crash when rendering
if self.content.range[1] < 0:
return
self.nav.page_index = self.content.range[1]
self.nav.cursor_index = 0
self.nav.inverted = True
@PageController.register(Command('UPVOTE'))
@logged_in
def upvote(self):
"""
Upvote the currently selected item.
"""
data = self.get_selected_item()
if 'likes' not in data:
self.term.flash()
elif getattr(data['object'], 'archived'):
self.term.show_notification("Voting disabled for archived post", style='Error')
elif data['likes']:
with self.term.loader('Clearing vote'):
data['object'].clear_vote()
if not self.term.loader.exception:
data['likes'] = None
else:
with self.term.loader('Voting'):
data['object'].upvote()
if not self.term.loader.exception:
data['likes'] = True
@PageController.register(Command('DOWNVOTE'))
@logged_in
def downvote(self):
"""
Downvote the currently selected item.
"""
data = self.get_selected_item()
if 'likes' not in data:
self.term.flash()
elif getattr(data['object'], 'archived'):
self.term.show_notification("Voting disabled for archived post", style='Error')
elif data['likes'] or data['likes'] is None:
with self.term.loader('Voting'):
data['object'].downvote()
if not self.term.loader.exception:
data['likes'] = False
else:
with self.term.loader('Clearing vote'):
data['object'].clear_vote()
if not self.term.loader.exception:
data['likes'] = None
@PageController.register(Command('SAVE'))
@logged_in
def save(self):
"""
Mark the currently selected item as saved through the reddit API.
"""
data = self.get_selected_item()
if 'saved' not in data:
self.term.flash()
elif not data['saved']:
with self.term.loader('Saving'):
data['object'].save()
if not self.term.loader.exception:
data['saved'] = True
else:
with self.term.loader('Unsaving'):
data['object'].unsave()
if not self.term.loader.exception:
data['saved'] = False
@PageController.register(Command('LOGIN'))
def login(self):
"""
Prompt to log into the user's account, or log out of the current
account.
"""
if self.reddit.is_oauth_session():
ch = self.term.show_notification('Log out? (y/n)')
if ch in (ord('y'), ord('Y')):
self.oauth.clear_oauth_data()
self.term.show_notification('Logged out')
else:
self.oauth.authorize()
def reply(self):
"""
Reply to the selected item. This is a utility method and should not
be bound to a key directly.
Item type:
Submission - add a top level comment
Comment - add a comment reply
Message - reply to a private message
"""
data = self.get_selected_item()
if data['type'] == 'Submission':
body = data['text']
description = 'submission'
reply = data['object'].add_comment
elif data['type'] in ('Comment', 'InboxComment'):
body = data['body']
description = 'comment'
reply = data['object'].reply
elif data['type'] == 'Message':
body = data['body']
description = 'private message'
reply = data['object'].reply
else:
self.term.flash()
return
# Construct the text that will be displayed in the editor file.
# The post body will be commented out and added for reference
lines = [' |' + line for line in body.split('\n')]
content = '\n'.join(lines)
comment_info = docs.REPLY_FILE.format(
author=data['author'],
type=description,
content=content)
with self.term.open_editor(comment_info) as comment:
if not comment:
self.term.show_notification('Canceled')
return
with self.term.loader('Posting {}'.format(description), delay=0):
reply(comment)
# Give reddit time to process the submission
time.sleep(2.0)
if self.term.loader.exception is None:
self.reload_page()
else:
raise TemporaryFileError()
@PageController.register(Command('DELETE'))
@logged_in
def delete_item(self):
"""
Delete a submission or comment.
"""
data = self.get_selected_item()
if data.get('author') != self.reddit.user.name:
self.term.flash()
return
prompt = 'Are you sure you want to delete this? (y/n): '
if not self.term.prompt_y_or_n(prompt):
self.term.show_notification('Canceled')
return
with self.term.loader('Deleting', delay=0):
data['object'].delete()
# Give reddit time to process the request
time.sleep(2.0)
if self.term.loader.exception is None:
self.reload_page()
@PageController.register(Command('EDIT'))
@logged_in
def edit(self):
"""
Edit a submission or comment.
"""
data = self.get_selected_item()
if data.get('author') != self.reddit.user.name:
self.term.flash()
return
if data['type'] == 'Submission':
content = data['text']
info = docs.SUBMISSION_EDIT_FILE.format(
content=content, id=data['object'].id)
elif data['type'] == 'Comment':
content = data['body']
info = docs.COMMENT_EDIT_FILE.format(
content=content, id=data['object'].id)
else:
self.term.flash()
return
with self.term.open_editor(info) as text:
if not text or text == content:
self.term.show_notification('Canceled')
return
with self.term.loader('Editing', delay=0):
data['object'].edit(text)
time.sleep(2.0)
if self.term.loader.exception is None:
self.reload_page()
else:
raise TemporaryFileError()
@PageController.register(Command('PRIVATE_MESSAGE'))
@logged_in
def send_private_message(self):
"""
Send a new private message to another user.
"""
message_info = docs.MESSAGE_FILE
with self.term.open_editor(message_info) as text:
if not text:
self.term.show_notification('Canceled')
return
parts = text.split('\n', 2)
if len(parts) == 1:
self.term.show_notification('Missing message subject')
return
elif len(parts) == 2:
self.term.show_notification('Missing message body')
return
recipient, subject, message = parts
recipient = recipient.strip()
subject = subject.strip()
message = message.rstrip()
if not recipient:
self.term.show_notification('Missing recipient')
return
elif not subject:
self.term.show_notification('Missing message subject')
return
elif not message:
self.term.show_notification('Missing message body')
return
with self.term.loader('Sending message', delay=0):
self.reddit.send_message(
recipient, subject, message, raise_captcha_exception=True)
# Give reddit time to process the message
time.sleep(2.0)
if self.term.loader.exception:
raise TemporaryFileError()
else:
self.term.show_notification('Message sent!')
self.selected_page = self.open_inbox_page('sent')
def prompt_and_select_link(self):
"""
Prompt the user to select a link from a list to open.
Return the link that was selected, or ``None`` if no link was selected.
"""
data = self.get_selected_item()
url_full = data.get('url_full')
permalink = data.get('permalink')
if url_full and url_full != permalink:
# The item is a link-only submission that won't contain text
link = url_full
else:
html = data.get('html')
if html:
extracted_links = self.content.extract_links(html)
if not extracted_links:
# Only one selection to choose from, so just pick it
link = permalink
else:
# Let the user decide which link to open
links = []
if permalink:
links += [{'text': 'Permalink', 'href': permalink}]
links += extracted_links
link = self.term.prompt_user_to_select_link(links)
else:
# Some items like hidden comments don't have any HTML to parse
link = permalink
return link
@PageController.register(Command('COPY_PERMALINK'))
def copy_permalink(self):
"""
Copy the submission permalink to OS clipboard
"""
url = self.get_selected_item().get('permalink')
self.copy_to_clipboard(url)
@PageController.register(Command('COPY_URL'))
def copy_url(self):
"""
Copy a link to OS clipboard
"""
url = self.prompt_and_select_link()
self.copy_to_clipboard(url)
def copy_to_clipboard(self, url):
"""
Attempt to copy the selected URL to the user's clipboard
"""
if url is None:
self.term.flash()
return
try:
clipboard_copy(url, self.config['clipboard_cmd'])
except (ProgramError, OSError) as e:
_logger.exception(e)
self.term.show_notification(
'Failed to copy url: {0}'.format(e))
else:
self.term.show_notification(
['Copied to clipboard:', url], timeout=1)
@PageController.register(Command('SUBSCRIPTIONS'))
@logged_in
def subscriptions(self):
"""
View a list of the user's subscribed subreddits
"""
self.selected_page = self.open_subscription_page('subreddit')
@PageController.register(Command('MULTIREDDITS'))
@logged_in
def multireddits(self):
"""
View a list of the user's subscribed multireddits
"""
self.selected_page = self.open_subscription_page('multireddit')
@PageController.register(Command('PROMPT'))
def prompt(self):
"""
Open a prompt to navigate to a different subreddit or comment"
"""
name = self.term.prompt_input('Enter page: /')
if name:
# Check if opening a submission url or a subreddit url
# Example patterns for submissions:
# comments/571dw3
# /comments/571dw3
# /r/pics/comments/571dw3/
# https://www.reddit.com/r/pics/comments/571dw3/at_disneyland
submission_pattern = re.compile(r'(^|/)comments/(?P<id>.+?)($|/)')
match = submission_pattern.search(name)
if match:
url = 'https://www.reddit.com/comments/{0}'.format(match.group('id'))
self.selected_page = self.open_submission_page(url)
else:
self.selected_page = self.open_subreddit_page(name)
@PageController.register(Command('INBOX'))
@logged_in
def inbox(self):
"""
View the user's inbox.
"""
self.selected_page = self.open_inbox_page('all')
def open_inbox_page(self, content_type):
"""
Open an instance of the inbox page for the logged in user.
"""
from .inbox_page import InboxPage
with self.term.loader('Loading inbox'):
page = InboxPage(self.reddit, self.term, self.config, self.oauth,
content_type=content_type)
if not self.term.loader.exception:
return page
def open_subscription_page(self, content_type):
"""
Open an instance of the subscriptions page with the selected content.
"""
from .subscription_page import SubscriptionPage
with self.term.loader('Loading {0}s'.format(content_type)):
page = SubscriptionPage(self.reddit, self.term, self.config,
self.oauth, content_type=content_type)
if not self.term.loader.exception:
return page
def open_submission_page(self, url=None, submission=None):
"""
Open an instance of the submission page for the given submission URL.
"""
from .submission_page import SubmissionPage
with self.term.loader('Loading submission'):
page = SubmissionPage(self.reddit, self.term, self.config,
self.oauth, url=url, submission=submission)
if not self.term.loader.exception:
return page
def open_subreddit_page(self, name):
"""
Open an instance of the subreddit page for the given subreddit name.
"""
from .subreddit_page import SubredditPage
with self.term.loader('Loading subreddit'):
page = SubredditPage(self.reddit, self.term, self.config,
self.oauth, name)
if not self.term.loader.exception:
return page
def clear_input_queue(self):
"""
Clear excessive input caused by the scroll wheel or holding down a key
"""
with self.term.no_delay():
while self.term.getch() != -1:
continue
def draw(self):
"""
Clear the terminal screen and redraw all of the sub-windows
"""
n_rows, n_cols = self.term.stdscr.getmaxyx()
if n_rows < self.term.MIN_HEIGHT or n_cols < self.term.MIN_WIDTH:
# TODO: Will crash when you try to navigate if the terminal is too
# small at startup because self._subwindows will never be populated
return
self._row = 0
self._draw_header()
self._draw_banner()
self._draw_content()
self._draw_footer()
self.term.clear_screen()
self.term.stdscr.refresh()
def _draw_header(self):
"""
Draw the title bar at the top of the screen
"""
n_rows, n_cols = self.term.stdscr.getmaxyx()
# Note: 2 argument form of derwin breaks PDcurses on Windows 7!
window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
window.erase()
# curses.bkgd expects bytes in py2 and unicode in py3
window.bkgd(str(' '), self.term.attr('TitleBar'))
sub_name = self.content.name
sub_name = sub_name.replace('/r/front', 'Front Page')
parts = sub_name.split('/')
if len(parts) == 1:
pass
elif '/m/' in sub_name:
_, _, user, _, multi = parts
sub_name = '{} Curated by {}'.format(multi, user)
elif parts[1] == 'u':
noun = 'My' if parts[2] == 'me' else parts[2] + "'s"
user_room = parts[3] if len(parts) == 4 else 'overview'
title_lookup = {
'overview': 'Overview',
'submitted': 'Submissions',
'comments': 'Comments',
'saved': 'Saved Content',
'hidden': 'Hidden Content',
'upvoted': 'Upvoted Content',
'downvoted': 'Downvoted Content'
}
sub_name = "{} {}".format(noun, title_lookup[user_room])
query = self.content.query
if query:
sub_name = 'Searching {0}: {1}'.format(sub_name, query)
self.term.add_line(window, sub_name, 0, 0)
# Set the terminal title
if len(sub_name) > 50:
title = sub_name.strip('/')
title = title.replace('_', ' ')
try:
title = title.rsplit('/', 1)[1]
except IndexError:
pass
else:
title = sub_name
# Setting the terminal title will break emacs or systems without
# X window.
if os.getenv('DISPLAY') and not os.getenv('INSIDE_EMACS'):
title += ' - tuir {0}'.format(__version__)
title = self.term.clean(title)
if six.PY3:
# In py3 you can't write bytes to stdout
title = title.decode('utf-8')
title = '\x1b]2;{0}\x07'.format(title)
else:
title = b'\x1b]2;{0}\x07'.format(title)
sys.stdout.write(title)
sys.stdout.flush()
if self.reddit and self.reddit.user is not None:
# The starting position of the name depends on if we're converting
# to ascii or not
width = len if self.config['ascii'] else textual_width
if self.config['hide_username']:
username = "Logged in"
else:
username = self.reddit.user.name
s_col = (n_cols - width(username) - 1)
# Only print username if it fits in the empty space on the right
if (s_col - 1) >= width(sub_name):
self.term.add_line(window, username, 0, s_col)
self._row += 1
def _draw_banner(self):
"""
Draw the banner with sorting options at the top of the page
"""
n_rows, n_cols = self.term.stdscr.getmaxyx()
window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
window.erase()
window.bkgd(str(' '), self.term.attr('OrderBar'))
banner = docs.BANNER_SEARCH if self.content.query else self.BANNER
items = banner.strip().split(' ')
distance = (n_cols - sum(len(t) for t in items) - 1) / (len(items) - 1)
spacing = max(1, int(distance)) * ' '
text = spacing.join(items)
self.term.add_line(window, text, 0, 0)
if self.content.order is not None:
order = self.content.order.split('-')[0]
col = text.find(order) - 3
attr = self.term.attr('OrderBarHighlight')
window.chgat(0, col, 3, attr)
self._row += 1
def _draw_content(self):
"""
Loop through submissions and fill up the content page.
"""
n_rows, n_cols = self.term.stdscr.getmaxyx()
window = self.term.stdscr.derwin(n_rows - self._row - 1, n_cols, self._row, 0)
window.erase()
win_n_rows, win_n_cols = window.getmaxyx()
self._subwindows = []
page_index, cursor_index, inverted = self.nav.position
step = self.nav.step
# If not inverted, align the first submission with the top and draw
# downwards. If inverted, align the first submission with the bottom
# and draw upwards.
cancel_inverted = True
current_row = (win_n_rows - 1) if inverted else 0
available_rows = win_n_rows
top_item_height = None if inverted else self.nav.top_item_height
for data in self.content.iterate(page_index, step, win_n_cols - 2):
subwin_n_rows = min(available_rows, data['n_rows'])
subwin_inverted = inverted
if top_item_height is not None:
# Special case: draw the page as non-inverted, except for the
# top element. This element will be drawn as inverted with a
# restricted height
subwin_n_rows = min(subwin_n_rows, top_item_height)
subwin_inverted = True
top_item_height = None
subwin_n_cols = win_n_cols - data['h_offset']
start = current_row - subwin_n_rows + 1 if inverted else current_row
subwindow = window.derwin(subwin_n_rows, subwin_n_cols, start, data['h_offset'])
self._subwindows.append((subwindow, data, subwin_inverted))
available_rows -= (subwin_n_rows + 1) # Add one for the blank line
current_row += step * (subwin_n_rows + 1)
if available_rows <= 0:
# Indicate the page is full and we can keep the inverted screen.
cancel_inverted = False
break
if len(self._subwindows) == 1:
# Never draw inverted if only one subwindow. The top of the
# subwindow should always be aligned with the top of the screen.
cancel_inverted = True
if cancel_inverted and self.nav.inverted:
# In some cases we need to make sure that the screen is NOT
# inverted. Unfortunately, this currently means drawing the whole
# page over again. Could not think of a better way to pre-determine
# if the content will fill up the page, given that it is dependent
# on the size of the terminal.
self.nav.flip((len(self._subwindows) - 1))
self._draw_content()
return
if self.nav.cursor_index >= len(self._subwindows):
# Don't allow the cursor to go over the number of subwindows
# This could happen if the window is resized and the cursor index is
# pushed out of bounds
self.nav.cursor_index = len(self._subwindows) - 1
# Now that the windows are setup, we can take a second pass through
# to draw the text onto each subwindow
for index, (win, data, inverted) in enumerate(self._subwindows):
if self.nav.absolute_index >= 0 and index == self.nav.cursor_index:
win.bkgd(str(' '), self.term.attr('Selected'))
with self.term.theme.turn_on_selected():
self._draw_item(win, data, inverted)
else:
win.bkgd(str(' '), self.term.attr('Normal'))
self._draw_item(win, data, inverted)
self._row += win_n_rows
def _draw_footer(self):
"""
Draw the key binds help bar at the bottom of the screen
"""
n_rows, n_cols = self.term.stdscr.getmaxyx()
window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
window.erase()
window.bkgd(str(' '), self.term.attr('HelpBar'))
text = self.FOOTER.strip()
self.term.add_line(window, text, 0, 0)
self._row += 1
def _move_cursor(self, direction):
# Note: ACS_VLINE doesn't like changing the attribute, so disregard the
# redraw flag and opt to always redraw
valid, redraw = self.nav.move(direction, len(self._subwindows))
if not valid:
self.term.flash()
def _move_page(self, direction):
valid, redraw = self.nav.move_page(direction, len(self._subwindows)-1)
if not valid:
self.term.flash()
def _prompt_period(self, order):
choices = {
'\n': order,
'1': '{0}-hour'.format(order),
'2': '{0}-day'.format(order),
'3': '{0}-week'.format(order),
'4': '{0}-month'.format(order),
'5': '{0}-year'.format(order),
'6': '{0}-all'.format(order)}
message = docs.TIME_ORDER_MENU.strip().splitlines()
ch = self.term.show_notification(message)
ch = six.unichr(ch)
return choices.get(ch)

415
tuir/submission_page.py Normal file
View File

@@ -0,0 +1,415 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from . import docs
from .content import SubmissionContent
from .page import Page, PageController, logged_in
from .objects import Navigator, Command
class SubmissionController(PageController):
character_map = {}
class SubmissionPage(Page):
BANNER = docs.BANNER_SUBMISSION
FOOTER = docs.FOOTER_SUBMISSION
name = 'submission'
def __init__(self, reddit, term, config, oauth, url=None, submission=None):
super(SubmissionPage, self).__init__(reddit, term, config, oauth)
self.controller = SubmissionController(self, keymap=config.keymap)
if url:
self.content = SubmissionContent.from_url(
reddit, url, term.loader,
max_comment_cols=config['max_comment_cols'])
else:
self.content = SubmissionContent(
submission, term.loader,
max_comment_cols=config['max_comment_cols'])
# Start at the submission post, which is indexed as -1
self.nav = Navigator(self.content.get, page_index=-1)
def handle_selected_page(self):
"""
Open the subscription page in a subwindow, but close the current page
if any other type of page is selected.
"""
if not self.selected_page:
pass
elif self.selected_page.name == 'subscription':
# Launch page in a subwindow
self.selected_page = self.selected_page.loop()
elif self.selected_page.name in ('subreddit', 'submission', 'inbox'):
# Replace the current page
self.active = False
else:
raise RuntimeError(self.selected_page.name)
def refresh_content(self, order=None, name=None):
"""
Re-download comments and reset the page index
"""
order = order or self.content.order
url = name or self.content.name
# Hack to allow an order specified in the name by prompt_subreddit() to
# override the current default
if order == 'ignore':
order = None
with self.term.loader('Refreshing page'):
self.content = SubmissionContent.from_url(
self.reddit, url, self.term.loader, order=order,
max_comment_cols=self.config['max_comment_cols'])
if not self.term.loader.exception:
self.nav = Navigator(self.content.get, page_index=-1)
@SubmissionController.register(Command('SORT_1'))
def sort_content_hot(self):
self.refresh_content(order='hot')
@SubmissionController.register(Command('SORT_2'))
def sort_content_top(self):
self.refresh_content(order='top')
@SubmissionController.register(Command('SORT_3'))
def sort_content_rising(self):
self.refresh_content(order='rising')
@SubmissionController.register(Command('SORT_4'))
def sort_content_new(self):
self.refresh_content(order='new')
@SubmissionController.register(Command('SORT_5'))
def sort_content_controversial(self):
self.refresh_content(order='controversial')
@SubmissionController.register(Command('SUBMISSION_TOGGLE_COMMENT'))
def toggle_comment(self):
"""
Toggle the selected comment tree between visible and hidden
"""
current_index = self.nav.absolute_index
self.content.toggle(current_index)
# This logic handles a display edge case after a comment toggle. We
# want to make sure that when we re-draw the page, the cursor stays at
# its current absolute position on the screen. In order to do this,
# apply a fixed offset if, while inverted, we either try to hide the
# bottom comment or toggle any of the middle comments.
if self.nav.inverted:
data = self.content.get(current_index)
if data['hidden'] or self.nav.cursor_index != 0:
window = self._subwindows[-1][0]
n_rows, _ = window.getmaxyx()
self.nav.flip(len(self._subwindows) - 1)
self.nav.top_item_height = n_rows
@SubmissionController.register(Command('SUBMISSION_EXIT'))
def exit_submission(self):
"""
Close the submission and return to the subreddit page
"""
self.active = False
@SubmissionController.register(Command('SUBMISSION_OPEN_IN_BROWSER'))
def open_link(self):
"""
Open the link contained in the selected item.
If there is more than one link contained in the item, prompt the user
to choose which link to open.
"""
data = self.get_selected_item()
if data['type'] == 'Submission':
link = self.prompt_and_select_link()
if link:
self.config.history.add(link)
self.term.open_link(link)
elif data['type'] == 'Comment':
link = self.prompt_and_select_link()
if link:
self.term.open_link(link)
else:
self.term.flash()
@SubmissionController.register(Command('SUBMISSION_OPEN_IN_PAGER'))
def open_pager(self):
"""
Open the selected item with the system's pager
"""
n_rows, n_cols = self.term.stdscr.getmaxyx()
if self.config['max_pager_cols'] is not None:
n_cols = min(n_cols, self.config['max_pager_cols'])
data = self.get_selected_item()
if data['type'] == 'Submission':
text = '\n\n'.join((data['permalink'], data['text']))
self.term.open_pager(text, wrap=n_cols)
elif data['type'] == 'Comment':
text = '\n\n'.join((data['permalink'], data['body']))
self.term.open_pager(text, wrap=n_cols)
else:
self.term.flash()
@SubmissionController.register(Command('SUBMISSION_POST'))
@logged_in
def add_comment(self):
"""
Submit a reply to the selected item.
"""
self.reply()
@SubmissionController.register(Command('DELETE'))
@logged_in
def delete_comment(self):
"""
Delete the selected comment
"""
if self.get_selected_item()['type'] == 'Comment':
self.delete_item()
else:
self.term.flash()
@SubmissionController.register(Command('SUBMISSION_OPEN_IN_URLVIEWER'))
def comment_urlview(self):
"""
Open the selected comment with the URL viewer
"""
data = self.get_selected_item()
comment = data.get('body') or data.get('text') or data.get('url_full')
if comment:
self.term.open_urlview(comment)
else:
self.term.flash()
@SubmissionController.register(Command('SUBMISSION_GOTO_PARENT'))
def move_parent_up(self):
"""
Move the cursor up to the comment's parent. If the comment is
top-level, jump to the previous top-level comment.
"""
cursor = self.nav.absolute_index
if cursor > 0:
level = max(self.content.get(cursor)['level'], 1)
while self.content.get(cursor - 1)['level'] >= level:
self._move_cursor(-1)
cursor -= 1
self._move_cursor(-1)
else:
self.term.flash()
self.clear_input_queue()
@SubmissionController.register(Command('SUBMISSION_GOTO_SIBLING'))
def move_sibling_next(self):
"""
Jump to the next comment that's at the same level as the selected
comment and shares the same parent.
"""
cursor = self.nav.absolute_index
if cursor >= 0:
level = self.content.get(cursor)['level']
try:
move = 1
while self.content.get(cursor + move)['level'] > level:
move += 1
except IndexError:
self.term.flash()
else:
if self.content.get(cursor + move)['level'] == level:
for _ in range(move):
self._move_cursor(1)
else:
self.term.flash()
else:
self.term.flash()
self.clear_input_queue()
def _draw_item(self, win, data, inverted):
if data['type'] == 'MoreComments':
return self._draw_more_comments(win, data)
elif data['type'] == 'HiddenComment':
return self._draw_more_comments(win, data)
elif data['type'] == 'Comment':
return self._draw_comment(win, data, inverted)
else:
return self._draw_submission(win, data)
def _draw_comment(self, win, data, inverted):
n_rows, n_cols = win.getmaxyx()
n_cols -= 1
# Handle the case where the window is not large enough to fit the text.
valid_rows = range(0, n_rows)
offset = 0 if not inverted else -(data['n_rows'] - n_rows)
# If there isn't enough space to fit the comment body on the screen,
# replace the last line with a notification.
split_body = data['split_body']
if data['n_rows'] > n_rows:
# Only when there is a single comment on the page and not inverted
if not inverted and len(self._subwindows) == 1:
cutoff = data['n_rows'] - n_rows + 1
split_body = split_body[:-cutoff]
split_body.append('(Not enough space to display)')
row = offset
if row in valid_rows:
if data['is_author']:
attr = self.term.attr('CommentAuthorSelf')
text = '{author} [S]'.format(**data)
else:
attr = self.term.attr('CommentAuthor')
text = '{author}'.format(**data)
self.term.add_line(win, text, row, 1, attr)
if data['flair']:
attr = self.term.attr('UserFlair')
self.term.add_space(win)
self.term.add_line(win, '{flair}'.format(**data), attr=attr)
arrow, attr = self.term.get_arrow(data['likes'])
self.term.add_space(win)
self.term.add_line(win, arrow, attr=attr)
attr = self.term.attr('Score')
self.term.add_space(win)
self.term.add_line(win, '{score}'.format(**data), attr=attr)
attr = self.term.attr('Created')
self.term.add_space(win)
self.term.add_line(win, '{created}{edited}'.format(**data),
attr=attr)
if data['gold']:
attr = self.term.attr('Gold')
self.term.add_space(win)
count = 'x{}'.format(data['gold']) if data['gold'] > 1 else ''
text = self.term.gilded + count
self.term.add_line(win, text, attr=attr)
if data['stickied']:
attr = self.term.attr('Stickied')
self.term.add_space(win)
self.term.add_line(win, '[stickied]', attr=attr)
if data['saved']:
attr = self.term.attr('Saved')
self.term.add_space(win)
self.term.add_line(win, '[saved]', attr=attr)
for row, text in enumerate(split_body, start=offset + 1):
attr = self.term.attr('CommentText')
if row in valid_rows:
self.term.add_line(win, text, row, 1, attr=attr)
# curses.vline() doesn't support custom colors so need to build the
# cursor bar on the left of the comment one character at a time
index = data['level'] % len(self.term.theme.CURSOR_BARS)
attr = self.term.attr(self.term.theme.CURSOR_BARS[index])
for y in range(n_rows):
self.term.addch(win, y, 0, self.term.vline, attr)
def _draw_more_comments(self, win, data):
n_rows, n_cols = win.getmaxyx()
n_cols -= 1
attr = self.term.attr('HiddenCommentText')
self.term.add_line(win, '{body}'.format(**data), 0, 1, attr=attr)
attr = self.term.attr('HiddenCommentExpand')
self.term.add_space(win)
self.term.add_line(win, '[{count}]'.format(**data), attr=attr)
index = data['level'] % len(self.term.theme.CURSOR_BARS)
attr = self.term.attr(self.term.theme.CURSOR_BARS[index])
self.term.addch(win, 0, 0, self.term.vline, attr)
def _draw_submission(self, win, data):
n_rows, n_cols = win.getmaxyx()
n_cols -= 3 # one for each side of the border + one for offset
attr = self.term.attr('SubmissionTitle')
for row, text in enumerate(data['split_title'], start=1):
self.term.add_line(win, text, row, 1, attr)
row = len(data['split_title']) + 1
attr = self.term.attr('SubmissionAuthor')
self.term.add_line(win, '{author}'.format(**data), row, 1, attr)
if data['flair']:
attr = self.term.attr('SubmissionFlair')
self.term.add_space(win)
self.term.add_line(win, '{flair}'.format(**data), attr=attr)
attr = self.term.attr('SubmissionSubreddit')
self.term.add_space(win)
self.term.add_line(win, '/r/{subreddit}'.format(**data), attr=attr)
attr = self.term.attr('Created')
self.term.add_space(win)
self.term.add_line(win, '{created_long}{edited_long}'.format(**data),
attr=attr)
row = len(data['split_title']) + 2
if data['url_full'] in self.config.history:
attr = self.term.attr('LinkSeen')
else:
attr = self.term.attr('Link')
self.term.add_line(win, '{url}'.format(**data), row, 1, attr)
offset = len(data['split_title']) + 3
# Cut off text if there is not enough room to display the whole post
split_text = data['split_text']
if data['n_rows'] > n_rows:
cutoff = data['n_rows'] - n_rows + 1
split_text = split_text[:-cutoff]
split_text.append('(Not enough space to display)')
attr = self.term.attr('SubmissionText')
for row, text in enumerate(split_text, start=offset):
self.term.add_line(win, text, row, 1, attr=attr)
row = len(data['split_title']) + len(split_text) + 3
attr = self.term.attr('Score')
self.term.add_line(win, '{score}'.format(**data), row, 1, attr=attr)
arrow, attr = self.term.get_arrow(data['likes'])
self.term.add_space(win)
self.term.add_line(win, arrow, attr=attr)
attr = self.term.attr('CommentCount')
self.term.add_space(win)
self.term.add_line(win, '{comments}'.format(**data), attr=attr)
if data['gold']:
attr = self.term.attr('Gold')
self.term.add_space(win)
count = 'x{}'.format(data['gold']) if data['gold'] > 1 else ''
text = self.term.gilded + count
self.term.add_line(win, text, attr=attr)
if data['nsfw']:
attr = self.term.attr('NSFW')
self.term.add_space(win)
self.term.add_line(win, 'NSFW', attr=attr)
if data['saved']:
attr = self.term.attr('Saved')
self.term.add_space(win)
self.term.add_line(win, '[saved]', attr=attr)
win.border()

329
tuir/subreddit_page.py Normal file
View File

@@ -0,0 +1,329 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import time
from . import docs
from .content import SubredditContent
from .page import Page, PageController, logged_in
from .objects import Navigator, Command
from .exceptions import TemporaryFileError
class SubredditController(PageController):
character_map = {}
class SubredditPage(Page):
BANNER = docs.BANNER_SUBREDDIT
FOOTER = docs.FOOTER_SUBREDDIT
name = 'subreddit'
def __init__(self, reddit, term, config, oauth, name):
"""
Params:
name (string): Name of subreddit to open
"""
super(SubredditPage, self).__init__(reddit, term, config, oauth)
self.controller = SubredditController(self, keymap=config.keymap)
self.content = SubredditContent.from_name(reddit, name, term.loader)
self.nav = Navigator(self.content.get)
self.toggled_subreddit = None
def handle_selected_page(self):
"""
Open all selected pages in subwindows except other subreddit pages.
"""
if not self.selected_page:
pass
elif self.selected_page.name in ('subscription', 'submission', 'inbox'):
# Launch page in a subwindow
self.selected_page = self.selected_page.loop()
elif self.selected_page.name == 'subreddit':
# Replace the current page
self.active = False
else:
raise RuntimeError(self.selected_page.name)
def refresh_content(self, order=None, name=None):
"""
Re-download all submissions and reset the page index
"""
order = order or self.content.order
# Preserve the query if staying on the current page
if name is None:
query = self.content.query
else:
query = None
name = name or self.content.name
# Hack to allow an order specified in the name by prompt_subreddit() to
# override the current default
if order == 'ignore':
order = None
with self.term.loader('Refreshing page'):
self.content = SubredditContent.from_name(
self.reddit, name, self.term.loader, order=order, query=query)
if not self.term.loader.exception:
self.nav = Navigator(self.content.get)
@SubredditController.register(Command('SORT_1'))
def sort_content_hot(self):
if self.content.query:
self.refresh_content(order='relevance')
else:
self.refresh_content(order='hot')
@SubredditController.register(Command('SORT_2'))
def sort_content_top(self):
order = self._prompt_period('top')
if order is None:
self.term.show_notification('Invalid option')
else:
self.refresh_content(order=order)
@SubredditController.register(Command('SORT_3'))
def sort_content_rising(self):
if self.content.query:
order = self._prompt_period('comments')
if order is None:
self.term.show_notification('Invalid option')
else:
self.refresh_content(order=order)
else:
self.refresh_content(order='rising')
@SubredditController.register(Command('SORT_4'))
def sort_content_new(self):
self.refresh_content(order='new')
@SubredditController.register(Command('SORT_5'))
def sort_content_controversial(self):
if self.content.query:
self.term.flash()
else:
order = self._prompt_period('controversial')
if order is None:
self.term.show_notification('Invalid option')
else:
self.refresh_content(order=order)
@SubredditController.register(Command('SORT_6'))
def sort_content_gilded(self):
if self.content.query:
self.term.flash()
else:
self.refresh_content(order='gilded')
@SubredditController.register(Command('SUBREDDIT_SEARCH'))
def search_subreddit(self, name=None):
"""
Open a prompt to search the given subreddit
"""
name = name or self.content.name
query = self.term.prompt_input('Search {0}: '.format(name))
if not query:
return
with self.term.loader('Searching'):
self.content = SubredditContent.from_name(
self.reddit, name, self.term.loader, query=query)
if not self.term.loader.exception:
self.nav = Navigator(self.content.get)
@SubredditController.register(Command('SUBREDDIT_FRONTPAGE'))
def show_frontpage(self):
"""
If on a subreddit, remember it and head back to the front page.
If this was pressed on the front page, go back to the last subreddit.
"""
if self.content.name != '/r/front':
target = '/r/front'
self.toggled_subreddit = self.content.name
else:
target = self.toggled_subreddit
# target still may be empty string if this command hasn't yet been used
if target is not None:
self.refresh_content(order='ignore', name=target)
@SubredditController.register(Command('SUBREDDIT_OPEN'))
def open_submission(self, url=None):
"""
Select the current submission to view posts.
"""
if url is None:
data = self.get_selected_item()
url = data['permalink']
if data.get('url_type') == 'selfpost':
self.config.history.add(data['url_full'])
self.selected_page = self.open_submission_page(url)
@SubredditController.register(Command('SUBREDDIT_OPEN_IN_BROWSER'))
def open_link(self):
"""
Open a link with the webbrowser
"""
data = self.get_selected_item()
if data['url_type'] == 'selfpost':
self.open_submission()
elif data['url_type'] == 'x-post subreddit':
self.refresh_content(order='ignore', name=data['xpost_subreddit'])
elif data['url_type'] == 'x-post submission':
self.open_submission(url=data['url_full'])
self.config.history.add(data['url_full'])
else:
self.term.open_link(data['url_full'])
self.config.history.add(data['url_full'])
@SubredditController.register(Command('SUBREDDIT_POST'))
@logged_in
def post_submission(self):
"""
Post a new submission to the given subreddit.
"""
# Check that the subreddit can be submitted to
name = self.content.name
if '+' in name or name in ('/r/all', '/r/front', '/r/me', '/u/saved'):
self.term.show_notification("Can't post to {0}".format(name))
return
submission_info = docs.SUBMISSION_FILE.format(name=name)
with self.term.open_editor(submission_info) as text:
if not text:
self.term.show_notification('Canceled')
return
elif '\n' not in text:
self.term.show_notification('Missing body')
return
title, content = text.split('\n', 1)
with self.term.loader('Posting', delay=0):
submission = self.reddit.submit(name, title, text=content,
raise_captcha_exception=True)
# Give reddit time to process the submission
time.sleep(2.0)
if self.term.loader.exception:
raise TemporaryFileError()
if not self.term.loader.exception:
# Open the newly created submission
self.selected_page = self.open_submission_page(submission=submission)
@SubredditController.register(Command('SUBREDDIT_HIDE'))
@logged_in
def hide(self):
data = self.get_selected_item()
if not hasattr(data["object"], 'hide'):
self.term.flash()
elif data['hidden']:
with self.term.loader('Unhiding'):
data['object'].unhide()
data['hidden'] = False
else:
with self.term.loader('Hiding'):
data['object'].hide()
data['hidden'] = True
def _draw_item(self, win, data, inverted):
n_rows, n_cols = win.getmaxyx()
n_cols -= 1 # Leave space for the cursor in the first column
# Handle the case where the window is not large enough to fit the data.
valid_rows = range(0, n_rows)
offset = 0 if not inverted else -(data['n_rows'] - n_rows)
n_title = len(data['split_title'])
if data['url_full'] in self.config.history:
attr = self.term.attr('SubmissionTitleSeen')
else:
attr = self.term.attr('SubmissionTitle')
for row, text in enumerate(data['split_title'], start=offset):
if row in valid_rows:
self.term.add_line(win, text, row, 1, attr)
row = n_title + offset
if data['url_full'] in self.config.history:
attr = self.term.attr('LinkSeen')
else:
attr = self.term.attr('Link')
if row in valid_rows:
self.term.add_line(win, '{url}'.format(**data), row, 1, attr)
row = n_title + offset + 1
if row in valid_rows:
attr = self.term.attr('Score')
self.term.add_line(win, '{score}'.format(**data), row, 1, attr)
self.term.add_space(win)
arrow, attr = self.term.get_arrow(data['likes'])
self.term.add_line(win, arrow, attr=attr)
self.term.add_space(win)
attr = self.term.attr('Created')
self.term.add_line(win, '{created}{edited}'.format(**data), attr=attr)
if data['comments'] is not None:
attr = self.term.attr('Separator')
self.term.add_space(win)
self.term.add_line(win, '-', attr=attr)
attr = self.term.attr('CommentCount')
self.term.add_space(win)
self.term.add_line(win, '{comments}'.format(**data), attr=attr)
if data['saved']:
attr = self.term.attr('Saved')
self.term.add_space(win)
self.term.add_line(win, '[saved]', attr=attr)
if data['hidden']:
attr = self.term.attr('Hidden')
self.term.add_space(win)
self.term.add_line(win, '[hidden]', attr=attr)
if data['stickied']:
attr = self.term.attr('Stickied')
self.term.add_space(win)
self.term.add_line(win, '[stickied]', attr=attr)
if data['gold']:
attr = self.term.attr('Gold')
self.term.add_space(win)
count = 'x{}'.format(data['gold']) if data['gold'] > 1 else ''
text = self.term.gilded + count
self.term.add_line(win, text, attr=attr)
if data['nsfw']:
attr = self.term.attr('NSFW')
self.term.add_space(win)
self.term.add_line(win, 'NSFW', attr=attr)
row = n_title + offset + 2
if row in valid_rows:
attr = self.term.attr('SubmissionAuthor')
self.term.add_line(win, '{author}'.format(**data), row, 1, attr)
self.term.add_space(win)
attr = self.term.attr('SubmissionSubreddit')
self.term.add_line(win, '/r/{subreddit}'.format(**data), attr=attr)
if data['flair']:
attr = self.term.attr('SubmissionFlair')
self.term.add_space(win)
self.term.add_line(win, '{flair}'.format(**data), attr=attr)
attr = self.term.attr('CursorBlock')
for y in range(n_rows):
self.term.addch(win, y, 0, str(' '), attr)

97
tuir/subscription_page.py Normal file
View File

@@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from . import docs
from .content import SubscriptionContent
from .page import Page, PageController, logged_in
from .objects import Navigator, Command
class SubscriptionController(PageController):
character_map = {}
class SubscriptionPage(Page):
BANNER = None
FOOTER = docs.FOOTER_SUBSCRIPTION
name = 'subscription'
def __init__(self, reddit, term, config, oauth, content_type='subreddit'):
super(SubscriptionPage, self).__init__(reddit, term, config, oauth)
self.controller = SubscriptionController(self, keymap=config.keymap)
self.content = SubscriptionContent.from_user(
reddit, term.loader, content_type)
self.nav = Navigator(self.content.get)
self.content_type = content_type
def handle_selected_page(self):
"""
Always close the current page when another page is selected.
"""
if self.selected_page:
self.active = False
def refresh_content(self, order=None, name=None):
"""
Re-download all subscriptions and reset the page index
"""
# reddit.get_my_subreddits() does not support sorting by order
if order:
self.term.flash()
return
with self.term.loader():
self.content = SubscriptionContent.from_user(
self.reddit, self.term.loader, self.content_type)
if not self.term.loader.exception:
self.nav = Navigator(self.content.get)
@SubscriptionController.register(Command('SUBSCRIPTION_SELECT'))
def select_subreddit(self):
"""
Store the selected subreddit and return to the subreddit page
"""
name = self.get_selected_item()['name']
self.selected_page = self.open_subreddit_page(name)
@SubscriptionController.register(Command('SUBSCRIPTION_EXIT'))
def close_subscriptions(self):
"""
Close subscriptions and return to the subreddit page
"""
self.active = False
def _draw_banner(self):
# Subscriptions can't be sorted, so disable showing the order menu
pass
def _draw_item(self, win, data, inverted):
n_rows, n_cols = win.getmaxyx()
n_cols -= 1 # Leave space for the cursor in the first column
# Handle the case where the window is not large enough to fit the data.
valid_rows = range(0, n_rows)
offset = 0 if not inverted else -(data['n_rows'] - n_rows)
row = offset
if row in valid_rows:
if data['type'] == 'Multireddit':
attr = self.term.attr('MultiredditName')
else:
attr = self.term.attr('SubscriptionName')
self.term.add_line(win, '{name}'.format(**data), row, 1, attr)
row = offset + 1
for row, text in enumerate(data['split_title'], start=row):
if row in valid_rows:
if data['type'] == 'Multireddit':
attr = self.term.attr('MultiredditText')
else:
attr = self.term.attr('SubscriptionText')
self.term.add_line(win, text, row, 1, attr)
attr = self.term.attr('CursorBlock')
for y in range(n_rows):
self.term.addch(win, y, 0, str(' '), attr)

31
tuir/templates/index.html Normal file
View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html>
<head>
<title>TUIR OAuth2 Helper</title>
<!-- style borrowed from http://bettermotherfuckingwebsite.com/ -->
<style type="text/css">
body {
margin:40px auto;
max-width:650px;
line-height:1.6;
font-size:18px;
font-family:Arial, Helvetica, sans-serif;
color:#444;
padding:0 10px;
}
h1, h2, h3 {
line-height:1.2
}
#footer {
position: absolute;
bottom: 0px;
width: 100%;
font-size:14px;
}
</style>
</head>
<body>
${message}
<div id="footer">View the <a href="http://www.gitlab.com/ajak/tuir">Documentation</a></div>
</body>
</html>

70
tuir/templates/mailcap Normal file
View File

@@ -0,0 +1,70 @@
# Example mailcap file for Terminal UI for Reddit
# https://gitlab.com/ajak/tuir/
#
# Copy the contents of this file to {HOME}/.mailcap, or point to it using $MAILCAPS
# Then launch TUIR using the --enable-media flag. All shell commands defined in
# this file depend on external programs that must be installed on your system.
#
# HELP REQUESTED! If you come up with your own commands (especially for OS X)
# and would like to share, please post an issue on the GitHub tracker and we
# can get them added to this file as references.
#
#
# Mailcap 101
# - The first entry with a matching MIME type will be executed, * is a wildcard
# - %s will be replaced with the image or video url
# - Add ``test=test -n "$DISPLAY"`` if your command opens a new window
# - Add ``needsterminal`` for commands that use the terminal
# - Add ``copiousoutput`` for commands that dump text to stdout
###############################################################################
# Commands below this point will open media in a separate window without
# pausing execution of TUIR.
###############################################################################
# Feh is a simple and effective image viewer
# Note that tuir returns a list of urls for imgur albums, so we don't put quotes
# around the `%s`
image/x-imgur-album; feh -g 640x480 -. %s; test=test -n "$DISPLAY"
image/gif; mpv '%s' --autofit 640x480 --loop=inf; test=test -n "$DISPLAY"
image/*; feh -g 640x480 -. '%s'; test=test -n "$DISPLAY"
# Youtube videos are assigned a custom mime-type, which can be streamed with
# vlc or youtube-dl.
video/x-youtube; vlc '%s' --width 640 --height 480; test=test -n "$DISPLAY"
video/x-youtube; mpv --ytdl-format=bestvideo+bestaudio/best '%s' --autofit 640x480; test=test -n "$DISPLAY"
# Mpv is a simple and effective video streamer
video/*; mpv '%s' --autofit 640x480 --loop=inf; test=test -n "$DISPLAY"
###############################################################################
# Commands below this point will attempt to display media directly in the
# terminal when a desktop is not available (e.g. inside of an SSH session)
###############################################################################
# View images directly in your terminal with iTerm2
# curl -L https://iterm2.com/misc/install_shell_integration_and_utilities.sh | bash
# image/*; bash -c '[[ "%s" == http* ]] && (curl -s %s | ~/.iterm2/imgcat) || ~/.iterm2/imgcat %s' && read -n 1; needsterminal
# View true images in the terminal, supported by rxvt-unicode, xterm and st
# Requires the w3m-img package
# image/*; w3m -o 'ext_image_viewer=off' '%s'; needsterminal
# Don't have a solution for albums yet
image/x-imgur-album; echo
# 256 color images using half-width unicode characters
# Much higher quality that img2txt, but must be built from source
# https://github.com/rossy/img2xterm
image/*; curl -s '%s' | convert -resize 80x80 - jpg:/tmp/tuir.jpg && img2xterm /tmp/tuir.jpg; needsterminal; copiousoutput
# Display images in classic ascii using img2txt and lib-caca
image/*; curl -s '%s' | convert - jpg:/tmp/tuir.jpg && img2txt -f utf8 /tmp/tuir.jpg; needsterminal; copiousoutput
# Full motion videos - requires a framebuffer to view
video/x-youtube; mpv -vo drm -quiet '%s'; needsterminal
video/*; mpv -vo drm -quiet '%s'; needsterminal
# Ascii videos
# video/x-youtube; youtube-dl -q -o - '%s' | mplayer -cache 8192 -vo caca -quiet -; needsterminal
# video/*; wget '%s' -O - | mplayer -cache 8192 -vo caca -quiet -; needsterminal

186
tuir/templates/tuir.cfg Normal file
View File

@@ -0,0 +1,186 @@
; Terminal UI for Reddit Configuration File
; https://gitlab.com/ajak/tuir
;
; This file should be placed in $XDG_CONFIG/tuir/tuir.cfg
; If $XDG_CONFIG is not set, use ~/.config/tuir/tuir.cfg
[tuir]
##################
# General Settings
##################
; Turn on ascii-only mode to disable all unicode characters.
; This may be necessary for compatibility with some terminal browsers.
ascii = False
; Turn on monochrome mode to disable color.
monochrome = False
; Data being copied is piped into this command
;clipboard_cmd = xclip
;clipboard_cmd = xsel -b -i
;clipboard_cmd = wl-copy
;clipboard_cmd = pbcopy w
; Flash when an invalid action is executed.
flash = True
; Enable debugging by logging all HTTP requests and errors to the given file.
;log = /tmp/tuir.log
; Default subreddit that will be opened when the program launches.
subreddit = front
;subreddit = python
;subreddit = python+linux+programming
;subreddit = all
; Allow tuir to store reddit authentication credentials between sessions.
persistent = True
; Automatically log in on startup, if credentials are available.
autologin = True
; Clear any stored credentials when the program starts.
clear_auth = False
; Maximum number of opened links that will be saved in the history file.
history_size = 200
; Open external links using programs defined in the mailcap config.
enable_media = False
; Maximum number of columns for a comment
max_comment_cols = 120
; Maximum number of columns for pager
;max_pager_cols = 70
; Hide username if logged in, display "Logged in" instead
hide_username = False
; Color theme, use "tuir --list-themes" to view a list of valid options.
; This can be an absolute filepath, or the name of a theme file that has
; been installed into either the custom of default theme paths.
;theme = molokai
; Open a new browser window instead of a new tab in existing instance
force_new_browser_window = False
################
# OAuth Settings
################
; This sections defines the paramaters that will be used during the OAuth
; authentication process. tuir is registered as an "installed app",
; see https://github.com/reddit/reddit/wiki/OAuth2 for more information.
; These settings are defined at https://www.reddit.com/prefs/apps and should
; not be altered unless you are defining your own developer application.
oauth_client_id = zjyhNI7tK8ivzQ
oauth_client_secret = praw_gapfill
oauth_redirect_uri = http://127.0.0.1:65000/
; Port that the tuir webserver will listen on. This should match the redirect
; uri defined above.
oauth_redirect_port = 65000
; Access permissions that will be requested.
oauth_scope = edit,history,identity,mysubreddits,privatemessages,read,report,save,submit,subscribe,vote
; This is a separate token for the imgur api. It's used to extract images
; from imgur links and albums so they can be opened with mailcap.
; See https://imgur.com/account/settings/apps to generate your own key.
imgur_client_id = 93396265f59dec9
[bindings]
##############
# Key Bindings
##############
; If you would like to define custom bindings, copy this section into your
; config file with the [bindings] heading. All commands must be bound to at
; least one key for the config to be valid.
;
; 1.) Plain keys can be represented by either uppercase/lowercase characters
; or the hexadecimal numbers referring their ascii codes. For reference, see
; https://en.wikipedia.org/wiki/ASCII#ASCII_printable_code_chart
; e.g. Q, q, 1, ?
; e.g. 0x20 (space), 0x3c (less-than sign)
;
; 2.) Special ascii control codes should be surrounded with <>. For reference,
; see https://en.wikipedia.org/wiki/ASCII#ASCII_control_code_chart
; e.g. <LF> (enter), <ESC> (escape)
;
; 3.) Other special keys are defined by curses, they should be surrounded by <>
; and prefixed with KEY_. For reference, see
; https://docs.python.org/2/library/curses.html#constants
; e.g. <KEY_LEFT> (left arrow), <KEY_F5>, <KEY_NPAGE> (page down)
;
; Notes:
; - Curses <KEY_ENTER> is unreliable and should always be used in conjunction
; with <LF>.
; - Use 0x20 for the space key.
; - A subset of Ctrl modifiers are available through the ascii control codes.
; For example, Ctrl-D will trigger an <EOT> signal. See the table above for
; a complete reference.
; Base page
EXIT = q
FORCE_EXIT = Q
HELP = ?
SORT_1 = 1
SORT_2 = 2
SORT_3 = 3
SORT_4 = 4
SORT_5 = 5
SORT_6 = 6
SORT_7 = 7
MOVE_UP = k, <KEY_UP>
MOVE_DOWN = j, <KEY_DOWN>
PREVIOUS_THEME = <KEY_F2>
NEXT_THEME = <KEY_F3>
PAGE_UP = m, <KEY_PPAGE>, <NAK>
PAGE_DOWN = n, <KEY_NPAGE>, <EOT>
PAGE_TOP = gg
PAGE_BOTTOM = G
UPVOTE = a
DOWNVOTE = z
LOGIN = u
DELETE = d
EDIT = e
INBOX = i
REFRESH = r, <KEY_F5>
PROMPT = /
SAVE = w
COPY_PERMALINK = y
COPY_URL = Y
PRIVATE_MESSAGE = C
SUBSCRIPTIONS = s
MULTIREDDITS = S
; Submission page
SUBMISSION_TOGGLE_COMMENT = 0x20
SUBMISSION_OPEN_IN_BROWSER = o, <LF>, <KEY_ENTER>
SUBMISSION_POST = c
SUBMISSION_EXIT = h, <KEY_LEFT>
SUBMISSION_OPEN_IN_PAGER = l, <KEY_RIGHT>
SUBMISSION_OPEN_IN_URLVIEWER = b
SUBMISSION_GOTO_PARENT = K
SUBMISSION_GOTO_SIBLING = J
; Subreddit page
SUBREDDIT_SEARCH = f
SUBREDDIT_POST = c
SUBREDDIT_OPEN = l, <KEY_RIGHT>
SUBREDDIT_OPEN_IN_BROWSER = o, <LF>, <KEY_ENTER>
SUBREDDIT_FRONTPAGE = p
SUBREDDIT_HIDE = 0x20
; Subscription page
SUBSCRIPTION_SELECT = l, <LF>, <KEY_ENTER>, <KEY_RIGHT>
SUBSCRIPTION_EXIT = h, s, S, <ESC>, <KEY_LEFT>
; Inbox page
INBOX_VIEW_CONTEXT = l, <KEY_RIGHT>
INBOX_OPEN_SUBMISSION = o, <LF>, <KEY_ENTER>
INBOX_REPLY = c
INBOX_MARK_READ = w
INBOX_EXIT = h, <ESC>, <KEY_LEFT>

1012
tuir/terminal.py Normal file

File diff suppressed because it is too large Load Diff

567
tuir/theme.py Normal file
View File

@@ -0,0 +1,567 @@
# pylint: disable=bad-whitespace
import os
import codecs
import curses
import logging
from collections import OrderedDict
from contextlib import contextmanager
import six
from six.moves import configparser
from .config import THEMES, DEFAULT_THEMES
from .exceptions import ConfigError
_logger = logging.getLogger(__name__)
class Theme(object):
ATTRIBUTE_CODES = {
'-': None,
'': None,
'normal': curses.A_NORMAL,
'bold': curses.A_BOLD,
'reverse': curses.A_REVERSE,
'underline': curses.A_UNDERLINE,
'standout': curses.A_STANDOUT
}
COLOR_CODES = {
'-': None,
'default': -1,
'black': curses.COLOR_BLACK,
'red': curses.COLOR_RED,
'green': curses.COLOR_GREEN,
'yellow': curses.COLOR_YELLOW,
'blue': curses.COLOR_BLUE,
'magenta': curses.COLOR_MAGENTA,
'cyan': curses.COLOR_CYAN,
'light_gray': curses.COLOR_WHITE,
'dark_gray': 8,
'bright_red': 9,
'bright_green': 10,
'bright_yellow': 11,
'bright_blue': 12,
'bright_magenta': 13,
'bright_cyan': 14,
'white': 15,
}
for i in range(256):
COLOR_CODES['ansi_{0}'.format(i)] = i
# For compatibility with as many terminals as possible, the default theme
# can only use the 8 basic colors with the default color as the background
DEFAULT_THEME = {
'modifiers': {
'Normal': (-1, -1, curses.A_NORMAL),
'Selected': (-1, -1, curses.A_NORMAL),
'SelectedCursor': (-1, -1, curses.A_REVERSE),
},
'page': {
'TitleBar': (curses.COLOR_CYAN, None, curses.A_BOLD | curses.A_REVERSE),
'OrderBar': (curses.COLOR_YELLOW, None, curses.A_BOLD),
'OrderBarHighlight': (curses.COLOR_YELLOW, None, curses.A_BOLD | curses.A_REVERSE),
'HelpBar': (curses.COLOR_CYAN, None, curses.A_BOLD | curses.A_REVERSE),
'Prompt': (curses.COLOR_CYAN, None, curses.A_BOLD | curses.A_REVERSE),
'NoticeInfo': (None, None, curses.A_BOLD),
'NoticeLoading': (None, None, curses.A_BOLD),
'NoticeError': (None, None, curses.A_BOLD),
'NoticeSuccess': (None, None, curses.A_BOLD),
},
# Fields that might be highlighted by the "SelectedCursor" element
'cursor': {
'CursorBlock': (None, None, None),
'CursorBar1': (curses.COLOR_MAGENTA, None, None),
'CursorBar2': (curses.COLOR_CYAN, None, None),
'CursorBar3': (curses.COLOR_GREEN, None, None),
'CursorBar4': (curses.COLOR_YELLOW, None, None),
},
# Fields that might be highlighted by the "Selected" element
'normal': {
'CommentAuthor': (curses.COLOR_BLUE, None, curses.A_BOLD),
'CommentAuthorSelf': (curses.COLOR_GREEN, None, curses.A_BOLD),
'CommentCount': (None, None, None),
'CommentText': (None, None, None),
'Created': (None, None, None),
'Downvote': (curses.COLOR_RED, None, curses.A_BOLD),
'Gold': (curses.COLOR_YELLOW, None, curses.A_BOLD),
'HiddenCommentExpand': (None, None, curses.A_BOLD),
'HiddenCommentText': (None, None, None),
'MultiredditName': (curses.COLOR_YELLOW, None, curses.A_BOLD),
'MultiredditText': (None, None, None),
'NeutralVote': (None, None, curses.A_BOLD),
'NSFW': (curses.COLOR_RED, None, curses.A_BOLD | curses.A_REVERSE),
'Saved': (curses.COLOR_GREEN, None, None),
'Hidden': (curses.COLOR_YELLOW, None, None),
'Score': (None, None, None),
'Separator': (None, None, curses.A_BOLD),
'Stickied': (curses.COLOR_GREEN, None, None),
'SubscriptionName': (curses.COLOR_YELLOW, None, curses.A_BOLD),
'SubscriptionText': (None, None, None),
'SubmissionAuthor': (curses.COLOR_GREEN, None, curses.A_BOLD),
'SubmissionFlair': (curses.COLOR_RED, None, None),
'SubmissionSubreddit': (curses.COLOR_YELLOW, None, None),
'SubmissionText': (None, None, None),
'SubmissionTitle': (None, None, curses.A_BOLD),
'SubmissionTitleSeen': (None, None, None),
'Upvote': (curses.COLOR_GREEN, None, curses.A_BOLD),
'Link': (curses.COLOR_BLUE, None, curses.A_UNDERLINE),
'LinkSeen': (curses.COLOR_MAGENTA, None, curses.A_UNDERLINE),
'UserFlair': (curses.COLOR_YELLOW, None, curses.A_BOLD),
'New': (curses.COLOR_RED, None, curses.A_BOLD),
'Distinguished': (curses.COLOR_RED, None, curses.A_BOLD),
'MessageSubject': (curses.COLOR_BLUE, None, curses.A_BOLD),
'MessageLink': (curses.COLOR_MAGENTA, None, curses.A_BOLD),
'MessageAuthor': (curses.COLOR_GREEN, None, curses.A_BOLD),
'MessageSubreddit': (curses.COLOR_YELLOW, None, None),
'MessageText': (None, None, None),
}
}
DEFAULT_ELEMENTS = {k: v for group in DEFAULT_THEME.values()
for k, v in group.items()}
# The SubmissionPage uses this to determine which color bar to use
CURSOR_BARS = ['CursorBar1', 'CursorBar2', 'CursorBar3', 'CursorBar4']
def __init__(self, name=None, source=None, elements=None, use_color=True):
"""
Params:
name (str): A unique string that describes the theme
source (str): A string that describes the source of the theme:
built-in - Should only be used when Theme() is called directly
preset - Themes packaged with tuir
installed - Themes in ~/.config/tuir/themes/
custom - When a filepath is explicitly provided, e.g.
``tuir --theme=/path/to/theme_file.cfg``
elements (dict): The theme's element map, should be in the same
format as Theme.DEFAULT_THEME.
"""
if source not in (None, 'built-in', 'preset', 'installed', 'custom'):
raise ValueError('Invalid source')
if name is None and source is None:
name = 'default' if use_color else 'monochrome'
source = 'built-in'
elif name is None or source is None:
raise ValueError('Must specify both `name` and `source`, or neither one')
self.name = name
self.source = source
self.use_color = use_color
self._color_pair_map = None
self._attribute_map = None
self._selected = None
self.required_color_pairs = 0
self.required_colors = 0
if elements is None:
elements = self.DEFAULT_ELEMENTS.copy()
# Set any elements that weren't defined by the config to fallback to
# the default color and attributes
for key in self.DEFAULT_ELEMENTS.keys():
if key not in elements:
elements[key] = (None, None, None)
self._set_fallback(elements, 'Normal', (-1, -1, curses.A_NORMAL))
self._set_fallback(elements, 'Selected', 'Normal')
self._set_fallback(elements, 'SelectedCursor', 'Normal')
# Create the "Selected" versions of elements, which are prefixed with
# the @ symbol. For example, "@CommentText" represents how comment
# text is formatted when it is highlighted by the cursor.
for key in self.DEFAULT_THEME['normal']:
dest = '@{0}'.format(key)
self._set_fallback(elements, key, 'Selected', dest)
for key in self.DEFAULT_THEME['cursor']:
dest = '@{0}'.format(key)
self._set_fallback(elements, key, 'SelectedCursor', dest)
# Fill in the ``None`` values for all of the elements with normal text
for key in self.DEFAULT_THEME['normal']:
self._set_fallback(elements, key, 'Normal')
for key in self.DEFAULT_THEME['cursor']:
self._set_fallback(elements, key, 'Normal')
for key in self.DEFAULT_THEME['page']:
self._set_fallback(elements, key, 'Normal')
self.elements = elements
if self.use_color:
# Pre-calculate how many colors / color pairs the theme will need
colors, color_pairs = set(), set()
for fg, bg, _ in self.elements.values():
colors.add(fg)
colors.add(bg)
color_pairs.add((fg, bg))
# Don't count the default (-1, -1) as a color pair because it
# doesn't need to be initialized by curses.init_pair().
color_pairs.discard((-1, -1))
self.required_color_pairs = len(color_pairs)
# Determine how many colors the terminal needs to support in order
# to be able to use the theme. This uses the common breakpoints
# that 99% of terminals follow and doesn't take into account
# 88 color themes.
self.required_colors = None
for marker in [0, 8, 16, 256]:
if max(colors) < marker:
self.required_colors = marker
break
@property
def display_string(self):
return '{0} ({1})'.format(self.name, self.source)
def bind_curses(self):
"""
Bind the theme's colors to curses's internal color pair map.
This method must be called once (after curses has been initialized)
before any element attributes can be accessed. Color codes and other
special attributes will be mixed bitwise into a single value that
can be passed into curses draw functions.
"""
self._color_pair_map = {}
self._attribute_map = {}
for element, item in self.elements.items():
fg, bg, attrs = item
color_pair = (fg, bg)
if self.use_color and color_pair != (-1, -1):
# Curses limits the number of available color pairs, so we
# need to reuse them if there are multiple elements with the
# same foreground and background.
if color_pair not in self._color_pair_map:
# Index 0 is reserved by curses for the default color
index = len(self._color_pair_map) + 1
curses.init_pair(index, color_pair[0], color_pair[1])
self._color_pair_map[color_pair] = curses.color_pair(index)
attrs |= self._color_pair_map[color_pair]
self._attribute_map[element] = attrs
def get(self, element, selected=False):
"""
Returns the curses attribute code for the given element.
"""
if self._attribute_map is None:
raise RuntimeError('Attempted to access theme attribute before '
'calling initialize_curses_theme()')
if selected or self._selected:
element = '@{0}'.format(element)
return self._attribute_map[element]
@contextmanager
def turn_on_selected(self):
"""
Sets the selected modifier inside of context block.
For example:
>>> with theme.turn_on_selected():
>>> attr = theme.get('CursorBlock')
Is the same as:
>>> attr = theme.get('CursorBlock', selected=True)
Is also the same as:
>>> attr = theme.get('@CursorBlock')
"""
# This context manager should never be nested
assert self._selected is None
self._selected = True
try:
yield
finally:
self._selected = None
@classmethod
def list_themes(cls, path=THEMES):
"""
Compile all of the themes configuration files in the search path.
"""
themes, errors = [], OrderedDict()
def load_themes(path, source):
"""
Load all themes in the given path.
"""
if os.path.isdir(path):
for filename in sorted(os.listdir(path)):
if not filename.endswith('.cfg'):
continue
filepath = os.path.join(path, filename)
name = filename[:-4]
try:
# Make sure the theme is valid
theme = cls.from_file(filepath, source)
except Exception as e:
errors[(source, name)] = e
else:
themes.append(theme)
themes.extend([Theme(use_color=True), Theme(use_color=False)])
load_themes(DEFAULT_THEMES, 'preset')
load_themes(path, 'installed')
return themes, errors
@classmethod
def print_themes(cls, path=THEMES):
"""
Prints a human-readable summary of the installed themes to stdout.
This is intended to be used as a command-line utility, outside of the
main curses display loop.
"""
themes, errors = cls.list_themes(path=path + '/')
print('\nInstalled ({0}):'.format(path))
installed = [t for t in themes if t.source == 'installed']
if installed:
for theme in installed:
line = ' {0:<20}[requires {1} colors]'
print(line.format(theme.name, theme.required_colors))
else:
print(' (empty)')
print('\nPresets:')
preset = [t for t in themes if t.source == 'preset']
for theme in preset:
line = ' {0:<20}[requires {1} colors]'
print(line.format(theme.name, theme.required_colors))
print('\nBuilt-in:')
built_in = [t for t in themes if t.source == 'built-in']
for theme in built_in:
line = ' {0:<20}[requires {1} colors]'
print(line.format(theme.name, theme.required_colors))
if errors:
print('\nWARNING: Some files encountered errors:')
for (source, name), error in errors.items():
theme_info = '({0}) {1}'.format(source, name)
# Align multi-line error messages with the right column
err_message = six.text_type(error).replace('\n', '\n' + ' ' * 20)
print(' {0:<20}{1}'.format(theme_info, err_message))
print('')
@classmethod
def from_name(cls, name, path=THEMES):
"""
Search for the given theme on the filesystem and attempt to load it.
Directories will be checked in a pre-determined order. If the name is
provided as an absolute file path, it will be loaded directly.
"""
if os.path.isfile(name):
return cls.from_file(name, 'custom')
filename = os.path.join(path, '{0}.cfg'.format(name))
if os.path.isfile(filename):
return cls.from_file(filename, 'installed')
filename = os.path.join(DEFAULT_THEMES, '{0}.cfg'.format(name))
if os.path.isfile(filename):
return cls.from_file(filename, 'preset')
raise ConfigError('Could not find theme named "{0}"'.format(name))
@classmethod
def from_file(cls, filename, source):
"""
Load a theme from the specified configuration file.
Parameters:
filename: The name of the filename to load.
source: A description of where the theme was loaded from.
"""
_logger.info('Loading theme %s', filename)
try:
config = configparser.ConfigParser()
config.optionxform = six.text_type # Preserve case
with codecs.open(filename, encoding='utf-8') as fp:
config.read_file(fp)
except configparser.ParsingError as e:
raise ConfigError(e.message)
if not config.has_section('theme'):
raise ConfigError(
'Error loading {0}:\n'
' missing [theme] section'.format(filename))
theme_name = os.path.basename(filename)
theme_name, _ = os.path.splitext(theme_name)
elements = {}
for element, line in config.items('theme'):
if element not in cls.DEFAULT_ELEMENTS:
# Could happen if using a new config with an older version
# of the software
_logger.info('Skipping element %s', element)
continue
elements[element] = cls._parse_line(element, line, filename)
return cls(name=theme_name, source=source, elements=elements)
@classmethod
def _parse_line(cls, element, line, filename=None):
"""
Parse a single line from a theme file.
Format:
<element>: <foreground> <background> <attributes>
"""
items = line.split()
if len(items) == 2:
fg, bg, attrs = items[0], items[1], ''
elif len(items) == 3:
fg, bg, attrs = items
else:
raise ConfigError(
'Error loading {0}, invalid line:\n'
' {1} = {2}'.format(filename, element, line))
if fg.startswith('#'):
fg = cls.rgb_to_ansi(fg)
if bg.startswith('#'):
bg = cls.rgb_to_ansi(bg)
if fg not in cls.COLOR_CODES:
raise ConfigError(
'Error loading {0}, invalid <foreground>:\n'
' {1} = {2}'.format(filename, element, line))
fg_code = cls.COLOR_CODES[fg]
if bg not in cls.COLOR_CODES:
raise ConfigError(
'Error loading {0}, invalid <background>:\n'
' {1} = {2}'.format(filename, element, line))
bg_code = cls.COLOR_CODES[bg]
attrs_code = curses.A_NORMAL
for attr in attrs.split('+'):
if attr not in cls.ATTRIBUTE_CODES:
raise ConfigError(
'Error loading {0}, invalid <attributes>:\n'
' {1} = {2}'.format(filename, element, line))
attr_code = cls.ATTRIBUTE_CODES[attr]
if attr_code is None:
attrs_code = None
break
else:
attrs_code |= attr_code
return fg_code, bg_code, attrs_code
@staticmethod
def _set_fallback(elements, src_field, fallback, dest_field=None):
"""
Helper function used to set the fallback attributes of an element when
they are defined by the configuration as "None" or "-".
"""
if dest_field is None:
dest_field = src_field
if isinstance(fallback, six.string_types):
fallback = elements[fallback]
attrs = elements[src_field]
elements[dest_field] = (
attrs[0] if attrs[0] is not None else fallback[0],
attrs[1] if attrs[1] is not None else fallback[1],
attrs[2] if attrs[2] is not None else fallback[2])
@staticmethod
def rgb_to_ansi(color):
"""
Converts hex RGB to the 6x6x6 xterm color space
Args:
color (str): RGB color string in the format "#RRGGBB"
Returns:
str: ansi color string in the format "ansi_n", where n
is between 16 and 230
Reference:
https://github.com/chadj2/bash-ui/blob/master/COLORS.md
"""
if color[0] != '#' or len(color) != 7:
return None
try:
r = round(int(color[1:3], 16) / 51.0) # Normalize between 0-5
g = round(int(color[3:5], 16) / 51.0)
b = round(int(color[5:7], 16) / 51.0)
n = int(36 * r + 6 * g + b + 16)
return 'ansi_{0:d}'.format(n)
except ValueError:
return None
class ThemeList(object):
"""
This is a small container around Theme.list_themes() that can be used
to cycle through all of the available themes.
"""
def __init__(self):
self.themes = None
self.errors = None
def reload(self):
"""
This acts as a lazy load, it won't read all of the theme files from
disk until the first time somebody tries to access the theme list.
"""
self.themes, self.errors = Theme.list_themes()
def _step(self, theme, direction):
"""
Traverse the list in the given direction and return the next theme
"""
if not self.themes:
self.reload()
# Try to find the starting index
key = (theme.source, theme.name)
for i, val in enumerate(self.themes):
if (val.source, val.name) == key:
index = i
break
else:
# If the theme was set from a custom source it might
# not be a part of the list returned by list_themes().
self.themes.insert(0, theme)
index = 0
index = (index + direction) % len(self.themes)
new_theme = self.themes[index]
return new_theme
def next(self, theme):
return self._step(theme, 1)
def previous(self, theme):
return self._step(theme, -1)

View File

@@ -0,0 +1,70 @@
# Black ansi_235
# White ansi_253
# Sky Blue ansi_81
# Bluish Green ansi_36
# Yellow ansi_227
# Blue ansi_32
# Vermillion ansi_202
# Reddish Purple ansi_175
[theme]
;<element> = <foreground> <background> <attributes>
Normal = ansi_253 ansi_235 normal
Selected = - ansi_236 normal
SelectedCursor = - - reverse
TitleBar = ansi_81 - bold+reverse
OrderBar = ansi_227 - bold
OrderBarHighlight = ansi_227 - bold+reverse
HelpBar = ansi_81 - bold+reverse
Prompt = ansi_81 - bold+reverse
NoticeInfo = - - bold
NoticeLoading = - - bold
NoticeError = - - bold
NoticeSuccess = - - bold
CursorBlock = - - -
CursorBar1 = ansi_175 - -
CursorBar2 = ansi_81 - -
CursorBar3 = ansi_36 - -
CursorBar4 = ansi_227 - -
CommentAuthor = ansi_32 - bold
CommentAuthorSelf = ansi_36 - bold
CommentCount = - - -
CommentText = - - -
Created = - - -
Downvote = ansi_202 - bold
Gold = ansi_227 - bold
HiddenCommentExpand = - - bold
HiddenCommentText = - - -
MultiredditName = ansi_227 - bold
MultiredditText = - - -
NeutralVote = - - bold
NSFW = ansi_202 - bold+reverse
Saved = ansi_36 - -
Hidden = ansi_227 - -
Score = - - -
Separator = - - bold
Stickied = ansi_36 - -
SubscriptionName = ansi_227 - bold
SubscriptionText = - - -
SubmissionAuthor = ansi_36 - bold
SubmissionFlair = ansi_202 - -
SubmissionSubreddit = ansi_227 - -
SubmissionText = - - -
SubmissionTitle = - - bold
SubmissionTitleSeen = - - -
Upvote = ansi_36 - bold
Link = ansi_32 - underline
LinkSeen = ansi_175 - underline
UserFlair = ansi_227 - bold
New = ansi_227 - bold
Distinguished = ansi_202 - bold
MessageSubject = ansi_32 - bold
MessageLink = ansi_175 - bold
MessageAuthor = ansi_36 - bold
MessageSubreddit = ansi_227 - -
MessageText = - - -

View File

@@ -0,0 +1,59 @@
[theme]
;<element> = <foreground> <background> <attributes>
Normal = default default normal
Selected = default default normal
SelectedCursor = default default reverse
TitleBar = cyan - bold+reverse
OrderBar = yellow - bold
OrderBarHighlight = yellow - bold+reverse
HelpBar = cyan - bold+reverse
Prompt = cyan - bold+reverse
NoticeInfo = - - bold
NoticeLoading = - - bold
NoticeError = - - bold
NoticeSuccess = - - bold
CursorBlock = - - -
CursorBar1 = magenta - -
CursorBar2 = cyan - -
CursorBar3 = green - -
CursorBar4 = yellow - -
CommentAuthor = blue - bold
CommentAuthorSelf = green - bold
CommentCount = - - -
CommentText = - - -
Created = - - -
Downvote = red - bold
Gold = yellow - bold
HiddenCommentExpand = - - bold
HiddenCommentText = - - -
MultiredditName = yellow - bold
MultiredditText = - - -
NeutralVote = - - bold
NSFW = red - bold+reverse
Saved = green - -
Hidden = yellow - -
Score = - - -
Separator = - - bold
Stickied = green - -
SubscriptionName = yellow - bold
SubscriptionText = - - -
SubmissionAuthor = green - bold
SubmissionFlair = red - -
SubmissionSubreddit = yellow - -
SubmissionText = - - -
SubmissionTitle = - - bold
SubmissionTitleSeen = - - -
Upvote = green - bold
Link = blue - underline
LinkSeen = magenta - underline
UserFlair = yellow - bold
New = red - bold
Distinguished = red - bold
MessageSubject = blue - bold
MessageLink = magenta - bold
MessageAuthor = green - bold
MessageSubreddit = yellow - -
MessageText = - - -

82
tuir/themes/molokai.cfg Normal file
View File

@@ -0,0 +1,82 @@
# https://github.com/tomasr/molokai
# normal ansi_252, ansi_234
# line number ansi_239, ansi_235
# cursor ansi_252, ansi_236
# pmenusel ansi_255, ansi_242
# text - normal ansi_252
# text - dim ansi_244
# text - ultra dim ansi_241
# purple ansi_141
# green ansi_154
# magenta ansi_199, ansi_16
# gold ansi_222, ansi_233
# red ansi_197
# red - dim ansi_203
# orange ansi_208
# blue ansi_81
# blue - dim ansi_67, ansi_16
[theme]
;<element> = <foreground> <background> <attributes>
Normal = ansi_252 ansi_234 normal
Selected = ansi_252 ansi_236 normal
SelectedCursor = ansi_252 ansi_234 bold+reverse
TitleBar = ansi_81 - bold+reverse
OrderBar = ansi_244 ansi_235 -
OrderBarHighlight = ansi_244 ansi_235 bold+reverse
HelpBar = ansi_81 - bold+reverse
Prompt = ansi_208 - bold+reverse
NoticeInfo = - - bold
NoticeLoading = - - bold
NoticeError = ansi_199 - bold
NoticeSuccess = ansi_154 - bold
CursorBlock = ansi_252 - -
CursorBar1 = ansi_141 - -
CursorBar2 = ansi_197 - -
CursorBar3 = ansi_154 - -
CursorBar4 = ansi_208 - -
CommentAuthor = ansi_81 - -
CommentAuthorSelf = ansi_154 - -
CommentCount = - - -
CommentText = - - -
Created = - - -
Downvote = ansi_197 - bold
Gold = ansi_222 - bold
HiddenCommentExpand = ansi_244 - bold
HiddenCommentText = ansi_244 - -
MultiredditName = - - bold
MultiredditText = ansi_244 - -
NeutralVote = - - bold
NSFW = ansi_197 - bold+reverse
Saved = ansi_199 - -
Hidden = ansi_208 - -
Score = - - bold
Separator = ansi_241 - bold
Stickied = ansi_208 - -
SubscriptionName = - - bold
SubscriptionText = ansi_244 - -
SubmissionAuthor = ansi_154 - -
SubmissionFlair = ansi_197 - -
SubmissionSubreddit = ansi_222 - -
SubmissionText = - - -
SubmissionTitle = - - bold
SubmissionTitleSeen = - - -
Upvote = ansi_154 - bold
Link = ansi_67 - underline
LinkSeen = ansi_141 - underline
UserFlair = ansi_222 - bold
New = ansi_208 - bold
Distinguished = ansi_197 - bold
MessageSubject = ansi_81 - bold
MessageLink = ansi_199 - bold
MessageAuthor = ansi_154 - bold
MessageSubreddit = ansi_222 - -
MessageText = - - -

View File

@@ -0,0 +1,80 @@
# https://github.com/NLKNguyen/papercolor-theme
# background ansi_255
# negative ansi_124
# positive ansi_28
# olive ansi_64
# neutral ansi_31
# comment ansi_102
# navy ansi_24
# foreground ansi_238
# nontext ansi_250
# red ansi_160
# pink ansi_162
# purple ansi_91
# accent ansi_166
# orange ansi_166
# blue ansi_25
# highlight ansi_24
# aqua ansi_31
# green ansi_28
[theme]
;<element> = <foreground> <background> <attributes>
Normal = ansi_238 ansi_255 normal
Selected = ansi_238 ansi_254 normal
SelectedCursor = ansi_238 ansi_255 bold+reverse
TitleBar = ansi_24 - bold+reverse
OrderBar = ansi_25 - bold
OrderBarHighlight = ansi_25 - bold+reverse
HelpBar = ansi_24 - bold+reverse
Prompt = ansi_31 - bold+reverse
NoticeInfo = ansi_238 ansi_252 bold
NoticeLoading = ansi_238 ansi_252 bold
NoticeError = ansi_124 ansi_225 bold
NoticeSuccess = ansi_28 ansi_157 bold
CursorBlock = ansi_102 - -
CursorBar1 = ansi_162 - -
CursorBar2 = ansi_166 - -
CursorBar3 = ansi_25 - -
CursorBar4 = ansi_91 - -
CommentAuthor = ansi_25 - bold
CommentAuthorSelf = ansi_64 - bold
CommentCount = - - -
CommentText = - - -
Created = - - -
Downvote = ansi_124 - bold
Gold = ansi_166 - bold
HiddenCommentExpand = ansi_102 - bold
HiddenCommentText = ansi_102 - -
MultiredditName = - - bold
MultiredditText = ansi_102 - -
NeutralVote = - - bold
NSFW = ansi_160 - bold+reverse
Saved = ansi_31 - bold
Hidden = ansi_166 - bold
Score = - - bold
Separator = - - bold
Stickied = ansi_166 - bold
SubscriptionName = - - bold
SubscriptionText = ansi_102 - -
SubmissionAuthor = ansi_64 - bold
SubmissionFlair = ansi_162 - bold
SubmissionSubreddit = ansi_166 - bold
SubmissionText = - - -
SubmissionTitle = - - bold
SubmissionTitleSeen = - - -
Upvote = ansi_28 - bold
Link = ansi_24 - underline
LinkSeen = ansi_91 - underline
UserFlair = ansi_162 - bold
New = ansi_162 - bold
Distinguished = ansi_160 - bold
MessageSubject = ansi_91 - bold
MessageLink = ansi_162 - bold
MessageAuthor = ansi_28 - bold
MessageSubreddit = ansi_166 - bold
MessageText = - - -

View File

@@ -0,0 +1,78 @@
# http://ethanschoonover.com/solarized
# base3 ansi_230
# base2 ansi_254
# base1 ansi_245 (optional emphasized content)
# base0 ansi_244 (body text / primary content)
# base00 ansi_241
# base01 ansi_240 (comments / secondary content)
# base02 ansi_235 (background highlights)
# base03 ansi_234 (background)
# yellow ansi_136
# orange ansi_166
# red ansi_160
# magenta ansi_125
# violet ansi_61
# blue ansi_33
# cyan ansi_37
# green ansi_64
[theme]
;<element> = <foreground> <background> <attributes>
Normal = ansi_244 ansi_234 normal
Selected = ansi_244 ansi_235 normal
SelectedCursor = ansi_244 ansi_235 bold+reverse
TitleBar = ansi_37 - bold+reverse
OrderBar = ansi_245 - bold
OrderBarHighlight = ansi_245 - bold+reverse
HelpBar = ansi_37 - bold+reverse
Prompt = ansi_33 - bold+reverse
NoticeInfo = - - bold
NoticeLoading = - - bold
NoticeError = ansi_160 - bold
NoticeSuccess = ansi_64 - bold
CursorBlock = ansi_240 - -
CursorBar1 = ansi_125 - -
CursorBar2 = ansi_160 - -
CursorBar3 = ansi_61 - -
CursorBar4 = ansi_37 - -
CommentAuthor = ansi_33 - bold
CommentAuthorSelf = ansi_64 - bold
CommentCount = - - -
CommentText = - - -
Created = - - -
Downvote = ansi_160 - bold
Gold = ansi_136 - bold
HiddenCommentExpand = ansi_240 - bold
HiddenCommentText = ansi_240 - -
MultiredditName = ansi_245 - bold
MultiredditText = ansi_240 - -
NeutralVote = - - bold
NSFW = ansi_160 - bold+reverse
Saved = ansi_125 - -
Hidden = ansi_136 - -
Score = - - -
Separator = - - bold
Stickied = ansi_136 - -
SubscriptionName = ansi_245 - bold
SubscriptionText = ansi_240 - -
SubmissionAuthor = ansi_64 - bold
SubmissionFlair = ansi_160 - -
SubmissionSubreddit = ansi_166 - -
SubmissionText = - - -
SubmissionTitle = ansi_245 - bold
SubmissionTitleSeen = - - -
Upvote = ansi_64 - bold
Link = ansi_33 - underline
LinkSeen = ansi_61 - underline
UserFlair = ansi_136 - bold
New = ansi_136 - bold
Distinguished = ansi_160 - bold
MessageSubject = ansi_37 - bold
MessageLink = ansi_125 - bold
MessageAuthor = ansi_64 - bold
MessageSubreddit = ansi_136 - -
MessageText = - - -

View File

@@ -0,0 +1,78 @@
# http://ethanschoonover.com/solarized
# base03 ansi_234
# base02 ansi_235
# base01 ansi_240 (optional emphasized content)
# base00 ansi_241 (body text / primary content)
# base0 ansi_244
# base1 ansi_245 (comments / secondary content)
# base2 ansi_254 (background highlights)
# base3 ansi_230 (background)
# yellow ansi_136
# orange ansi_166
# red ansi_160
# magenta ansi_125
# violet ansi_61
# blue ansi_33
# cyan ansi_37
# green ansi_64
[theme]
;<element> = <foreground> <background> <attributes>
Normal = ansi_241 ansi_230 normal
Selected = ansi_241 ansi_254 normal
SelectedCursor = ansi_241 ansi_254 bold+reverse
TitleBar = ansi_37 - bold+reverse
OrderBar = ansi_245 - bold
OrderBarHighlight = ansi_245 - bold+reverse
HelpBar = ansi_37 - bold+reverse
Prompt = ansi_33 - bold+reverse
NoticeInfo = - - bold
NoticeLoading = - - bold
NoticeError = ansi_160 - bold
NoticeSuccess = ansi_64 - bold
CursorBlock = ansi_245 - -
CursorBar1 = ansi_125 - -
CursorBar2 = ansi_160 - -
CursorBar3 = ansi_61 - -
CursorBar4 = ansi_37 - -
CommentAuthor = ansi_33 - bold
CommentAuthorSelf = ansi_64 - bold
CommentCount = - - -
CommentText = - - -
Created = - - -
Downvote = ansi_160 - bold
Gold = ansi_136 - bold
HiddenCommentExpand = ansi_245 - bold
HiddenCommentText = ansi_245 - -
MultiredditName = ansi_240 - bold
MultiredditText = ansi_245 - -
NeutralVote = - - bold
NSFW = ansi_160 - bold+reverse
Saved = ansi_125 - bold
Hidden = ansi_136 - bold
Score = - - -
Separator = - - bold
Stickied = ansi_136 - bold
SubscriptionName = ansi_240 - bold
SubscriptionText = ansi_245 - -
SubmissionAuthor = ansi_64 - bold
SubmissionFlair = ansi_160 - bold
SubmissionSubreddit = ansi_166 - bold
SubmissionText = - - -
SubmissionTitle = ansi_240 - bold
SubmissionTitleSeen = - - -
Upvote = ansi_64 - bold
Link = ansi_33 - underline
LinkSeen = ansi_61 - underline
UserFlair = ansi_136 - bold
New = ansi_227 - bold
Distinguished = ansi_202 - bold
MessageSubject = ansi_32 - bold
MessageLink = ansi_175 - bold
MessageAuthor = ansi_36 - bold
MessageSubreddit = ansi_166 - -
MessageText = - - -