Merge pull request #179 from michael-lazar/default_config

Default config
This commit is contained in:
Michael Lazar
2015-12-20 00:17:47 -08:00
11 changed files with 208 additions and 111 deletions

View File

@@ -4,4 +4,5 @@ include CONTRIBUTORS.rst
include README.rst include README.rst
include LICENSE include LICENSE
include rtv.1 include rtv.1
include rtv/templates/index.html include rtv/rtv.cfg
include rtv/templates/*

View File

@@ -50,7 +50,7 @@ The installation will place a script in the system path
$ rtv $ rtv
$ rtv --help $ rtv --help
See the `FAQ`_ for more information on common installation problems See the `FAQ`_ to troubleshoot common installation problems
===== =====
Usage Usage
@@ -166,7 +166,7 @@ RTV uses OAuth to facilitate logging into your reddit user account [#]_. The log
1. You initiate a login by pressing the ``u`` key. 1. You initiate a login by pressing the ``u`` key.
2. You're redirected to a webbrowser where reddit will ask you to login and authorize RTV. 2. You're redirected to a webbrowser where reddit will ask you to login and authorize RTV.
3. RTV uses the generated token to login on your behalf. 3. RTV uses the generated token to login on your behalf.
4. The token is stored on your computer at ``~/.config/rtv/refresh-token`` for future sessions. You can disable this behavior by setting ``persistent=False`` in your RTV config. 4. The token is stored on your computer at ``{HOME}/.config/rtv/refresh-token`` for future sessions. You can disable this behavior by setting ``persistent=False`` in your RTV config.
Note that RTV no longer allows you to input your username/password directly. This method of cookie based authentication has been deprecated by reddit and will not be supported in future releases [#]_. Note that RTV no longer allows you to input your username/password directly. This method of cookie based authentication has been deprecated by reddit and will not be supported in future releases [#]_.
@@ -177,34 +177,14 @@ Note that RTV no longer allows you to input your username/password directly. Thi
Config File Config File
----------- -----------
RTV will read a configuration placed at ``~/.config/rtv/rtv.cfg`` (or ``$XDG_CONFIG_HOME``). RTV stores configuration settings in ``{HOME}/.config/rtv/rtv.cfg``.
Each line in the file will replace the corresponding default argument in the launch script. You can auto-generate the config file by running
This can be used to avoid having to re-enter login credentials every time the program is launched.
Example initial config: .. code-block:: bash
**rtv.cfg** $ rtv --copy-config
.. code-block:: ini
[rtv]
# Log file location
log=/tmp/rtv.log
# Default subreddit
subreddit=CollegeBasketball
# Default submission link - will be opened every time the program starts
# link=http://www.reddit.com/r/CollegeBasketball/comments/31irjq
# Turn on ascii-only mode and disable all unicode characters
# This may be necessary for compatibility with some terminal browsers
# ascii=True
# Enable persistent storage of your authentication token
# This allows you to remain logged in when you restart the program
persistent=True
See the `default config <https://github.com/michael-lazar/rtv/blob/master/rtv.cfg>`_ to view descriptions for each setting.
=== ===
FAQ FAQ

View File

@@ -9,7 +9,7 @@ import praw
import tornado import tornado
from . import docs from . import docs
from .config import Config from .config import Config, copy_default_config
from .oauth import OAuthHelper from .oauth import OAuthHelper
from .terminal import Terminal from .terminal import Terminal
from .objects import curses_session from .objects import curses_session
@@ -38,11 +38,18 @@ def main():
title = 'rtv {0}'.format(__version__) title = 'rtv {0}'.format(__version__)
sys.stdout.write('\x1b]2;{0}\x07'.format(title)) sys.stdout.write('\x1b]2;{0}\x07'.format(title))
# Attempt to load from the config file first, and then overwrite with any args = Config.get_args()
# provided command line arguments. fargs = Config.get_file(args.get('config'))
# Apply the file config first, then overwrite with any command line args
config = Config() config = Config()
config.from_file() config.update(**fargs)
config.from_args() config.update(**args)
# Copy the default config file and quit
if config['copy_config']:
copy_default_config()
return
# Load the browsing history from previous sessions # Load the browsing history from previous sessions
config.load_history() config.load_history()

View File

@@ -3,20 +3,23 @@ from __future__ import unicode_literals
import os import os
import codecs import codecs
import shutil
import argparse import argparse
from functools import partial
import six
from six.moves import configparser from six.moves import configparser
from . import docs, __version__ from . import docs, __version__
HOME = os.path.expanduser('~')
PACKAGE = os.path.dirname(__file__) PACKAGE = os.path.dirname(__file__)
HOME = os.path.expanduser('~')
TEMPLATE = os.path.join(PACKAGE, 'templates')
DEFAULT_CONFIG = os.path.join(PACKAGE, 'rtv.cfg')
XDG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config')) XDG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config'))
CONFIG = os.path.join(XDG_HOME, 'rtv', 'rtv.cfg') CONFIG = os.path.join(XDG_HOME, 'rtv', 'rtv.cfg')
TOKEN = os.path.join(XDG_HOME, 'rtv', 'refresh-token') TOKEN = os.path.join(XDG_HOME, 'rtv', 'refresh-token')
HISTORY = os.path.join(XDG_HOME, 'rtv', 'history.log') HISTORY = os.path.join(XDG_HOME, 'rtv', 'history.log')
TEMPLATE = os.path.join(PACKAGE, 'templates')
def build_parser(): def build_parser():
@@ -33,22 +36,50 @@ def build_parser():
parser.add_argument( parser.add_argument(
'-l', dest='link', '-l', dest='link',
help='full URL of a submission that will be opened on start') help='full URL of a submission that will be opened on start')
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( parser.add_argument(
'--ascii', action='store_const', const=True, '--ascii', action='store_const', const=True,
help='enable ascii-only mode') help='enable ascii-only mode')
parser.add_argument(
'--log', metavar='FILE', action='store',
help='log HTTP requests to a file')
parser.add_argument( parser.add_argument(
'--non-persistent', dest='persistent', action='store_const', '--non-persistent', dest='persistent', action='store_const',
const=False, const=False,
help='Forget all authenticated users when the program exits') help='Forget the authenticated user when the program exits')
parser.add_argument( parser.add_argument(
'--clear-auth', dest='clear_auth', action='store_const', const=True, '--clear-auth', dest='clear_auth', action='store_const', const=True,
help='Remove any saved OAuth tokens before starting') 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/rtv/rtv.cfg')
return parser return parser
def copy_default_config(filename=CONFIG):
"""
Copy the default configuration file to the user's {HOME}/.config/rtv
"""
if os.path.exists(filename):
try:
ch = six.moves.input(
'File %s already exists, overwrite? y/[n]):' % filename)
if ch not in ('Y', 'y'):
return
except KeyboardInterrupt:
return
filepath = os.path.dirname(filename)
if not os.path.exists(filepath):
os.makedirs(filepath)
print('Copying default settings to %s' % filename)
shutil.copy(DEFAULT_CONFIG, filename)
class OrderedSet(object): class OrderedSet(object):
""" """
A simple implementation of an ordered set. A set is used to check A simple implementation of an ordered set. A set is used to check
@@ -76,36 +107,12 @@ class OrderedSet(object):
class Config(object): class Config(object):
DEFAULT = { def __init__(self, history_file=HISTORY, token_file=TOKEN, **kwargs):
'ascii': False,
'persistent': True,
'clear_auth': False,
'log': None,
'link': None,
'subreddit': 'front',
'history_size': 200,
# https://github.com/reddit/reddit/wiki/OAuth2
# Client ID is of type "installed app" and the secret should be empty
'oauth_client_id': 'E2oEtRQfdfAfNQ',
'oauth_client_secret': 'praw_gapfill',
'oauth_redirect_uri': 'http://127.0.0.1:65000/',
'oauth_redirect_port': 65000,
'oauth_scope': [
'edit', 'history', 'identity', 'mysubreddits', 'privatemessages',
'read', 'report', 'save', 'submit', 'subscribe', 'vote'],
'template_path': TEMPLATE,
}
def __init__(self,
config_file=CONFIG,
history_file=HISTORY,
token_file=TOKEN,
**kwargs):
self.config_file = config_file
self.history_file = history_file self.history_file = history_file
self.token_file = token_file self.token_file = token_file
self.config = kwargs self.config = kwargs
self.default = self.get_file(DEFAULT_CONFIG)
# `refresh_token` and `history` are saved/loaded at separate locations, # `refresh_token` and `history` are saved/loaded at separate locations,
# so they are treated differently from the rest of the config options. # so they are treated differently from the rest of the config options.
@@ -113,7 +120,10 @@ class Config(object):
self.history = OrderedSet() self.history = OrderedSet()
def __getitem__(self, item): def __getitem__(self, item):
return self.config.get(item, self.DEFAULT.get(item)) if item in self.config:
return self.config[item]
else:
return self.default.get(item, None)
def __setitem__(self, key, value): def __setitem__(self, key, value):
self.config[key] = value self.config[key] = value
@@ -124,33 +134,6 @@ class Config(object):
def update(self, **kwargs): def update(self, **kwargs):
self.config.update(kwargs) self.config.update(kwargs)
def from_args(self):
parser = build_parser()
args = vars(parser.parse_args())
# Filter out argument values that weren't supplied
args = {key: val for key, val in args.items() if val is not None}
self.update(**args)
def from_file(self):
config = configparser.ConfigParser()
if os.path.exists(self.config_file):
with codecs.open(self.config_file, encoding='utf-8') as fp:
config.readfp(fp)
config_dict = {}
if config.has_section('rtv'):
config_dict = dict(config.items('rtv'))
# Convert 'true'/'false' to boolean True/False
if 'ascii' in config_dict:
config_dict['ascii'] = config.getboolean('rtv', 'ascii')
if 'clear_auth' in config_dict:
config_dict['clear_auth'] = config.getboolean('rtv', 'clear_auth')
if 'persistent' in config_dict:
config_dict['persistent'] = config.getboolean('rtv', 'persistent')
self.update(**config_dict)
def load_refresh_token(self): def load_refresh_token(self):
if os.path.exists(self.token_file): if os.path.exists(self.token_file):
with open(self.token_file) as fp: with open(self.token_file) as fp:
@@ -185,6 +168,54 @@ class Config(object):
os.remove(self.history_file) os.remove(self.history_file)
self.history = OrderedSet() self.history = OrderedSet()
@staticmethod
def get_args():
"""
Load settings from the command line.
"""
parser = build_parser()
args = vars(parser.parse_args())
# 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 rtv 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.readfp(fp)
return cls._parse_rtv_file(config)
@staticmethod
def _parse_rtv_file(config):
out = {}
if config.has_section('rtv'):
out = dict(config.items('rtv'))
params = {
'ascii': partial(config.getboolean, 'rtv'),
'clear_auth': partial(config.getboolean, 'rtv'),
'persistent': partial(config.getboolean, 'rtv'),
'history_size': partial(config.getint, 'rtv'),
'oauth_redirect_port': partial(config.getint, 'rtv'),
'oauth_scope': lambda x: out[x].split(',')
}
for key, func in params.items():
if key in out:
out[key] = func(key)
return out
@staticmethod @staticmethod
def _ensure_filepath(filename): def _ensure_filepath(filename):
""" """

View File

@@ -26,6 +26,7 @@ Basic Commands
`j/k` or `UP/DOWN` : Move the cursor up/down `j/k` or `UP/DOWN` : Move the cursor up/down
`m/n` or `PgUp/PgDn`: Jump to the previous/next page `m/n` or `PgUp/PgDn`: Jump to the previous/next page
`o` or `ENTER` : Open the selected item as a webpage `o` or `ENTER` : Open the selected item as a webpage
`1`-`5` : Toggle post order
`r` or `F5` : Refresh page content `r` or `F5` : Refresh page content
`u` : Log in or switch accounts `u` : Log in or switch accounts
`?` : Show the help screen `?` : Show the help screen

View File

@@ -7,6 +7,8 @@ import uuid
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from tornado import gen, ioloop, web, httpserver from tornado import gen, ioloop, web, httpserver
from .config import TEMPLATE
class OAuthHandler(web.RequestHandler): class OAuthHandler(web.RequestHandler):
""" """
@@ -54,7 +56,7 @@ class OAuthHelper(object):
kwargs = {'display': self.term.display, 'params': self.params} kwargs = {'display': self.term.display, 'params': self.params}
routes = [('/', OAuthHandler, kwargs)] routes = [('/', OAuthHandler, kwargs)]
self.callback_app = web.Application( self.callback_app = web.Application(
routes, template_path=self.config['template_path']) routes, template_path=TEMPLATE)
self.reddit.set_oauth_app_info( self.reddit.set_oauth_app_info(
self.config['oauth_client_id'], self.config['oauth_client_id'],

52
rtv/rtv.cfg Normal file
View File

@@ -0,0 +1,52 @@
; Reddit Terminal Viewer Configuration File
; https://github.com/michael-lazar/rtv
;
; This file should be placed in $XDG_CONFIG/rtv/rtv.cfg
; If $XDG_CONFIG is not set, use ~/.config/rtv/rtv.cfg
[rtv]
##################
# General Settings
##################
; Turn on ascii-only mode to disable all unicode characters.
; This may be necessary for compatibility with some terminal browsers.
ascii = False
; Enable debugging by logging all HTTP requests and errors to the given file.
;log = /tmp/rtv.log
; Default subreddit that will be opened when the program launches.
subreddit = front
;subreddit = python
;subreddit = python+linux+programming
;subreddit = all
; Allow rtv to store reddit authentication credentials between sessions.
persistent = 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
################
# OAuth Settings
################
; This sections defines the paramaters that will be used during the OAuth
; authentication process. rtv 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 = E2oEtRQfdfAfNQ
oauth_client_secret = praw_gapfill
oauth_redirect_uri = http://127.0.0.1:65000/
; Port that the rtv 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

View File

@@ -56,8 +56,8 @@ def main():
data['copyright'] = rtv.__copyright__ data['copyright'] = rtv.__copyright__
# Escape dashes is all of the sections # Escape dashes is all of the sections
data = {k: v.replace('-', r'\-') for k, v in data.items()} data = {k: v.replace('-', r'\-') for k, v in data.items()}
print('Reading from %s/rtv/templates/rtv.1.template' % ROOT) print('Reading from %s/rtv/scripts/rtv.1.template' % ROOT)
with open(os.path.join(ROOT, 'rtv/templates/rtv.1.template')) as fp: with open(os.path.join(ROOT, 'rtv/scripts/rtv.1.template')) as fp:
template = fp.read() template = fp.read()
print('Populating template') print('Populating template')
out = template.format(**data) out = template.format(**data)

View File

@@ -20,7 +20,7 @@ setuptools.setup(
license='MIT', license='MIT',
keywords='reddit terminal praw curses', keywords='reddit terminal praw curses',
packages=['rtv'], packages=['rtv'],
package_data={'rtv': ['templates/index.html']}, package_data={'rtv': ['templates/*', 'rtv.cfg']},
data_files=[("share/man/man1", ["rtv.1"])], data_files=[("share/man/man1", ["rtv.1"])],
extras_require={ extras_require={
':python_version=="2.6" or python_version=="2.7"': ['futures']}, ':python_version=="2.6" or python_version=="2.7"': ['futures']},

View File

@@ -5,7 +5,7 @@ import os
import codecs import codecs
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from rtv.config import Config from rtv.config import Config, copy_default_config, DEFAULT_CONFIG
try: try:
from unittest import mock from unittest import mock
@@ -13,6 +13,17 @@ except ImportError:
import mock import mock
def test_copy_default_config():
"Make sure the default config file was included in the package"
with NamedTemporaryFile(suffix='.cfg') as fp:
with mock.patch('rtv.config.six.moves.input', return_value='y'):
copy_default_config(fp.name)
assert fp.read()
permissions = os.stat(fp.name).st_mode & 0o777
assert permissions == 0o664
def test_config_interface(): def test_config_interface():
"Test setting and removing values" "Test setting and removing values"
@@ -20,40 +31,50 @@ def test_config_interface():
assert config['ascii'] is True assert config['ascii'] is True
config['ascii'] = False config['ascii'] = False
assert config['ascii'] is False assert config['ascii'] is False
config['ascii'] = True config['ascii'] = None
assert config['ascii'] is None
del config['ascii'] del config['ascii']
assert config['ascii'] is False assert config['ascii'] is False
config.update(subreddit='cfb', new_value=2.0) config.update(subreddit='cfb', new_value=2.0)
assert config['subreddit'] == 'cfb' assert config['subreddit'] == 'cfb'
assert config['new_value'] == 2.0 assert config['new_value'] == 2.0
assert config['link'] is None
assert config['log'] is None
def test_config_from_args():
def test_config_get_args():
"Ensure that command line arguments are parsed properly" "Ensure that command line arguments are parsed properly"
args = ['rtv', args = ['rtv',
'-s', 'cfb', '-s', 'cfb',
'-l', 'https://reddit.com/permalink •', '-l', 'https://reddit.com/permalink •',
'--log', 'logfile.log', '--log', 'logfile.log',
'--config', 'configfile.cfg',
'--ascii', '--ascii',
'--non-persistent', '--non-persistent',
'--clear-auth'] '--clear-auth',
'--copy-config']
with mock.patch('sys.argv', ['rtv']): with mock.patch('sys.argv', ['rtv']):
config = Config() config_dict = Config.get_args()
config.from_args() config = Config(**config_dict)
assert config.config == {} assert config.config == {}
with mock.patch('sys.argv', args): with mock.patch('sys.argv', args):
config = Config() config_dict = Config.get_args()
config.from_args()
config = Config(**config_dict)
assert config['ascii'] is True assert config['ascii'] is True
assert config['subreddit'] == 'cfb' assert config['subreddit'] == 'cfb'
assert config['link'] == 'https://reddit.com/permalink •'
assert config['log'] == 'logfile.log' assert config['log'] == 'logfile.log'
assert config['ascii'] is True assert config['ascii'] is True
assert config['persistent'] is False assert config['persistent'] is False
assert config['clear_auth'] is True assert config['clear_auth'] is True
assert config['link'] == 'https://reddit.com/permalink •'
assert config['config'] == 'configfile.cfg'
assert config['copy_config'] is True
def test_config_from_file(): def test_config_from_file():
@@ -68,15 +89,17 @@ def test_config_from_file():
'subreddit': 'cfb'} 'subreddit': 'cfb'}
with NamedTemporaryFile(suffix='.cfg') as fp: with NamedTemporaryFile(suffix='.cfg') as fp:
config = Config(config_file=fp.name)
config.from_file() fargs = Config.get_file(filename=fp.name)
config = Config(**fargs)
assert config.config == {} assert config.config == {}
rows = ['{0}={1}'.format(key, val) for key, val in args.items()] rows = ['{0}={1}'.format(key, val) for key, val in args.items()]
data = '\n'.join(['[rtv]'] + rows) data = '\n'.join(['[rtv]'] + rows)
fp.write(codecs.encode(data, 'utf-8')) fp.write(codecs.encode(data, 'utf-8'))
fp.flush() fp.flush()
config.from_file() fargs = Config.get_file(filename=fp.name)
config.update(**fargs)
assert config.config == args assert config.config == args