Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

Merge branch 'develop'

  • Loading branch information...
commit 46a33e7f0f353f34fc4d5c52a9041a9a7788592b 2 parents e0b2cab + cb4e724
Alejandro Gómez authored
View
6 .travis.yml
@@ -0,0 +1,6 @@
+language: python
+python:
+ - "2.7"
+# dependencies
+install: pip install -r requirements/dev.txt --use-mirrors
+script: nosetests --nocapture --with-coverage --cover-package=turses
View
1  AUTHORS
@@ -3,3 +3,4 @@ Authors
* Nicolas Paris <http://github.com/Nic0>
* Alejandro Gómez <http://github.com/alejandrogomez>
+* Sascha Kruse <http://github.com/knopwob>
View
6 HISTORY.rst
@@ -1,3 +1,9 @@
+0.1.0
+-----
+- binding to open focused status authors' tweets
+- reload configuration
+- configuration default location and format changed
+
0.0.15
------
- bugfix: DM recipient was not correctly resolved
View
4 Makefile
@@ -1,5 +1,5 @@
APPNAME=turses
-VERSION=0.0.15
+VERSION=0.1.0
DISTPKG=dist/$(APPNAME)-$(VERSION).tar.gz
PY=python
@@ -8,7 +8,7 @@ PIPI=pip install
PIPFLAGS=--ignore-installed --no-deps
TESTRUNNER=nosetests
-TESTFLAGS=--with-progressive --logging-clear-handlers --with-coverage --cover-package=turses
+TESTFLAGS=--nocapture --with-progressive --logging-clear-handlers --with-coverage --cover-package=turses
WATCHTESTFLAGS=--verbosity=0
View
9 README.rst
@@ -1,9 +1,8 @@
turses: a Twitter client featuring a curses interface
=====================================================
-
-A Twitter client with a sexy curses interface written in Python. Various parts of
-the codebase are borrowed from the `Tyrs`_ project by `Nicolas Paris`_.
+``turses`` is a Twitter client with a sexy curses interface written in Python. Various
+parts of the codebase are borrowed from the `Tyrs`_ project by `Nicolas Paris`_.
.. _`Tyrs`: http://tyrs.nicosphere.net
.. _`Nicolas Paris`: http://github.com/Nic0
@@ -65,8 +64,10 @@ Any feedback is very much appreciated.
Roadmap
-------
- - Lists
- Documentation
+ - Lists
+ - Streaming
+ - Notifications
- Geo
- Blocking
View
6 setup.py
@@ -21,16 +21,15 @@
long_description = ""
setup(name="turses",
- version=turses.__version__,
+ version=turses.version,
author="Alejandro Gómez",
author_email="alejandroogomez@gmail.com",
- license="GPLv3",
+ url="http://github.com/alejandrogomez/turses",
description="A Twitter client with a curses interface.",
long_description=long_description,
keywords="twitter client curses",
packages=[
"turses",
- "turses.ui",
"turses.api",
],
package_data={'': ['LICENSE']},
@@ -48,6 +47,7 @@
"Natural Language :: English",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 2.7",
+ "Topic :: Communications"
],
install_requires=requirements,
tests_require=test_requirements,)
View
132 tests/test_config.py
@@ -0,0 +1,132 @@
+###############################################################################
+# coding=utf-8 #
+# Copyright (c) 2012 turses contributors. See AUTHORS. #
+# Licensed under the GPL License. See LICENSE for full details. #
+###############################################################################
+
+import unittest
+from mock import Mock
+from os.path import join
+from sys import path
+path.append('../')
+
+from turses.config import (
+ PALETTE,
+
+ CONFIG_PATH,
+ DEFAULT_CONFIG_FILE,
+ DEFAULT_TOKEN_FILE,
+
+ validate_color,
+ Configuration,
+)
+
+
+class Args(object):
+ """
+ Represents the arguments.
+ """
+ def __init__(self,
+ account=None,
+ config=None,
+ generate_config=None):
+ self.account = account
+ self.config = config
+ self.generate_config = generate_config
+
+
+class ConfigurationTest(unittest.TestCase):
+ """Tests for `turses.config.Configuration`."""
+
+ def test_palette(self):
+ """Test that every color in the default `PALETTE` is valid."""
+ for label in list(PALETTE):
+ # ignore the label name
+ for color in label[1:]:
+ if color:
+ self.assertTrue(validate_color(color))
+
+ def test_defaults(self):
+ """Test that defaults get loaded correctly."""
+ config = Configuration()
+ self.assertEqual(config.config_file, DEFAULT_CONFIG_FILE)
+ self.assertEqual(config.token_file, DEFAULT_TOKEN_FILE)
+
+ def test_parse_config_file(self):
+ pass
+
+ def test_parse_token_file(self):
+ pass
+
+ def test_parse_legacy_config_file(self):
+ pass
+
+ def test_parse_legacy_token_file(self):
+ pass
+
+ def test_set_color(self):
+ """Test `Configuration._set_color`."""
+ config = Configuration()
+
+ palette = [
+ ['first', 'cyan', 'black', 'default', ''],
+ ['second', 'green', 'black',]
+ ]
+ modified_color = ['first', 'black', 'cyan', 'default', '']
+ palette[0] = modified_color
+ label, fg, bg = modified_color[:3]
+
+ config.palette = list(palette)
+ config._set_color(label, fg, bg)
+
+ self.assertEqual(palette, config.palette)
+
+ config._set_color('idontexist', fg, bg)
+ self.assertEqual(palette, config.palette)
+
+ def test_set_key_binding(self):
+ """Test `Configuration._set_key_binding`."""
+ config = Configuration()
+
+ key_bindings = {
+ 'quit': ('q', 'Quit the program'),
+ 'help': ('h', 'Show help')
+ }
+
+ config.key_bindings = key_bindings.copy()
+ # swap the key bindings
+ config._set_key_binding('quit', 'h')
+ config._set_key_binding('help', 'q')
+ swapped_key_bindings = {
+ 'quit': ('h', 'Quit the program'),
+ 'help': ('q', 'Show help')
+ }
+
+ self.assertEqual(swapped_key_bindings, config.key_bindings)
+
+ config._set_key_binding('idontexist', '~')
+ self.assertEqual(swapped_key_bindings, config.key_bindings)
+
+ def test_args_account(self):
+ account = 'bob'
+ args = Args(account=account)
+ config = Configuration(args)
+ token_path = join(CONFIG_PATH, "%s.token" % account)
+ self.assertEqual(token_path, config.token_file)
+
+ def test_args_generate_config(self):
+ config_path = '~/.turses/custom_config'
+ args = Args(generate_config=config_path)
+ ConfigurationMock = Mock(Configuration)
+ config = ConfigurationMock(args)
+ config.generate_config_file.assert_called_once()
+
+ def test_args_config(self):
+ config_path = '/path/to/custom/config/file'
+ args = Args(config=config_path)
+ config = Configuration(args)
+ self.assertEqual(config_path, config.config_file)
+
+
+if __name__ == '__main__':
+ unittest.main()
View
208 tests/test_models.py
@@ -12,27 +12,174 @@
from mock import MagicMock
from turses.models import (
- ActiveList,
+ prepend_at,
+
+ is_DM,
+ get_mentioned_usernames,
+ get_mentioned_for_reply,
+ get_dm_recipients_username,
+ get_authors_username,
+
+ is_username,
+ is_hashtag,
+ sanitize_username,
+
Status,
+ DirectMessage,
+
+ ActiveList,
Timeline,
TimelineList,
- VisibleTimelineList
+ VisibleTimelineList,
)
#
# Helpers
#
-def create_status(id, datetime):
- return Status(id=id,
- created_at=datetime,
- user='test',
- text='Test',)
+def create_status(**kwargs):
+ now = datetime.now()
+ defaults = {
+ 'id': 1,
+ 'created_at': now,
+ 'user': 'testbot',
+ 'text': 'Status created at %s' % now,
+ }
+ defaults.update(kwargs)
+
+ return Status(**defaults)
+
+def create_direct_message(**kwargs):
+ now = datetime.now()
+ defaults = {
+ 'id': 1,
+ 'created_at': now,
+ 'sender_screen_name': 'Alice',
+ 'recipient_screen_name': 'Bob',
+ 'text': 'Direct Message at %s' % now,
+ }
+ defaults.update(kwargs)
+
+ return DirectMessage(**defaults)
-# TODO
class HelperFunctionTest(unittest.TestCase):
- pass
-
+ def test_is_DM(self):
+ # status is NOT a DM
+ status = create_status()
+ self.failIf(is_DM(status))
+
+ dm = create_direct_message()
+ self.failUnless(is_DM(dm))
+
+ def test_get_mentioned_usernames(self):
+ user = 'turses'
+ mentioned = ('dialelo', 'mental_floss', '4n_4Wfu1_US3RN4M3')
+
+ expected_output = list(mentioned)
+
+ text = "@%s, @%s and @%s" % mentioned
+ status = create_status(user=user,
+ text=text)
+
+ expected = set(expected_output)
+ mentioned_usernames = get_mentioned_usernames(status)
+ self.assertEqual(expected, set(mentioned_usernames))
+
+ def test_get_mentioned_for_reply(self):
+ user = 'turses'
+ mentioned = ('dialelo', 'mental_floss', '4n_4Wfu1_US3RN4M3')
+
+ expected_output = list(mentioned)
+ expected_output.append(user)
+ expected_output = map(prepend_at, expected_output)
+
+ text = "@%s, @%s and @%s" % mentioned
+ status = create_status(user=user,
+ text=text)
+
+ expected = set(filter(prepend_at, expected_output))
+ mentioned_for_reply = get_mentioned_for_reply(status)
+ self.assertEqual(expected, set(mentioned_for_reply))
+
+ def test_get_authors_username(self):
+ user = 'turses'
+
+ # tweet
+ status = create_status(user=user)
+ author = get_authors_username(status)
+ self.assertEqual(user, author)
+
+ # retweet
+ retweeter = 'bot'
+ retweet = create_status(user=retweeter,
+ is_retweet=True,
+ author=user)
+ author = get_authors_username(retweet)
+ self.assertEqual(user, author)
+
+ # direct message
+ dm = create_direct_message(sender_screen_name=user,)
+ author = get_authors_username(dm)
+ self.assertEqual(user, author)
+
+ def test_get_dm_recipients_username(self):
+ # authenticating user
+ user = 'turses'
+
+ # given a status in which the author is the authenticated author
+ # must return `None`
+ status = create_status(user=user)
+ recipient_own_tweet = get_dm_recipients_username(user, status)
+ self.failIf(recipient_own_tweet)
+
+ # @user -> @another_user messages should return 'another_user'
+ expected_recipient = 'dialelo'
+ dm = create_direct_message(sender_screen_name=user,
+ recipient_screen_name=expected_recipient,)
+ recipient_dm_user_is_sender = get_dm_recipients_username(user, dm)
+ self.assertEqual(recipient_dm_user_is_sender, expected_recipient)
+
+ # @another_user -> @user messages should return 'another_user'
+ dm = create_direct_message(sender_screen_name=expected_recipient,
+ recipient_screen_name=user,)
+ recipient_dm_user_is_recipient = get_dm_recipients_username(user, dm)
+ self.assertEqual(recipient_dm_user_is_recipient, expected_recipient)
+
+ def test_is_username(self):
+ valid = ['dialelo', 'mental_floss', '4n_4Wfu1_US3RN4M3']
+ for user in valid:
+ self.failUnless(is_username(user))
+
+ # FIXME
+ #invalid = ['-asd', 'adsd?']
+ #for user in invalid:
+ #self.failIf(is_username(user))
+
+ def test_is_hashtag(self):
+ valid = ['#turses', '#cúrcuma', '#4n_4Wfu1_US3RN4M3']
+ for hashtag in valid:
+ self.failUnless(is_hashtag(hashtag))
+ # TODO: test invalid hashtags
+
+ def test_sanitize_username(self):
+ dirty_and_clean = [
+ ('@dialelo', 'dialelo'),
+ ('dialelo', 'dialelo'),
+ ('?@mental_floss', 'mental_floss'),
+ ('@4n_4Wfu1_US3RN4M3', '4n_4Wfu1_US3RN4M3'),
+ ]
+ for dirty, clean in dirty_and_clean:
+ sanitized = sanitize_username(dirty)
+ self.assertEqual(sanitized, clean)
+
+ def test_get_hashtags(self):
+ pass
+
+ def test_is_valid_status_text(self):
+ pass
+
+ def test_is_valid_search_text(self):
+ pass
# TODO
class StatusTest(unittest.TestCase):
@@ -69,7 +216,7 @@ def setUp(self):
def test_unique_statuses_in_timeline(self):
self.assertEqual(len(self.timeline), 0)
# create and add the status
- status = create_status(1, datetime.now())
+ status = create_status()
self.timeline.add_status(status)
self.assertEqual(len(self.timeline), 1)
# check that adding more than once does not duplicate element
@@ -77,7 +224,7 @@ def test_unique_statuses_in_timeline(self):
self.assertEqual(len(self.timeline), 1)
def test_active_index_becomes_0_when_adding_first_status(self):
- status = create_status(1, datetime.now())
+ status = create_status()
self.timeline.add_status(status)
self.assertEqual(self.timeline.active_index, 0)
# check that adding than once does not move the active
@@ -85,8 +232,8 @@ def test_active_index_becomes_0_when_adding_first_status(self):
self.assertEqual(self.timeline.active_index, 0)
def test_insert_different_statuses(self):
- old_status = create_status(1, datetime(1988, 12, 19))
- new_status = create_status(2, datetime.now())
+ old_status = create_status(created_at=datetime(1988, 12, 19))
+ new_status = create_status(id=2)
self.timeline.add_statuses([old_status, new_status])
self.assertEqual(len(self.timeline), 2)
@@ -101,38 +248,40 @@ def test_active_is_the_same_when_inserting_statuses(self):
"""
Test that when inserting new statuses the active doesn't change.
"""
- active_status = create_status(1, datetime(1988, 12, 19))
+ active_status = create_status(created_at=datetime(1988, 12, 19))
self.timeline.add_status(active_status)
self.assert_active(active_status)
- older_status = create_status(2, datetime(1978, 12, 19))
+ older_status = create_status(id=2,
+ created_at=datetime(1978, 12, 19))
self.timeline.add_status(older_status)
self.assert_active(active_status)
- newer_status = create_status(2, datetime.now())
+ newer_status = create_status(id=2)
self.timeline.add_status(newer_status)
self.assert_active(active_status)
def test_insert_different_statuses_individually(self):
- old_status = create_status(1, datetime(1988, 12, 19))
- new_status = create_status(2, datetime.now())
+ old_status = create_status(created_at=datetime(1988, 12, 19))
+ new_status = create_status(id=2)
self.timeline.add_status(old_status)
self.assertEqual(len(self.timeline), 1)
self.timeline.add_status(new_status)
self.assertEqual(len(self.timeline), 2)
def test_statuses_ordered_reversely_by_date(self):
- old_status = create_status(1, datetime(1988, 12, 19))
- new_status = create_status(2, datetime.now())
+ old_status = create_status(created_at=datetime(1988, 12, 19))
+ new_status = create_status(id=2)
self.timeline.add_statuses([old_status, new_status])
self.assertEqual(self.timeline[0], new_status)
self.assertEqual(self.timeline[1], old_status)
def test_get_newer_than(self):
old_created_at = datetime(1988, 12, 19)
- old_status = create_status(1, old_created_at)
+ old_status = create_status(created_at=old_created_at)
new_created_at = datetime.now()
- new_status = create_status(2, new_created_at)
+ new_status = create_status(id=2,
+ created_at=new_created_at)
self.timeline.add_statuses([old_status, new_status])
# get newers than `old_status`
newers = self.timeline.get_newer_than(old_created_at)
@@ -144,9 +293,10 @@ def test_get_newer_than(self):
def test_clear(self):
old_created_at = datetime(1988, 12, 19)
- old_status = create_status(1, old_created_at)
+ old_status = create_status(created_at=old_created_at)
new_created_at = datetime.now()
- new_status = create_status(2, new_created_at)
+ new_status = create_status(id=2,
+ created_at=new_created_at)
self.timeline.add_statuses([old_status, new_status])
self.timeline.clear()
self.assertEqual(len(self.timeline), 0)
@@ -156,14 +306,14 @@ def test_clear(self):
# update function related
- def test_extract_update_args_and_kwargs_no_args(self):
+ def test_extract_with_no_args(self):
mock = MagicMock(name='update')
timeline = Timeline(update_function=mock,)
self.assertEqual(timeline.update_function_args, None)
self.assertEqual(timeline.update_function_kwargs, None)
- def test_extract_update_args_and_kwargs_only_args(self):
+ def test_extract_with_only_args(self):
mock = MagicMock(name='update')
args = 'python', 42
timeline = Timeline(update_function=mock,
@@ -172,7 +322,7 @@ def test_extract_update_args_and_kwargs_only_args(self):
self.assertEqual(timeline.update_function_args, list(args))
self.assertEqual(timeline.update_function_kwargs, None)
- def test_extract_update_args_and_kwargs_only_kwargs(self):
+ def test_extract_with_only_kwargs(self):
mock = MagicMock(name='update')
kwargs = {'python': 'rocks'}
timeline = Timeline(update_function=mock,
@@ -181,7 +331,7 @@ def test_extract_update_args_and_kwargs_only_kwargs(self):
self.assertEqual(timeline.update_function_args, None)
self.assertEqual(timeline.update_function_kwargs, kwargs)
- def test_extract_update_args_and_kwargs_only_kwargs(self):
+ def test_extract_with_both_args_and_kwargs(self):
mock = MagicMock(name='update')
args = 'python', 42
kwargs = {'python': 'rocks'}
View
3  turses/__init__.py
@@ -13,5 +13,6 @@
__author__ = "Alejandro Gómez"
__copyright__ = "Copyright 2012 turses contributors"
__license__ = "GPL3"
+__version__ = (0, 1, 0)
-__version__ = "0.0.15"
+version = "%s.%s.%s" % __version__
View
85 turses/api/base.py
@@ -10,14 +10,89 @@
It also contains an asynchronous wrapper to `Api`.
"""
+import oauth2 as oauth
from threading import Thread
+from urlparse import parse_qsl
+from gettext import gettext as _
-from ..decorators import wrap_exceptions
from ..models import is_DM
+from ..utils import encode, wrap_exceptions
-twitter_consumer_key = 'OEn4hrNGknVz9ozQytoR0A'
-twitter_consumer_secret = 'viud49uVgdVO9dnOGxSQJRo7jphTioIlEn3OdpkZI'
+TWITTER_CONSUMER_KEY = 'OEn4hrNGknVz9ozQytoR0A'
+TWITTER_CONSUMER_SECRET = 'viud49uVgdVO9dnOGxSQJRo7jphTioIlEn3OdpkZI'
+
+BASE_URL = 'https://api.twitter.com'
+
+def authorization():
+ """
+ Authorize `turses` to use a Twitter account.
+
+ Return a dictionary with `oauth_token` and `oauth_token_secret`
+ if succesfull, `None` otherwise.
+ """
+ # This function is borrowed from python-twitter developers
+
+ # Copyright 2007 The Python-Twitter Developers
+ #
+ # Licensed under the Apache License, Version 2.0 (the "License");
+ # you may not use this file except in compliance with the License.
+ # You may obtain a copy of the License at
+ #
+ # http://www.apache.org/licenses/LICENSE-2.0
+ #
+ # Unless required by applicable law or agreed to in writing, software
+ # distributed under the License is distributed on an "AS IS" BASIS,
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ # See the License for the specific language governing permissions and
+ # limitations under the License.
+ print 'base_url:{0}'.format(BASE_URL)
+
+
+ REQUEST_TOKEN_URL = BASE_URL + '/oauth/request_token'
+ ACCESS_TOKEN_URL = BASE_URL + '/oauth/access_token'
+ AUTHORIZATION_URL = BASE_URL + '/oauth/authorize'
+ consumer_key = TWITTER_CONSUMER_KEY
+ consumer_secret = TWITTER_CONSUMER_SECRET
+ oauth_consumer = oauth.Consumer(key=consumer_key, secret=consumer_secret)
+ oauth_client = oauth.Client(oauth_consumer)
+
+ print encode(_('Requesting temp token from Twitter'))
+
+ resp, content = oauth_client.request(REQUEST_TOKEN_URL, 'GET')
+
+ if resp['status'] != '200':
+ print encode(_('Invalid respond, requesting temp token: %s')) % str(resp['status'])
+ return
+
+ request_token = dict(parse_qsl(content))
+
+ print ''
+ print encode(_('Please visit the following page to retrieve needed pin code'))
+ print encode(_('to obtain an Authentication Token:'))
+ print ''
+ print '%s?oauth_token=%s' % (AUTHORIZATION_URL, request_token['oauth_token'])
+ print ''
+
+ pincode = raw_input('Pin code? ')
+
+ token = oauth.Token(request_token['oauth_token'], request_token['oauth_token_secret'])
+ token.set_verifier(pincode)
+
+ print ''
+ print encode(_('Generating and signing request for an access token'))
+ print ''
+
+ oauth_client = oauth.Client(oauth_consumer, token)
+ resp, content = oauth_client.request(ACCESS_TOKEN_URL, method='POST', body='oauth_verifier=%s' % pincode)
+ access_token = dict(parse_qsl(content))
+
+ if resp['status'] == '200':
+ return access_token
+ else:
+ print 'response:{0}'.format(resp['status'])
+ print encode(_('Request for access token failed: %s')) % resp['status']
+ return None
class Api(object):
@@ -29,8 +104,8 @@ class Api(object):
def __init__(self,
access_token_key,
access_token_secret,
- consumer_key=twitter_consumer_key,
- consumer_secret=twitter_consumer_secret,):
+ consumer_key=TWITTER_CONSUMER_KEY,
+ consumer_secret=TWITTER_CONSUMER_SECRET,):
self._consumer_key = consumer_key
self._consumer_secret = consumer_secret
self._access_token_key = access_token_key
View
11 turses/cli.py
@@ -7,23 +7,26 @@
This module contains the logic to launch `turses` with a curses interface.
"""
+from urwid import set_encoding
+
from .utils import parse_arguments
from .config import Configuration
from .controller import CursesController
-from .constant import palette
-from .ui.curses import CursesInterface
+from .ui import CursesInterface
from .api.backends import TweepyApi
def main():
try:
+ set_encoding('utf8')
+
args = parse_arguments()
configuration = Configuration(args)
+ configuration.load()
ui = CursesInterface(configuration)
# start `turses`
- CursesController(palette=palette,
- configuration=configuration,
+ CursesController(configuration=configuration,
ui=ui,
api_backend=TweepyApi)
except KeyboardInterrupt:
View
824 turses/config.py
@@ -4,314 +4,570 @@
turses.config
~~~~~~~~~~~~~
-This module contains a class for managing the configuration.
+This module contains the configuration logic.
+
+
+There is one mayor configuration file in turses:
+
+ `config`
+ contains user preferences: colors, bindings, etc.
+
+An one default token file:
+
+ `token`
+ contains authentication token for the default user account
+
+Each user account (that is no the default one) has its .token file.
+Keep this secret.
+
+ `<alias>.token`
+ contains the oauth tokens
+
+The standard location is under $HOME directory, in a folder called `.turses`.
+Here is an example with two accounts apart from the default one, aliased
+to `alice` and `bob`.
+
+ ~
+ |+.turses/
+ | |-config
+ | |-token
+ | |-alice.token
+ | `-bob.token
+ |+...
+ |-...
+ `
"""
-import logging
-import oauth2 as oauth
-from curses import ascii
from ConfigParser import RawConfigParser
-from os import environ, path, makedirs, mkdir
+from os import getenv, path, mkdir, remove
from gettext import gettext as _
-from urlparse import parse_qsl
-from . import constant
-from .utils import encode
-from .api.base import twitter_consumer_key, twitter_consumer_secret
+from .utils import encode, wrap_exceptions
+from .api.base import authorization
+
+# -- Defaults -----------------------------------------------------------------
+
+KEY_BINDINGS = {
+ # motion
+ 'up':
+ ('k', _('scroll up')),
+ 'down':
+ ('j', _('scroll down')),
+ 'left':
+ ('h', _('activate the timeline on the left')),
+ 'right':
+ ('l', _('activate the timeline on the right')),
+ 'scroll_to_top':
+ ('g', _('scroll to top')),
+ 'scroll_to_bottom':
+ ('G', _('scroll to bottom')),
+
+ # buffers
+ 'activate_first_buffer':
+ ('a', _('activate first buffer')),
+ 'activate_last_buffer':
+ ('e', _('activate last buffer')),
+ 'shift_buffer_beggining':
+ ('ctrl a', _('shift active buffer to the beginning')),
+ 'shift_buffer_end':
+ ('ctrl e', _('shift active buffer to the end')),
+ 'shift_buffer_left':
+ ('<', _('shift active buffer one position to the left')),
+ 'shift_buffer_right':
+ ('>', _('shift active buffer one position to the right')),
+ 'expand_visible_left':
+ ('p', _('expand visible timelines one column to the left')),
+ 'expand_visible_right':
+ ('n', _('expand visible timelines one column to the right')),
+ 'shrink_visible_left':
+ ('P', _('shrink visible timelines one column from the left')),
+ 'shrink_visible_right':
+ ('N', _('shrink visible timelines one column from the left')),
+ 'delete_buffer':
+ ('d', _('delete buffer')),
+ 'clear':
+ ('c', _('clear status bar')),
+ 'mark_all_as_read':
+ ('A', _('mark all tweets in the current timeline as read')),
+
+ # tweets
+ 'tweet':
+ ('t', _('compose a tweet')),
+ 'delete_tweet':
+ ('X', _('delete focused status')),
+ 'reply':
+ ('r', _('reply to focused status')),
+ 'retweet':
+ ('R', _('retweet focused status')),
+ 'retweet_and_edit':
+ ('E', _('open a editor for manually retweeting the focused status')),
+ 'send_dm':
+ ('D', _('compose a direct message')),
+ 'update':
+ ('u', _('refresh the active timeline')),
+ 'tweet_hashtag':
+ ('H', _('compose a tweet with the same hashtags as the focused status')),
+ 'fav':
+ ('b', _('mark focused tweet as favorite')),
+ 'delete_fav':
+ ('ctrl b', _('remove tweet from favorites')),
+ 'follow_selected':
+ ('f', _('follow selected status\' author')),
+ 'unfollow_selected':
+ ('U', _('unfollow selected status\' author')),
+
+ # timelines
+ 'home':
+ ('.', _('open a home timeline')),
+ 'own_tweets':
+ ('_', _('open a timeline with your tweets')),
+ 'favorites':
+ ('B', _('open a timeline with your favorites')),
+ 'mentions':
+ ('m', _('open a mentions timeline')),
+ 'DMs':
+ ('M', _('open a direct message timeline')),
+ 'search':
+ ('/', _('search for term and show resulting timeline')),
+ 'search_user':
+ ('@', _('open a timeline with the tweets of the specified user')),
+ 'user_timeline':
+ ('+', _('open a timeline with the tweets of the focused status\' author')),
+ 'thread':
+ ('T', _('open the thread of the focused status')),
+ 'hashtags':
+ ('L', _('open a search timeline with the hashtags of the focused status')),
+
+ # meta
+ 'help':
+ ('?', _('show program help')),
+ 'reload_config':
+ ('C', _('reload configuration')),
+
+ # turses
+ 'quit':
+ ('q', _('exit program')),
+ 'openurl':
+ ('o', _('open URLs of the focused status in a browser')),
+ 'redraw':
+ ('ctrl l', _('redraw the screen')),
+}
+
+MOTION_KEY_BINDINGS = [
+ 'up',
+ 'down',
+ 'left',
+ 'right',
+ 'scroll_to_top',
+ 'scroll_to_bottom',
+]
+
+BUFFERS_KEY_BINDINGS = [
+ 'activate_first_buffer',
+ 'activate_last_buffer',
+ 'shift_buffer_beggining',
+ 'shift_buffer_end',
+ 'shift_buffer_left',
+ 'shift_buffer_right',
+ 'expand_visible_left',
+ 'expand_visible_right',
+ 'shrink_visible_left',
+ 'shrink_visible_right',
+ 'delete_buffer',
+ 'clear',
+ 'mark_all_as_read',
+]
+
+TWEETS_KEY_BINDINGS = [
+ 'tweet',
+ 'delete_tweet',
+ 'reply',
+ 'retweet',
+ 'retweet_and_edit',
+ 'send_dm',
+ 'update',
+ 'tweet_hashtag',
+ 'fav',
+ 'delete_fav',
+ 'follow_selected',
+ 'unfollow_selected',
+]
+
+TIMELINES_KEY_BINDINGS = [
+ 'home',
+ 'own_tweets',
+ 'favorites',
+ 'mentions',
+ 'DMs',
+ 'search',
+ 'search_user',
+ 'user_timeline',
+ 'thread',
+ 'hashtags',
+]
+
+META_KEY_BINDINGS = [
+ 'help',
+ 'reload_config',
+]
+
+TURSES_KEY_BINDINGS = [
+ 'quit',
+ 'openurl',
+ 'redraw',
+]
+
+# TODO: not hard coded
+# valid colors for `urwid`s palette
+VALID_COLORS = [
+ 'default',
+ 'black',
+ 'dark red',
+ 'dark green',
+ 'brown',
+ 'dark blue',
+ 'dark magenta',
+ 'dark cyan',
+ 'light gray',
+ 'dark gray',
+ 'light red',
+ 'light green',
+ 'yellow',
+ 'light blue',
+ 'light magenta',
+ 'light cyan',
+ 'white',
+]
+
+def validate_color(colorstring):
+ return colorstring if colorstring in VALID_COLORS else ''
+
+PALETTE = [
+ #Tabs
+ ['active_tab', 'white', 'dark blue'],
+ ['visible_tab', 'yellow', 'dark blue'],
+ ['inactive_tab', 'dark blue', ''],
+
+ # Statuses
+ ['header', 'light blue', ''],
+ ['body', 'white', ''],
+ ['focus', 'light red', ''],
+ ['line', 'black', ''],
+ ['unread', 'dark red', ''],
+ ['read', 'dark blue', ''],
+ ['favorited', 'yellow', ''],
+
+ # Text
+ ['highlight', 'dark red', ''],
+ ['highlight_nick', 'light red', ''],
+ ['attag', 'brown', ''],
+ ['hashtag', 'dark green', ''],
+
+ # Messages
+ ['error', 'white', 'dark red'],
+ ['info', 'white', 'dark blue'],
+
+ # Editor
+ ['editor', 'white', 'dark blue'],
+]
+
+STYLES = {
+ # TODO: make time string configurable
+ 'header_template': ' {username}{retweeted}{retweeter} - {time}{reply} {retweet_count} ',
+ 'dm_template': ' {sender_screen_name} => {recipient_screen_name} - {time} ',
+}
+
+# Debug
+LOGGING_LEVEL = 3
+
+# Environment
+HOME = getenv('HOME')
+BROWSER = getenv('BROWSER')
+
+# -- Configuration ------------------------------------------------------------
+
+# Default config path
+CONFIG_DIR = '.turses'
+CONFIG_PATH = path.join(HOME, CONFIG_DIR)
+DEFAULT_CONFIG_FILE = path.join(CONFIG_PATH, 'config')
+DEFAULT_TOKEN_FILE = path.join(CONFIG_PATH, 'token')
+
+LEGACY_CONFIG_DIR = '.config/turses'
+LEGACY_CONFIG_PATH = path.join(HOME, LEGACY_CONFIG_DIR)
+LEGACY_CONFIG_FILE = path.join(LEGACY_CONFIG_PATH, 'turses.cfg')
+LEGACY_TOKEN_FILE = path.join(LEGACY_CONFIG_PATH, 'turses.tok')
+
+# Names of the sections in the configuration
+SECTION_KEY_BINDINGS = 'bindings'
+SECTION_PALETTE = 'colors'
+SECTION_STYLES = 'styles'
+SECTION_DEBUG = 'debug'
+SECTION_TOKEN = 'token'
+
+
+def print_deprecation_notice():
+ print "NOTE:"
+ print
+ print "The configuration file in %s has been deprecated." % LEGACY_CONFIG_FILE
+ print "A new configuration directory is being created in %s." % CONFIG_PATH
+ print
class Configuration(object):
- """Class responsible for managing the configuration."""
-
- def __init__(self, args):
- self.init_config()
- self.home = environ['HOME']
- self.get_xdg_config()
- self.get_browser()
- # generate the config file
- if args.generate_config != None:
- self.generate_config_file(args.generate_config)
+ """
+ Create and parse configuration files.
+
+ Has backwards compatibility with the Tyrs legacy configuration.
+ """
+
+ def __init__(self, cli_args=None):
+ """
+ Create a `Configuration` taking into account the arguments
+ from the command line interface (if any).
+ """
+ self.load_defaults()
+
+ self.browser = BROWSER
+
+ # create the config directory if it does not exist
+ if not path.isdir(CONFIG_PATH):
+ try:
+ mkdir(CONFIG_PATH)
+ except:
+ print encode(_('Error creating config directory in %s' % CONFIG_DIR))
+ exit(3)
+
+ # generate config file and exit
+ if cli_args and cli_args.generate_config:
+ self.generate_config_file(config_file=cli_args.generate_config,)
exit(0)
- self.set_path(args)
- self.check_for_default_config()
- self.conf = RawConfigParser()
- self.conf.read(self.config_file)
- if not path.isfile(self.token_file):
- self.new_account()
+ if cli_args and cli_args.config:
+ config_file = cli_args.config
+ else:
+ config_file = DEFAULT_CONFIG_FILE
+ self.config_file = config_file
+
+ if cli_args and cli_args.account:
+ token_file = path.join(CONFIG_PATH, '%s.token' % cli_args.account)
else:
- self.parse_token()
-
- self.parse_config()
-
- def init_config(self):
- self.keys = constant.key
- self.params = constant.params
- self.palette = constant.palette
-
- def get_xdg_config(self):
- try:
- self.xdg_config = environ['XDG_CONFIG_HOME']
- except:
- self.xdg_config = self.home+'/.config'
-
- def get_browser(self):
- try:
- self.browser = environ['BROWSER']
- except:
- self.browser = ''
-
- def check_for_default_config(self):
- default_dir = '/turses'
- default_file = '/turses/turses.cfg'
- if not path.isfile(self.xdg_config + default_file):
- if not path.exists(self.xdg_config + default_dir):
- try:
- makedirs(self.xdg_config + default_dir)
- except:
- print encode(_('Couldn\'t create the directory in %s/turses')) % self.xdg_config
- self.generate_config_file(self.xdg_config + default_file)
+ # loads the default `token' if no account was specified
+ token_file = DEFAULT_TOKEN_FILE
+ self.token_file = token_file
+
+ def load(self):
+ """Loads configuration from files."""
+ self._init_config()
+ self._init_token()
+
+ def load_defaults(self):
+ """Load default values into configuration."""
+ self.key_bindings = KEY_BINDINGS
+ self.palette = PALETTE
+ self.styles = STYLES
+ self.logging_level = LOGGING_LEVEL
+
+ def _init_config(self):
+ if path.isfile(LEGACY_CONFIG_FILE):
+ self._parse_legacy_config_file()
+ print_deprecation_notice()
+ remove(LEGACY_CONFIG_FILE)
+ self.generate_config_file(self.config_file)
+ elif path.isfile(self.config_file):
+ self.parse_config_file(self.config_file)
+ else:
+ self.generate_config_file(self.config_file)
+
+ def _init_token(self):
+ if path.isfile(LEGACY_TOKEN_FILE):
+ self.parse_token_file(LEGACY_TOKEN_FILE)
+ remove(LEGACY_TOKEN_FILE)
+ if hasattr(self, 'oauth_token') and \
+ hasattr(self, 'oauth_token_secret'):
+ self.generate_token_file(self.token_file,
+ self.oauth_token,
+ self.oauth_token_secret)
+ elif not path.isfile(self.token_file):
+ self.authorize_new_account()
+ else:
+ self.parse_token_file(self.token_file)
+
+ def _parse_legacy_config_file(self):
+ """
+ Parse a legacy configuration file.
+ """
+ conf = RawConfigParser()
+ conf.read(LEGACY_CONFIG_FILE)
+
+ styles = self.styles.copy()
+
+ if conf.has_option('params', 'dm_template'):
+ styles['dm_template'] = conf.get('params', 'dm_template')
+
+ if conf.has_option('params', 'header_template'):
+ styles['header_template'] = conf.get('params', 'header_template')
+
+ self.styles.update(styles)
+
+ if conf.has_option('params', 'logging_level'):
+ self.logging_level = conf.getint('params', 'logging_level')
+
+ for binding in self.key_bindings:
+ if conf.has_option('keys', binding):
+ custom_key = conf.get('keys', binding)
+ self._set_key_binding(binding, custom_key)
+
+ palette_labels = [color[0] for color in PALETTE]
+ for label in palette_labels:
+ if conf.has_option('colors', label):
+ custom_fg = conf.get('colors', label)
+ self._set_color(label, custom_fg)
+
+ def _parse_legacy_token_file(self):
+ conf = RawConfigParser()
+ conf.read(LEGACY_TOKEN_FILE)
+
+ if conf.has_option(SECTION_TOKEN, 'oauth_token'):
+ self.oauth_token = conf.get(SECTION_TOKEN, 'oauth_token')
+
+ if conf.has_option(SECTION_TOKEN, 'oauth_token'):
+ self.oauth_token_secret = conf.get(SECTION_TOKEN, 'oauth_token_secret')
+
+ def _set_color(self, color_label, custom_fg=None, custom_bg=None):
+ for color in self.palette:
+ label, fg, bg = color[0], color[1], color[2]
+ if label == color_label:
+ color[1] = custom_fg if validate_color(custom_fg) is not None else fg
+ color[2] = custom_bg if validate_color(custom_bg) is not None else bg
+
+ def _set_key_binding(self, binding, new_key):
+ if not self.key_bindings.has_key(binding):
+ return
+
+ key, description = self.key_bindings[binding]
+ new_key_binding = new_key, description
+ self.key_bindings[binding] = new_key_binding
def generate_config_file(self, config_file):
+ self._generate_config_file(config_file=config_file,
+ on_error=self._config_generation_error,
+ on_success=self._config_generation_success)
+
+ @wrap_exceptions
+ def _generate_config_file(self, config_file):
conf = RawConfigParser()
- conf.read(config_file)
-
- # COLOR
- conf.add_section('colors')
- for c in self.palette:
- conf.set('colors', c[0], c[1])
- # KEYS
- conf.add_section('keys')
- for k in self.keys:
- conf.set('keys', k, self.keys[k])
- # PARAMS
- conf.add_section('params')
- for p in self.params:
- if self.params[p] == True:
- value = 1
- elif self.params[p] == False:
- value = 0
- elif self.params[p] == None:
- continue
- else:
- value = self.params[p]
-
- conf.set('params', p, value)
+
+ self.config_file = config_file
+
+ # Key bindings
+ conf.add_section(SECTION_KEY_BINDINGS)
+ binding_lists = [MOTION_KEY_BINDINGS,
+ BUFFERS_KEY_BINDINGS,
+ TWEETS_KEY_BINDINGS,
+ TIMELINES_KEY_BINDINGS,
+ META_KEY_BINDINGS,
+ TURSES_KEY_BINDINGS,]
+ for binding_list in binding_lists:
+ for binding in binding_list:
+ key = self.key_bindings[binding][0]
+ conf.set(SECTION_KEY_BINDINGS, binding, key)
+
+
+ # Color
+ conf.add_section(SECTION_PALETTE)
+ for label in self.palette:
+ label_name, fg, bg = label[0], label[1], label[2]
+ conf.set(SECTION_PALETTE, label_name, fg)
+ conf.set(SECTION_PALETTE, label_name + '_bg', bg)
+
+ # Styles
+ conf.add_section(SECTION_STYLES)
+ for style in self.styles:
+ conf.set(SECTION_STYLES, style, self.styles[style])
+
+ # Debug
+ conf.add_section(SECTION_DEBUG)
+ conf.set(SECTION_DEBUG, 'logging_level', LOGGING_LEVEL)
with open(config_file, 'wb') as config:
conf.write(config)
- print encode(_('Generating configuration file in %s')) % config_file
+ def _config_generation_error(self):
+ print encode(_('Unable to create configuration file in %s')) % self.config_file
+ exit(2)
- def set_path(self, args):
- # Default config path set
- if self.xdg_config != '':
- self.turses_path = self.xdg_config + '/turses/'
- else:
- self.turses_path = self.home + '/.config/turses/'
- # Setup the token file
- self.token_file = self.turses_path + 'turses.tok'
- if args.account != None:
- self.token_file += '.' + args.account
- # Setup the config file
- self.config_file = self.turses_path + 'turses.cfg'
- if args.config != None:
- self.config_file += '.' + args.config
-
- def new_account(self):
- self.authorization()
- self.createTokenFile()
-
- def parse_token(self):
- token = RawConfigParser()
- token.read(self.token_file)
-
- self.oauth_token = token.get('token', 'oauth_token')
- self.oauth_token_secret = token.get('token', 'oauth_token_secret')
-
- def parse_config(self):
- self.parse_color()
- self.parse_keys()
- self.parse_params()
- self.init_logger()
-
- def parse_color(self):
- for i, c in enumerate(self.palette):
- if self.conf.has_option('colors', c[0]):
- self.palette[i][1] = (self.conf.get('colors', c[0]))
-
- def parse_keys(self):
- for key in self.keys:
- if self.conf.has_option('keys', key):
- self.keys[key] = self.conf.get('keys', key)
- else:
- self.keys[key] = self.keys[key]
-
- def char_value(self, ch):
- if ch[0] == '^':
- i = 0
- while i <= 31:
- if ascii.unctrl(i) == ch.upper():
- return i
- i +=1
- return ord(ch)
-
- def parse_params(self):
-
- # refresh (in minutes)
- if self.conf.has_option('params', 'refresh'):
- self.params['refresh'] = int(self.conf.get('params', 'refresh'))
-
- if self.conf.has_option('params', 'box_position'):
- self.params['refresh'] = int(self.conf.get('params', 'box_position'))
-
- # tweet_border
- if self.conf.has_option('params', 'tweet_border'):
- self.params['tweet_border'] = int(self.conf.get('params', 'tweet_border'))
-
- # Relative_time
- if self.conf.has_option('params', 'relative_time'):
- self.params['relative_time'] = int(self.conf.get('params', 'relative_time'))
-
- # Retweet_By
- if self.conf.has_option('params', 'retweet_by'):
- self.params['retweet_by'] = int(self.conf.get('params', 'retweet_by'))
-
- # Openurl_command
- # NOTE: originally `openurl_command` configuration parameter was
- # prioritary but I'm deprecating this, using the BROWSER
- # environment variable instead.
- if self.browser != '':
- self.params['openurl_command'] = self.browser
- elif self.conf.has_option('params', 'openurl_command'):
- self.params['openurl_command'] = self.conf.get('params',
- 'openurl_command')
-
- if self.conf.has_option('params', 'logging_level'):
- self.params['logging_level'] = self.conf.get('params', 'logging_level')
-
- if self.conf.has_option('params', 'header_template'):
- self.params['header_template'] = self.conf.get('params', 'header_template')
-
- if self.conf.has_option('params', 'dm_template'):
- self.params['dm_template'] = self.conf.get('params', 'dm_template')
-
- def init_logger(self):
- log_file = self.xdg_config + '/turses/turses.log'
- lvl = self.init_logger_level()
-
- logging.basicConfig(
- filename=log_file,
- level=lvl,
- format='%(asctime)s %(levelname)s - %(message)s',
- datefmt='%d/%m/%Y %H:%M:%S',
- )
- logging.info('turses starting...')
-
- def init_logger_level(self):
- try:
- lvl = int(self.params['logging_level'])
- except:
- # INFO is the default logging level
- return logging.INFO
-
- if lvl == 1:
- return logging.DEBUG
- elif lvl == 2:
- return logging.INFO
- elif lvl == 3:
- return logging.WARNING
- elif lvl == 4:
- return logging.ERROR
-
- # TODO: can `tweepy` be used for this?
- def authorization(self):
- ''' This function from python-twitter developers '''
- # Copyright 2007 The Python-Twitter Developers
- #
- # Licensed under the Apache License, Version 2.0 (the "License");
- # you may not use this file except in compliance with the License.
- # You may obtain a copy of the License at
- #
- # http://www.apache.org/licenses/LICENSE-2.0
- #
- # Unless required by applicable law or agreed to in writing, software
- # distributed under the License is distributed on an "AS IS" BASIS,
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- # See the License for the specific language governing permissions and
- # limitations under the License.
-
- base_url = 'https://api.twitter.com'
-
- print 'base_url:{0}'.format(base_url)
-
-
- REQUEST_TOKEN_URL = base_url + '/oauth/request_token'
- ACCESS_TOKEN_URL = base_url + '/oauth/access_token'
- AUTHORIZATION_URL = base_url + '/oauth/authorize'
- consumer_key = twitter_consumer_key
- consumer_secret = twitter_consumer_secret
- oauth_consumer = oauth.Consumer(key=consumer_key, secret=consumer_secret)
- oauth_client = oauth.Client(oauth_consumer)
-
- print encode(_('Requesting temp token from Twitter'))
-
- resp, content = oauth_client.request(REQUEST_TOKEN_URL, 'GET')
-
- if resp['status'] != '200':
- print encode(_('Invalid respond, requesting temp token: %s')) % str(resp['status'])
- else:
- request_token = dict(parse_qsl(content))
-
- print ''
- print encode(_('Please visit the following page to retrieve needed pin code'))
- print encode(_('to obtain an Authentication Token:'))
- print ''
- print '%s?oauth_token=%s' % (AUTHORIZATION_URL, request_token['oauth_token'])
- print ''
-
- pincode = raw_input('Pin code? ')
-
- token = oauth.Token(request_token['oauth_token'], request_token['oauth_token_secret'])
- token.set_verifier(pincode)
-
- print ''
- print encode(_('Generating and signing request for an access token'))
- print ''
-
- oauth_client = oauth.Client(oauth_consumer, token)
- resp, content = oauth_client.request(ACCESS_TOKEN_URL, method='POST', body='oauth_verifier=%s' % pincode)
- access_token = dict(parse_qsl(content))
-
- if resp['status'] != '200':
- print 'response:{0}'.format(resp['status'])
- print encode(_('Request for access token failed: %s')) % resp['status']
- print access_token
- exit()
- else:
- self.oauth_token = access_token['oauth_token']
- self.oauth_token_secret = access_token['oauth_token_secret']
-
- def createTokenFile(self):
- if not path.isdir(self.turses_path):
- try:
- mkdir(self.turses_path)
- except:
- print encode(_('Error creating directory .config/turses'))
+ def _config_generation_success(self):
+ print encode(_('Created configuration file in %s')) % self.config_file
+
+ def generate_token_file(self,
+ token_file,
+ oauth_token,
+ oauth_token_secret):
+ self.oauth_token = oauth_token
+ self.oauth_token_secret = oauth_token_secret
conf = RawConfigParser()
- conf.add_section('token')
- conf.set('token', 'oauth_token', self.oauth_token)
- conf.set('token', 'oauth_token_secret', self.oauth_token_secret)
+ conf.add_section(SECTION_TOKEN)
+ conf.set(SECTION_TOKEN, 'oauth_token', oauth_token)
+ conf.set(SECTION_TOKEN, 'oauth_token_secret', oauth_token_secret)
- with open(self.token_file, 'wb') as tokens:
+ with open(token_file, 'wb') as tokens:
conf.write(tokens)
print encode(_('your account has been saved'))
- def save_last_read(self, last_read):
- conf = RawConfigParser()
- conf.read(self.token_file)
+ def parse_config_file(self, config_file):
+ self._conf = RawConfigParser()
+ self._conf.read(config_file)
+
+ self._parse_key_bindings()
+ self._parse_palette()
+ self._parse_styles()
+ self._parse_debug()
+
+ def _parse_key_bindings(self):
+ for binding in self.key_bindings:
+ if self._conf.has_option(SECTION_KEY_BINDINGS, binding):
+ custom_key = self._conf.get(SECTION_KEY_BINDINGS, binding)
+ self._set_key_binding(binding, custom_key)
+
+ def _parse_palette(self):
+ # Color
+ for label in self.palette:
+ label_name, fg, bg = label[0], label[1], label[2]
+ if self._conf.has_option(SECTION_PALETTE, label_name):
+ fg = self._conf.get(SECTION_PALETTE, label_name)
+ if self._conf.has_option(SECTION_PALETTE, label_name + '_bg'):
+ bg = self._conf.get(SECTION_PALETTE, label_name + '_bg')
+ self._set_color(label_name, fg, bg)
+
+ def _parse_styles(self):
+ for style in self.styles:
+ if self._conf.has_option(SECTION_STYLES, style):
+ self.styles[style] = self._conf.get(SECTION_STYLES, style)
+
+ def _parse_debug(self):
+ if self._conf.has_option(SECTION_DEBUG, 'logging_level'):
+ self.logging_level = self._conf.get(SECTION_DEBUG, 'logging_level')
+
+ def parse_token_file(self, token_file):
+ self._conf = RawConfigParser()
+ self._conf.read(token_file)
+
+ if self._conf.has_option(SECTION_TOKEN, 'oauth_token'):
+ self.oauth_token = self._conf.get(SECTION_TOKEN, 'oauth_token')
+ if self._conf.has_option(SECTION_TOKEN, 'oauth_token_secret'):
+ self.oauth_token_secret = self._conf.get(SECTION_TOKEN, 'oauth_token_secret')
+
+ def authorize_new_account(self):
+ access_token = authorization()
+ if access_token:
+ self.generate_token_file(self.token_file,
+ access_token['oauth_token'],
+ access_token['oauth_token_secret'])
+ else:
+ # TODO: exit codes
+ exit(2)
- with open(self.token_file, 'wb') as tokens:
- conf.write(tokens)
+ def reload(self):
+ self.parse_config_file(self.config_file)
View
115 turses/constant.py
@@ -1,115 +0,0 @@
-# -*- coding: utf-8 -*-
-
-"""
-turses.constant
-~~~~~~~~~~~~~~~
-
-This module contains the programs defaults.
-"""
-
-palette = [
- # Tabs
- ['active_tab', 'white', ''],
- ['visible_tab', 'light cyan', ''],
- ['inactive_tab', 'dark blue', ''],
-
- # Statuses
- ['header', 'light blue', ''],
- ['body', 'default', '', 'standout'],
- ['focus','dark red', '', 'standout'],
- ['line', 'dark blue', ''],
- ['unread', 'dark red', ''],
- ['read', 'dark blue', ''],
- ['favorited', 'yellow', ''],
-
- # Text
- ['highlight', 'dark red', ''],
- ['highlight_nick', 'light red', ''],
- ['attag', 'brown', ''],
- ['hashtag', 'dark green', ''],
-
- # Messages
- ['error', 'white', 'dark red'],
- ['info', 'white', 'dark blue'],
-]
-
-key = {
- # Motion
- 'up': 'k',
- 'down': 'j',
- 'left': 'h',
- 'right': 'l',
- 'scroll_to_top': 'g',
- 'scroll_to_bottom': 'G',
-
- # Buffers
- 'shift_buffer_left': '<',
- 'shift_buffer_right': '>',
- 'shift_buffer_beggining': 'ctrl a',
- 'shift_buffer_end': 'ctrl e',
- 'activate_first_buffer': 'a',
- 'activate_last_buffer': 'e',
- 'delete_buffer': 'd',
- 'clear': 'c',
- 'mark_all_as_read': 'A',
- 'expand_visible_left': 'p',
- 'expand_visible_right': 'n',
- 'shrink_visible_left': 'P',
- 'shrink_visible_right': 'N',
-
- # Tweets
- 'tweet': 't',
- 'delete_tweet': 'X',
- 'reply': 'r',
- 'retweet': 'R',
- 'retweet_and_edit': 'E',
- 'sendDM': 'D',
- 'update': 'u',
- 'tweet_hashtag': 'H',
-
- # Friendship
- 'follow_selected': 'f',
- 'unfollow_selected': 'U',
-
- # Favorites
- 'fav': 'b',
- 'delete_fav': 'ctrl b',
-
- # Timelines
- 'home': '.',
- 'own_tweets': '_',
- 'favorites': 'B',
- 'mentions': 'm',
- 'DMs': 'M',
- 'search': '/',
- 'search_user': '@',
- 'thread': 'T',
- 'hashtags': 'L',
-
- # Meta
- 'user_info': 'i',
- 'help': '?',
-
- # Misc
- 'quit': 'q',
- 'openurl': 'o',
- 'redraw': 'ctrl l',
-}
-
-params = {
- # TODO: refresh interval
- #'refresh': 2,
- # TODO: make time string configurable
- #'relative_time': 1,
- 'openurl_command': 'firefox',
- 'logging_level': 3,
- 'header_template': ' {username}{retweeted}{retweeter} - {time}{reply} {retweet_count} ',
- 'dm_template': ' {sender_screen_name} => {recipient_screen_name} - {time} ',
-}
-
-#filter = {
- #'activate': False,
- #'myself': False,
- #'behavior': 'all',
- #'except': [],
-#}
View
89 turses/controller.py
@@ -14,23 +14,22 @@
import urwid
-from .decorators import wrap_exceptions
from .api.base import AsyncApi
-from .utils import get_urls, spawn_process
+from .utils import get_urls, spawn_process, wrap_exceptions
from .models import (
- Timeline,
- VisibleTimelineList,
+ is_DM,
+ is_username,
+ is_valid_status_text,
+ is_valid_search_text,
+ sanitize_username,
get_authors_username,
get_mentioned_for_reply,
get_dm_recipients_username,
get_mentioned_usernames,
get_hashtags,
-
- is_valid_status_text,
- is_valid_search_text,
- is_valid_username,
- is_DM
+ Timeline,
+ VisibleTimelineList,
)
@@ -69,7 +68,12 @@ def is_bound(self, key, name):
"""
Return True if `key` corresponds to the action specified by `name`.
"""
- return key == self.configuration.keys[name]
+ try:
+ bound_key, bound_key_description = self.configuration.key_bindings[name]
+ except KeyError:
+ return False
+ else:
+ return key == bound_key
def handle_keyboard_input(self, key):
"""Handle a keyboard input."""
@@ -83,7 +87,8 @@ def handle_keyboard_input(self, key):
self._turses_key_handler(key)
# Timeline commands
- self._timeline_key_handler(key)
+ if not self.controller.is_in_help_mode():
+ self._timeline_key_handler(key)
if self.controller.is_in_info_mode():
return
@@ -119,6 +124,9 @@ def _turses_key_handler(self, key):
# help
elif self.is_bound(key, 'help'):
self.controller.help_mode()
+ # reload configuration
+ elif self.is_bound(key, 'reload_config'):
+ self.controller.reload_configuration()
def _motion_key_handler(self, key):
## up
@@ -214,6 +222,9 @@ def _timeline_key_handler(self, key):
# Follow hashtags
elif self.is_bound(key, 'hashtags'):
self.controller.search_hashtags()
+ # Authors timeline
+ elif self.is_bound(key, 'user_timeline'):
+ self.controller.focused_status_author_timeline()
def _twitter_key_handler(self, key):
# Update timeline
@@ -323,6 +334,9 @@ def _init_timelines(self):
# TODO make default timeline list configurable
self.append_default_timelines()
+ def reload_configuration(self):
+ raise NotImplementedError
+
# -- Callbacks ------------------------------------------------------------
def api_init_error(self):
@@ -385,13 +399,14 @@ def is_in_editor_mode(self):
def append_timeline(self,
name,
update_function,
- update_args=None):
+ update_args=None,
+ update_kwargs=None):
"""
Given a name, function to update a timeline and optionally
arguments to the update function, it creates the timeline and
appends it to `timelines` asynchronously.
"""
- args = name, update_function, update_args
+ args = name, update_function, update_args, update_kwargs
thread = Thread(target=self._append_timeline,
args=args)
thread.run()
@@ -399,13 +414,17 @@ def append_timeline(self,
def _append_timeline(self,
name,
update_function,
- update_args=None):
+ update_args,
+ update_kwargs):
timeline = Timeline(name=name,
update_function=update_function,
- update_function_args=update_args)
+ update_function_args=update_args,
+ update_function_kwargs=update_kwargs)
timeline.update()
timeline.activate_first()
self.timelines.append_timeline(timeline)
+ if self.is_in_info_mode():
+ self.timeline_mode()
self.draw_timelines()
def append_default_timelines(self):
@@ -432,6 +451,18 @@ def append_home_timeline(self):
on_error=timeline_not_fetched,
on_success=timeline_fetched,)
+ def append_user_timeline(self, username):
+ timeline_fetched = partial(self.info_message,
+ _('@%s\'s tweets fetched' % username))
+ timeline_not_fetched = partial(self.error_message,
+ _('Failed to fetch @%s\'s tweets' % username))
+
+ self.append_timeline(name='@%s' % username,
+ update_function=self.api.get_user_timeline,
+ update_kwargs={'screen_name': username},
+ on_error=timeline_not_fetched,
+ on_success=timeline_fetched,)
+
def append_own_tweets_timeline(self):
timeline_fetched = partial(self.info_message,
_('Your tweets fetched'))
@@ -804,7 +835,8 @@ def search_user_handler(self, username):
self.ui.set_focus('body')
# TODO make sure that the user EXISTS and THEN fetch its tweets
- if not is_valid_username(username):
+ username = sanitize_username(username)
+ if not is_username(username):
self.info_message(_('Invalid username'))
return
else:
@@ -841,6 +873,11 @@ def search_hashtags(self):
hashtags = ' '.join(get_hashtags(status))
self.search_handler(text=hashtags)
+ def focused_status_author_timeline(self):
+ status = self.timelines.get_focused_status()
+ author = get_authors_username(status)
+ self.append_user_timeline(author)
+
def tweet(self):
self.ui.show_tweet_editor(prompt=_('Tweet'),
content='',
@@ -1010,10 +1047,11 @@ def open_urls(self):
return
args = ' '.join(urls)
- # TODO: delegate this to BROWSER environment variable (?)
- command = self.configuration.params['openurl_command']
- # remove %s from legacy configuration
- command.strip('%s')
+
+ command = self.configuration.browser
+ if not command:
+ self.error_message(_('You have to set the BROWSER environment variable to open URLs'))
+ return
try:
spawn_process(command, args)
@@ -1024,15 +1062,11 @@ def open_urls(self):
class CursesController(Controller):
"""Controller for the curses implementation."""
- def __init__(self, palette, *args, **kwargs):
- self.palette = palette
- Controller.__init__(self, *args, **kwargs)
-
def main_loop(self):
if not hasattr(self, 'loop'):
self.key_handler = KeyHandler(self.configuration, self)
self.loop = urwid.MainLoop(self.ui,
- self.palette,
+ self.configuration.palette,
input_filter=self.key_handler.handle,)
self.loop.run()
@@ -1045,3 +1079,8 @@ def redraw_screen(self):
self.loop.draw_screen()
except AssertionError:
pass
+
+ def reload_configuration(self):
+ self.configuration.reload()
+ self.redraw_screen()
+ self.info_message(_('Configuration reloaded'))
View
36 turses/decorators.py
@@ -1,36 +0,0 @@
-# -*- coding: utf-8 -*-
-
-"""
-turses.decorators
-~~~~~~~~~~~~~~~~~
-
-This module contains handy decorators.
-"""
-
-from functools import wraps
-
-
-def wrap_exceptions(func):
- """
- Augments the function arguments with the `on_error` and `on_success`
- keyword arguments.
-
- Executes the decorated function in a try except block and calls `on_success`
- (if given) if no exception was raised, otherwise calls `on_error` (if given).
- """
- @wraps(func)
- def wrapper(self=None, *args, **kwargs):
- on_error = kwargs.pop('on_error', None)
- on_success = kwargs.pop('on_success', None)
-
- try:
- result = func(self, *args, **kwargs)
- except:
- if callable(on_error):
- on_error()
- else:
- if callable(on_success):
- on_success()
- return result
-
- return wrapper
View
71 turses/models.py
@@ -15,18 +15,36 @@
from .utils import html_unescape, timestamp_from_datetime
##
-# Helper functions
+# Helpers
##
+username_regex = re.compile(r'[A-Za-z0-9_]+')
+
+prepend_at = lambda username: '@%s' % username
+
def is_DM(status):
return status.__class__ == DirectMessage
+def get_mentioned_usernames(status):
+ """
+ Return mentioned usernames in `status` without '@'.
+ """
+ usernames = []
+ for word in status.text.split():
+ if len(word) > 1 and word.startswith('@'):
+ word.strip('@')
+ usernames.append(word)
+ return map(sanitize_username, usernames)
+
def get_mentioned_for_reply(status):
+ """
+ Return a list containing the author of `status` and all the mentioned
+ usernames prepended with '@'.
+ """
author = get_authors_username(status)
mentioned = get_mentioned_usernames(status)
mentioned.insert(0, author)
- prepend_at = lambda username: '@%s' % username
return map(prepend_at, mentioned)
def get_authors_username(status):
@@ -41,33 +59,55 @@ def get_authors_username(status):
return username
def get_dm_recipients_username(sender, status):
+ """
+ Return the recipient for a Direct Message depending on what `status`
+ is.
+
+ If is a `turses.models.Status` and sender != `status.user` I will return
+ `status.user`.
+
+ If is a `turses.models.DirectMessage` I will return the username that
+ is not `sender` looking at the DMs sender and recipient.
+
+ Otherwise I return `None`.
+ """
+ username = None
if is_DM(status):
users = [status.sender_screen_name,
status.recipient_screen_name,]
if sender in users:
- users.pop(sender)
+ users.pop(users.index(sender))
username = users.pop()
- else:
- # status
+ elif status.user != sender:
username = status.user
return username
-def is_username(string):
- return len(string) > 1 and string.startswith('@')
+def is_username(username):
+ """
+ Return `True` if `username` is a valid Twitter username, `False`
+ otherwise.
+ """
+ match = username_regex.match(username)
+ if not match:
+ return False
+ else:
+ return match.string == username
def is_hashtag(string):
return len(string) > 1 and string.startswith('#')
def sanitize_username(username):
- is_legal_username_char = lambda char: char.isalnum()
- sanitized = filter(is_legal_username_char, username[1:])
+ """
+ Return `username` with illegal characters for a Twitter username
+ striped.
+ """
+ sanitized = filter(is_username, username)
return sanitized
-def get_mentioned_usernames(status):
- usernames = filter(is_username, status.text.split())
- return map(sanitize_username, usernames)
-
def get_hashtags(status):
+ """
+ Return a list of hashtags encountered in `status`.
+ """
return filter(is_hashtag, status.text.split())
def is_valid_status_text(text):
@@ -78,11 +118,6 @@ def is_valid_search_text(text):
"""Checks the validity of a search text."""
return bool(text)
-def is_valid_username(username):
- username_regex = re.compile(r'[A-Za-z0-9_]+')
- return bool(username_regex.match(username))
-
-
##
# Classes
##
View
188 turses/ui/curses.py → turses/ui.py
@@ -1,17 +1,16 @@
# -*- coding: utf-8 -*-
"""
-turses.ui.curses
-~~~~~~~~~~~~~~~~
+turses.ui
+~~~~~~~~~
-This module contains the curses implementation of the UI widgets contained
-in `turses.ui.base`.
+This module contains the UI widgets.
"""
from gettext import gettext as _
from urwid import (
- AttrWrap,
+ AttrMap,
WidgetWrap,
Padding,
WidgetDecoration,
@@ -35,31 +34,54 @@
)
from urwid import __version__ as urwid_version
-from .. import __version__
-from ..models import is_DM, get_authors_username
-from ..utils import encode
-from .base import UserInterface
+from . import version
+from .config import (
+ MOTION_KEY_BINDINGS,
+ BUFFERS_KEY_BINDINGS,
+ TWEETS_KEY_BINDINGS,
+ TIMELINES_KEY_BINDINGS,
+ META_KEY_BINDINGS,
+ TURSES_KEY_BINDINGS,
+
+ CONFIG_PATH
+)
+from .models import is_DM, get_authors_username
+from .utils import encode
TWEET_MAX_CHARS = 140
-banner = [
+BANNER = [
" _ ",
" _| |_ _ _ _ __ ___ ___ ____ ",
"|_ _| | | | '__/ __|/ \/ ___|",
- " | | | | | | | | \ _ || \\ ",
+ " | | | | | | | | \ ~ || \\ ",
" | |_| |_| | | \__ | __/\__ | ",
" \___|\____|_| |____/\___||___/ ",
" ······························ ",
- "%s" % __version__,
+ "%s" % version,
"",
"",
_("Press '?' for help"),
_("Press 'q' to quit turses"),
"",
+ "",
+ _("New configuration and token files from the old ones"),
+ _("have been created in %s." % CONFIG_PATH),
+ "",
+ "",
+ " ~ ",
+ " |+.turses/ ",
+ " | |-config ",
+ _(" | |-token # default account's token "),
+ _(" | `-bob.token # another account's token "),
+ " |+... ",
+ " |-... ",
+ "",
+ "",
]
-class CursesInterface(Frame, UserInterface):
+class CursesInterface(Frame):
"""
Creates a curses interface for the program, providing functions to draw
all the components of the UI.
@@ -224,7 +246,7 @@ def __init__(self):
def _create_text(self):
"""Creates the text to display in the welcome buffer."""
self.text = []
- for line in banner:
+ for line in BANNER:
self._insert_line(line)
return ScrollableListBox(self.text)
@@ -247,11 +269,13 @@ def __init__(self,
if content:
content += ' '
self.editor = Edit(u'%s (twice enter key to validate or esc) \n>> ' % prompt, content)
- self.last_key = ''
-
+
+ widgets = [self.editor]
+ w = AttrMap(Columns(widgets), 'editor')
+
connect_signal(self, 'done', done_signal_handler)
- self.__super.__init__(self.editor)
+ self.__super.__init__(w)
def keypress(self, size, key):
if key == 'enter' and self.last_key == 'enter':
@@ -287,10 +311,7 @@ def __init__(self,
self.counter_widget = Text(str(self.counter))
widgets = [('fixed', 4, self.counter_widget), self.editor]
- w = Columns(widget_list=widgets,
- # `Column` passes keypresses to the focused widget,
- # in this case the editor
- focus_column=1)
+ w = AttrMap(Columns(widgets), 'editor')
connect_signal(self, 'done', done_signal_handler)
connect_signal(self.editor, 'change', self.update_counter)
@@ -313,8 +334,7 @@ def keypress(self, size, key):
self.last_key = key
size = size,
- editor = self._w.get_focus()
- editor.keypress(size, key)
+ self.editor.keypress(size, key)
def emit_done_signal(self, content=None):
emit_signal(self, 'done', content)
@@ -525,72 +545,37 @@ def __init__ (self, configuration):
ScrollableListBox(self.items,
offset=offset,))
+ def _insert_bindings(self, bindings):
+ for label in bindings:
+ values = self.configuration.key_bindings[label]
+ key, description = values[0], values[1]
+ widgets = [
+ ('fixed', self.col[0], Text(' ' + label)),
+ ('fixed', self.col[1], Text(key)),
+ Text(description)
+ ]
+ self.items.append(Columns(widgets))
+
def create_help_buffer(self):
- # TODO: remove the descriptions from the code. Store the keybindings
- # in `turses/constant.py`.
self.insert_header()
- # Motion
- self.insert_division(_('Motion'))
- self.insert_help_item(