diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7164f19..71e46f7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,7 @@ RTV Changelog ============= +.. _1.5: http://github.com/michael-lazar/rtv/releases/tag/v1.5 .. _1.4.2: http://github.com/michael-lazar/rtv/releases/tag/v1.4.2 .. _1.4.1: http://github.com/michael-lazar/rtv/releases/tag/v1.4.1 .. _1.4: http://github.com/michael-lazar/rtv/releases/tag/v1.4 @@ -10,6 +11,19 @@ RTV Changelog .. _1.2.1: http://github.com/michael-lazar/rtv/releases/tag/v1.2.1 .. _1.2: http://github.com/michael-lazar/rtv/releases/tag/v1.2 +----------------- +1.5_ (2015-08-26) +----------------- +Features + +* New page to view and open subscribed subreddits with `s`. +* Sorting method can now be toggled with the `1` - `5` keys. +* Links to x-posts are now opened inside of RTV. + +Bugfixes + +* Added */r/* to subreddit names in the subreddit view. + ------------------- 1.4.2_ (2015-08-01) ------------------- diff --git a/README.rst b/README.rst index f2394a7..17dd810 100644 --- a/README.rst +++ b/README.rst @@ -46,6 +46,13 @@ The installation will place a script in the system path $ rtv $ rtv --help +If you're having issues running RTV with Python 2, run RTV as module : + +.. code-block:: bash + + $ cd /path/to/rtv + $ python2 -m rtv + ===== Usage ===== @@ -63,7 +70,7 @@ Basic Commands :``j``/``k`` or ``▲``/``▼``: Move the cursor up/down :``m``/``n`` or ``PgUp``/``PgDn``: Jump to the previous/next page -:``o`` or ``ENTER``: Open the selected item as a webpage +:``1-5``: Toggle post order (*hot*, *top*, *rising*, *new*, *controversial*) :``r`` or ``F5``: Refresh page content :``u``: Log in or switch accounts :``?``: Show the help screen @@ -84,7 +91,8 @@ Once you are logged in your username will appear in the top-right corner of the :``c``: Compose a new post or comment :``e``: Edit an existing post or comment :``d``: Delete an existing post or comment -:``s``: Open/close subscribed subreddits list +:``i``: Display new messages prompt +:``s``: View a list of subscribed subreddits -------------- Subreddit Mode @@ -93,6 +101,7 @@ Subreddit Mode In subreddit mode you can browse through the top submissions on either the front page or a specific subreddit. :``l`` or ``►``: Enter the selected submission +:``o`` or ``ENTER``: Open the submission link with your web browser :``/``: Open a prompt to switch subreddits :``f``: Open a prompt to search the current subreddit @@ -111,6 +120,7 @@ Submission Mode In submission mode you can view the self text for a submission and browse comments. :``h`` or ``◄``: Return to the subreddit +:``o`` or ``ENTER``: Open the comment permalink with your web browser :``SPACE``: Fold the selected comment, or load additional comments ============= @@ -149,19 +159,14 @@ Config File ----------- RTV will read a configuration placed at ``~/.config/rtv/rtv.cfg`` (or ``$XDG_CONFIG_HOME``). -Each line in the file will replace the corresponding default argument in the launch script. +Each line in the files will replace the corresponding default argument in the launch script. This can be used to avoid having to re-enter login credentials every time the program is launched. -The OAuth section contains a boolean to trigger auto-login (defaults to False). -When authenticated, two additional fields are written : **access_token** and **refresh_token**. -Those are basically like username and password : they are used to authenticate you on Reddit servers. - Example initial config: -.. code-block:: ini +**rtv.cfg** - [oauth] - auto_login=False +.. code-block:: ini [rtv] # Log file location @@ -177,6 +182,24 @@ Example initial config: # This may be necessary for compatibility with some terminal browsers # ascii=True +----- +OAuth +----- + +OAuth is an authentication standard, that replaces authentication with login and password. + +RTV implements OAuth. It stores OAuth configuration at ``~/.config/rtv/oauth.cfg``(or ``$XDG_CONFIG_HOME``). +**The OAuth configuration file must be writable, and is created automatically if it doesn't exist.** +It contains a boolean to trigger auto-login (defaults to false). +When authenticated, an additional field is written : **refresh_token**. +This acts as a replacement to username and password : it is used to authenticate you on Reddit servers. + +Example **oauth.cfg**: + +.. code-block:: ini + + [oauth] + auto_login=false ========= Changelog diff --git a/rtv/__main__.py b/rtv/__main__.py index 881feff..0096031 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -22,7 +22,13 @@ from tornado import ioloop __all__ = [] -def get_config_fp(): +def load_rtv_config(): + """ + Attempt to load saved settings for things like the username and password. + """ + + config = configparser.ConfigParser() + HOME = os.path.expanduser('~') XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config')) @@ -35,30 +41,9 @@ def get_config_fp(): # get the first existing config file for config_path in config_paths: if os.path.exists(config_path): + config.read(config_path) break - return config_path - -def open_config(): - """ - Search for a configuration file at the location ~/.rtv and attempt to load - saved settings for things like the username and password. - """ - - config = configparser.ConfigParser() - - config_path = get_config_fp() - config.read(config_path) - - return config - -def load_rtv_config(): - """ - Attempt to load saved settings for things like the username and password. - """ - - config = open_config() - defaults = {} if config.has_section('rtv'): defaults = dict(config.items('rtv')) @@ -73,14 +58,26 @@ def load_oauth_config(): Attempt to load saved OAuth settings """ - config = open_config() + config = configparser.ConfigParser() + + HOME = os.path.expanduser('~') + XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME', + os.path.join(HOME, '.config')) + + if os.path.exists(os.path.join(XDG_CONFIG_HOME, 'rtv')): + config_path = os.path.join(XDG_CONFIG_HOME, 'rtv', 'oauth.cfg') + else: + config_path = os.path.join(HOME, '.rtv-oauth') + + config.read(config_path) if config.has_section('oauth'): defaults = dict(config.items('oauth')) else: # Populate OAuth section - config['oauth'] = {'auto_login': False} - with open(get_config_fp(), 'w') as cfg: + config.add_section('oauth') + config.set('oauth', 'auto_login', 'false') + with open(config_path, 'w') as cfg: config.write(cfg) defaults = dict(config.items('oauth')) @@ -106,7 +103,6 @@ def command_line(): oauth_group = parser.add_argument_group('OAuth data (optional)', OAUTH) oauth_group.add_argument('--auto-login', dest='auto_login', help='OAuth auto-login setting') - oauth_group.add_argument('--auth-token', dest='access_token', help='OAuth authorization token') oauth_group.add_argument('--refresh-token', dest='refresh_token', help='OAuth refresh token') args = parser.parse_args() @@ -154,7 +150,7 @@ def main(): reddit.config.decode_html_entities = False with curses_session() as stdscr: oauth = OAuthTool(reddit, stdscr, LoadScreen(stdscr)) - if args.auto_login == 'True': # Ew! + if args.auto_login == 'true': # Ew! oauth.authorize() if args.link: page = SubmissionPage(stdscr, reddit, oauth, url=args.link) diff --git a/rtv/__version__.py b/rtv/__version__.py index 98d186b..fcb6b5d 100644 --- a/rtv/__version__.py +++ b/rtv/__version__.py @@ -1 +1 @@ -__version__ = '1.4.2' +__version__ = '1.5' diff --git a/rtv/docs.py b/rtv/docs.py index cefd7b0..a4cba11 100644 --- a/rtv/docs.py +++ b/rtv/docs.py @@ -18,8 +18,8 @@ given, the program will display a secure prompt to enter a password. """ OAUTH = """\ -Authentication is now done by OAuth, since PRAW will stop supporting login with -username and password soon. +Authentication is now done by OAuth, since PRAW will drop +password authentication soon. """ CONTROLS = """ @@ -47,6 +47,7 @@ Authenticated Commands `c` : Compose a new post or comment `e` : Edit an existing post or comment `d` : Delete an existing post or comment + `i` : Display new messages prompt `s` : Open/close subscribed subreddits list Subreddit Mode diff --git a/rtv/oauth.py b/rtv/oauth.py index 0d3029c..6a51ada 100644 --- a/rtv/oauth.py +++ b/rtv/oauth.py @@ -1,4 +1,3 @@ -from six.moves import configparser import curses import logging import os @@ -7,17 +6,17 @@ import uuid import webbrowser import praw +from six.moves import configparser from . import config from .curses_helpers import show_notification, prompt_input -from tornado import ioloop, web +from tornado import gen, ioloop, web, httpserver +from concurrent.futures import ThreadPoolExecutor __all__ = ['token_validity', 'OAuthTool'] _logger = logging.getLogger(__name__) -token_validity = 3540 - oauth_state = None oauth_code = None oauth_error = None @@ -29,6 +28,9 @@ class HomeHandler(web.RequestHandler): class AuthHandler(web.RequestHandler): + def initialize(self): + self.compact = os.environ.get('BROWSER') in ['w3m', 'links', 'elinks', 'lynx'] + def get(self): global oauth_state global oauth_code @@ -40,7 +42,9 @@ class AuthHandler(web.RequestHandler): self.render('auth.html', state=oauth_state, code=oauth_code, error=oauth_error) - ioloop.IOLoop.current().stop() + # Stop IOLoop if using BackgroundBrowser (or GUI browser) + if not self.compact: + ioloop.IOLoop.current().stop() class OAuthTool(object): @@ -59,34 +63,30 @@ class OAuthTool(object): self.redirect_uri = redirect_uri or config.oauth_redirect_uri self.scope = scope or config.oauth_scope.split('-') - self.access_info = {} - self.token_expiration = 0 + # Terminal web browser + self.compact = os.environ.get('BROWSER') in ['w3m', 'links', 'elinks', 'lynx'] - # Initialize Tornado webapp and listen on port 65000 + # Initialize Tornado webapp self.callback_app = web.Application([ (r'/', HomeHandler), (r'/auth', AuthHandler), ], template_path='rtv/templates') - self.callback_app.listen(65000) + + self.http_server = None def get_config_fp(self): HOME = os.path.expanduser('~') XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config')) - config_paths = [ - os.path.join(XDG_CONFIG_HOME, 'rtv', 'rtv.cfg'), - os.path.join(HOME, '.rtv') - ] + if os.path.exists(os.path.join(XDG_CONFIG_HOME, 'rtv')): + file_path = os.path.join(XDG_CONFIG_HOME, 'rtv', 'oauth.cfg') + else: + file_path = os.path.join(HOME, '.rtv-oauth') - # get the first existing config file - for config_path in config_paths: - if os.path.exists(config_path): - break - - return config_path + return file_path def open_config(self, update=False): if self.config_fp is None: @@ -100,48 +100,50 @@ class OAuthTool(object): with open(self.config_fp, 'w') as cfg: self.config.write(cfg) - def set_token_expiration(self): - self.token_expiration = time.time() + token_validity + def clear_oauth_data(self): + self.open_config(update=True) + if self.config.has_section('oauth') and self.config.has_option('oauth', 'refresh_token'): + self.config.remove_option('oauth', 'refresh_token') + self.save_config() - def token_expired(self): - return time.time() > self.token_expiration + @gen.coroutine + def open_terminal_browser(self, url): + with ThreadPoolExecutor(max_workers=1) as executor: + yield executor.submit(webbrowser.open_new_tab, url) - def refresh(self, force=False): - if self.token_expired() or force: - try: - with self.loader(message='Refreshing token'): - new_access_info = self.reddit.refresh_access_information( - self.config['oauth']['refresh_token']) - self.access_info = new_access_info - self.reddit.set_access_credentials(scope=set(self.access_info['scope']), - access_token=self.access_info['access_token'], - refresh_token=self.access_info['refresh_token']) - self.set_token_expiration() - except (praw.errors.OAuthAppRequired, praw.errors.OAuthInvalidToken, - praw.errors.HTTPException) as e: - show_notification(self.stdscr, ['Invalid OAuth data']) - else: - self.config['oauth']['access_token'] = self.access_info['access_token'] - self.config['oauth']['refresh_token'] = self.access_info['refresh_token'] - self.save_config() + ioloop.IOLoop.current().stop() def authorize(self): + if self.compact and not '.compact' in self.reddit.config.API_PATHS['authorize']: + self.reddit.config.API_PATHS['authorize'] += '.compact' + self.reddit.set_oauth_app_info(self.client_id, self.client_secret, self.redirect_uri) self.open_config(update=True) # If no previous OAuth data found, starting from scratch - if 'oauth' not in self.config or 'access_token' not in self.config['oauth']: + if not self.config.has_section('oauth') or not self.config.has_option('oauth', 'refresh_token'): + if self.http_server is None: + self.http_server = httpserver.HTTPServer(self.callback_app) + self.http_server.listen(65000) + # Generate a random UUID hex_uuid = uuid.uuid4().hex permission_ask_page_link = self.reddit.get_authorize_url(str(hex_uuid), scope=self.scope, refreshable=True) - with self.loader(message='Waiting for authorization'): - webbrowser.open(permission_ask_page_link) + if self.compact: + show_notification(self.stdscr, ['Opening ' + os.environ.get('BROWSER')]) + curses.endwin() + ioloop.IOLoop.current().add_callback(self.open_terminal_browser, permission_ask_page_link) ioloop.IOLoop.current().start() + curses.doupdate() + else: + with self.loader(message='Waiting for authorization'): + webbrowser.open(permission_ask_page_link) + ioloop.IOLoop.current().start() global oauth_state global oauth_code @@ -169,21 +171,15 @@ class OAuthTool(object): with self.loader(message='Logging in'): # Get access information (tokens and scopes) self.access_info = self.reddit.get_access_information(self.final_code) - - self.reddit.set_access_credentials( - scope=set(self.access_info['scope']), - access_token=self.access_info['access_token'], - refresh_token=self.access_info['refresh_token']) - self.set_token_expiration() except (praw.errors.OAuthAppRequired, praw.errors.OAuthInvalidToken) as e: show_notification(self.stdscr, ['Invalid OAuth data']) else: - if 'oauth' not in self.config: - self.config['oauth'] = {} + if not self.config.has_section('oauth'): + self.config.add_section('oauth') - self.config['oauth']['access_token'] = self.access_info['access_token'] - self.config['oauth']['refresh_token'] = self.access_info['refresh_token'] + self.config.set('oauth', 'refresh_token', self.access_info['refresh_token']) self.save_config() # Otherwise, fetch new access token else: - self.refresh(force=True) + with self.loader(message='Logging in'): + self.reddit.refresh_access_information(self.config.get('oauth', 'refresh_token')) diff --git a/rtv/page.py b/rtv/page.py index 8f13565..34a5467 100644 --- a/rtv/page.py +++ b/rtv/page.py @@ -314,9 +314,6 @@ class BasePage(object): @BaseController.register('a') def upvote(self): - # Refresh access token if expired - self.oauth.refresh() - data = self.content.get(self.nav.absolute_index) try: if 'likes' not in data: @@ -332,9 +329,6 @@ class BasePage(object): @BaseController.register('z') def downvote(self): - # Refresh access token if expired - self.oauth.refresh() - data = self.content.get(self.nav.absolute_index) try: if 'likes' not in data: @@ -358,6 +352,7 @@ class BasePage(object): if self.reddit.is_oauth_session(): self.reddit.clear_authentication() + self.oauth.clear_oauth_data() return self.oauth.authorize() @@ -372,9 +367,6 @@ class BasePage(object): show_notification(self.stdscr, ['Not logged in']) return - # Refresh access token if expired - self.oauth.refresh() - data = self.content.get(self.nav.absolute_index) if data.get('author') != self.reddit.user.name: curses.flash() @@ -403,9 +395,6 @@ class BasePage(object): show_notification(self.stdscr, ['Not logged in']) return - # Refresh access token if expired - self.oauth.refresh() - data = self.content.get(self.nav.absolute_index) if data.get('author') != self.reddit.user.name: curses.flash() @@ -440,9 +429,6 @@ class BasePage(object): Checks the inbox for unread messages and displays a notification. """ - # Refresh access token if expired - self.oauth.refresh() - inbox = len(list(self.reddit.get_unread(limit=1))) try: if inbox > 0: diff --git a/rtv/submission.py b/rtv/submission.py index 1086ce6..f5aba23 100644 --- a/rtv/submission.py +++ b/rtv/submission.py @@ -93,9 +93,6 @@ class SubmissionPage(BasePage): show_notification(self.stdscr, ['Not logged in']) return - # Refresh access token if expired - self.oauth.refresh() - data = self.content.get(self.nav.absolute_index) if data['type'] == 'Submission': content = data['text'] @@ -131,9 +128,6 @@ class SubmissionPage(BasePage): def delete_comment(self): "Delete a comment as long as it is not the current submission" - # Refresh access token if expired - self.oauth.refresh() - if self.nav.absolute_index != -1: self.delete() else: diff --git a/rtv/subreddit.py b/rtv/subreddit.py index fcd1f07..9359d1e 100644 --- a/rtv/subreddit.py +++ b/rtv/subreddit.py @@ -8,7 +8,7 @@ import requests from .exceptions import SubredditError, AccountError from .page import BasePage, Navigator, BaseController from .submission import SubmissionPage -from .subscriptions import SubscriptionPage +from .subscription import SubscriptionPage from .content import SubredditContent from .helpers import open_browser, open_editor, strip_subreddit_url from .docs import SUBMISSION_FILE @@ -54,9 +54,6 @@ class SubredditPage(BasePage): def refresh_content(self, name=None, order=None): "Re-download all submissions and reset the page index" - # Refresh access token if expired - self.oauth.refresh() - name = name or self.content.name order = order or self.content.order @@ -136,9 +133,6 @@ class SubredditPage(BasePage): show_notification(self.stdscr, ['Not logged in']) return - # Refresh access token if expired - self.oauth.refresh() - # Strips the subreddit to just the name # Make sure it is a valid subreddit for submission subreddit = self.reddit.get_subreddit(self.content.name) @@ -180,9 +174,6 @@ class SubredditPage(BasePage): show_notification(self.stdscr, ['Not logged in']) return - # Refresh access token if expired - self.oauth.refresh() - # Open subscriptions page page = SubscriptionPage(self.stdscr, self.reddit, self.oauth) page.loop() diff --git a/rtv/subscriptions.py b/rtv/subscription.py similarity index 97% rename from rtv/subscriptions.py rename to rtv/subscription.py index 64e3a2a..0361427 100644 --- a/rtv/subscriptions.py +++ b/rtv/subscription.py @@ -38,9 +38,6 @@ class SubscriptionPage(BasePage): def refresh_content(self): "Re-download all subscriptions and reset the page index" - # Refresh access token if expired - self.oauth.refresh() - self.content = SubscriptionContent.from_user(self.reddit, self.loader) self.nav = Navigator(self.content.get) diff --git a/setup.py b/setup.py index 38d0d08..f00c25d 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,14 @@ from setuptools import setup from version import __version__ as version +import sys + +requirements = ['tornado', 'praw>=3.1.0', 'six', 'requests', 'kitchen'] + +# Python 2: add required concurrent.futures backport from Python 3.2 +if sys.version_info.major <= 2: + requirements.append('futures') + setup( name='rtv', version=version, @@ -13,7 +21,7 @@ setup( keywords='reddit terminal praw curses', packages=['rtv'], include_package_data=True, - install_requires=['tornado', 'praw>=3.1.0', 'six', 'requests', 'kitchen'], + install_requires=requirements, entry_points={'console_scripts': ['rtv=rtv.__main__:main']}, classifiers=[ 'Intended Audience :: End Users/Desktop',