summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--NEWS10
-rw-r--r--alot/__init__.py4
-rw-r--r--alot/commands/globals.py33
-rw-r--r--alot/commands/thread.py21
-rw-r--r--alot/ui.py22
-rw-r--r--docs/source/usage/modes/global.rst1
-rwxr-xr-xsetup.py2
-rw-r--r--tests/commands/envelope_test.py14
-rw-r--r--tests/commands/global_test.py46
-rw-r--r--tests/commands/utils_tests.py20
-rw-r--r--tests/utilities.py7
11 files changed, 127 insertions, 53 deletions
diff --git a/NEWS b/NEWS
index e590d573..42e03d07 100644
--- a/NEWS
+++ b/NEWS
@@ -1,3 +1,13 @@
+0.7:
+* info: missing html mailcap entry now reported as mail body text
+* feature: Allow regex special characters in tagstrings
+* feature: configurable thread mode message indentation
+* new thread buffer command "indent" (bound to '[' and ']')
+* config: new option thread_indent_replies
+* config: new option exclude_tags
+* config: new option encrypt_to_self
+* config: update behaviour of encrypt_by_default
+
0.6:
* feature: Add command to reload configuration files in running session
* feature: new command "tag" (and friends) in EnvelopeBuffer to add additional tags after sending
diff --git a/alot/__init__.py b/alot/__init__.py
index 733006c6..de61c3e1 100644
--- a/alot/__init__.py
+++ b/alot/__init__.py
@@ -1,6 +1,6 @@
__productname__ = 'alot'
-__version__ = '0.7.0dev'
-__copyright__ = "Copyright (C) 2012-17 Patrick Totzke"
+__version__ = '0.7'
+__copyright__ = "Copyright (C) 2012-18 Patrick Totzke"
__author__ = "Patrick Totzke"
__author_email__ = "patricktotzke@gmail.com"
__description__ = "Terminal MUA using notmuch mail"
diff --git a/alot/commands/globals.py b/alot/commands/globals.py
index 68f0a906..4e0ed506 100644
--- a/alot/commands/globals.py
+++ b/alot/commands/globals.py
@@ -284,13 +284,8 @@ class ExternalCommand(Command):
d = threads.deferToThread(thread_code)
d.addCallback(afterwards)
else:
- ui.mainloop.screen.stop()
- ret = thread_code()
- ui.mainloop.screen.start()
-
- # make sure urwid renders its canvas at the correct size
- ui.mainloop.screen_size = None
- ui.mainloop.draw_screen()
+ with ui.paused():
+ ret = thread_code()
afterwards(ret)
@@ -348,9 +343,8 @@ class PythonShellCommand(Command):
repeatable = True
def apply(self, ui):
- ui.mainloop.screen.stop()
- code.interact(local=locals())
- ui.mainloop.screen.start()
+ with ui.paused():
+ code.interact(local=locals())
@registerCommand(MODE, 'repeat')
@@ -675,6 +669,8 @@ class HelpCommand(Command):
(['--sender'], {'nargs': '?', 'help': 'sender'}),
(['--template'], {'nargs': '?',
'help': 'path to a template message file'}),
+ (['--tags'], {'nargs': '?',
+ 'help': 'comma-separated list of tags to apply to message'}),
(['--subject'], {'nargs': '?', 'help': 'subject line'}),
(['--to'], {'nargs': '+', 'help': 'recipients'}),
(['--cc'], {'nargs': '+', 'help': 'copy to'}),
@@ -690,7 +686,7 @@ class ComposeCommand(Command):
"""compose a new email"""
def __init__(self, envelope=None, headers=None, template=None, sender=u'',
- subject=u'', to=None, cc=None, bcc=None, attach=None,
+ tags=None, subject=u'', to=None, cc=None, bcc=None, attach=None,
omit_signature=False, spawn=None, rest=None, encrypt=False,
**kwargs):
"""
@@ -704,6 +700,8 @@ class ComposeCommand(Command):
:type template: str
:param sender: From-header value
:type sender: str
+ :param tags: Comma-separated list of tags to apply to message
+ :type tags: list(str)
:param subject: Subject-header value
:type subject: str
:param to: To-header value
@@ -741,6 +739,7 @@ class ComposeCommand(Command):
self.force_spawn = spawn
self.rest = ' '.join(rest or [])
self.encrypt = encrypt
+ self.tags = tags
@inlineCallbacks
def apply(self, ui):
@@ -770,8 +769,11 @@ class ComposeCommand(Command):
priority='error')
return
try:
- with open(path) as f:
- self.envelope.parse_template(f.read())
+ with open(path, 'rb') as f:
+ blob = f.read()
+ encoding = helper.guess_encoding(blob)
+ logging.debug('template encoding: `%s`' % encoding)
+ self.envelope.parse_template(blob.decode(encoding))
except Exception as e:
ui.notify(str(e), priority='error')
return
@@ -791,6 +793,8 @@ class ComposeCommand(Command):
self.envelope.add('Cc', ','.join(self.cc))
if self.bcc:
self.envelope.add('Bcc', ','.join(self.bcc))
+ if self.tags:
+ self.envelope.tags = [t for t in self.tags.split(',') if t]
# get missing From header
if 'From' not in self.envelope.headers:
@@ -892,7 +896,8 @@ class ComposeCommand(Command):
if settings.get('compose_ask_tags'):
comp = TagsCompleter(ui.dbman)
- tagsstring = yield ui.prompt('Tags', completer=comp)
+ tags = ','.join(self.tags) if self.tags else ''
+ tagsstring = yield ui.prompt('Tags', text=tags, completer=comp)
tags = [t for t in tagsstring.split(',') if t]
if tags is None:
raise CommandCanceled()
diff --git a/alot/commands/thread.py b/alot/commands/thread.py
index dcce2edb..609a4f3a 100644
--- a/alot/commands/thread.py
+++ b/alot/commands/thread.py
@@ -769,18 +769,15 @@ class PipeCommand(Command):
if self.notify_stdout:
ui.notify(out)
else:
- logging.debug('stop urwid screen')
- ui.mainloop.screen.stop()
- logging.debug('call: %s', self.cmd)
- # if proc.stdout is defined later calls to communicate
- # seem to be non-blocking!
- proc = subprocess.Popen(self.cmd, shell=True,
- stdin=subprocess.PIPE,
- # stdout=subprocess.PIPE,
- stderr=subprocess.PIPE)
- out, err = proc.communicate(mail)
- logging.debug('start urwid screen')
- ui.mainloop.screen.start()
+ with ui.paused():
+ logging.debug('call: %s', self.cmd)
+ # if proc.stdout is defined later calls to communicate
+ # seem to be non-blocking!
+ proc = subprocess.Popen(self.cmd, shell=True,
+ stdin=subprocess.PIPE,
+ # stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+ out, err = proc.communicate(mail)
if err:
ui.notify(err, priority='error')
return
diff --git a/alot/ui.py b/alot/ui.py
index 68a21f6d..a8da57d2 100644
--- a/alot/ui.py
+++ b/alot/ui.py
@@ -7,6 +7,7 @@ import logging
import os
import signal
import codecs
+import contextlib
from twisted.internet import reactor, defer, task
import urwid
@@ -75,6 +76,9 @@ class UI(object):
# alarm handle for callback that clears input queue (to cancel alarm)
self._alarm = None
+ # force urwid to pass key events as unicode, independent of LANG
+ urwid.set_encoding('utf-8')
+
# create root widget
global_att = settings.get_theming_attribute('global', 'body')
mainframe = urwid.Frame(urwid.SolidFill())
@@ -360,6 +364,24 @@ class UI(object):
exit_msg = 'Could not stop reactor: {}.'.format(e)
logging.error('%s\nShutting down anyway..', exit_msg)
+ @contextlib.contextmanager
+ def paused(self):
+ """
+ context manager that pauses the UI to allow running external commands.
+
+ If an exception occurs, the UI will be started before the exception is
+ re-raised.
+ """
+ self.mainloop.stop()
+ try:
+ yield
+ finally:
+ self.mainloop.start()
+
+ # make sure urwid renders its canvas at the correct size
+ self.mainloop.screen_size = None
+ self.mainloop.draw_screen()
+
def buffer_open(self, buf):
"""register and focus new :class:`~alot.buffers.Buffer`."""
diff --git a/docs/source/usage/modes/global.rst b/docs/source/usage/modes/global.rst
index dfd0c0f9..dcbe4040 100644
--- a/docs/source/usage/modes/global.rst
+++ b/docs/source/usage/modes/global.rst
@@ -130,6 +130,7 @@ The following commands are available globally
optional arguments
:---sender: sender.
:---template: path to a template message file.
+ :---tags: comma-separated list of tags to apply to message.
:---subject: subject line.
:---to: recipients.
:---cc: copy to.
diff --git a/setup.py b/setup.py
index f20c5cba..3bf8706e 100755
--- a/setup.py
+++ b/setup.py
@@ -42,7 +42,7 @@ setup(
},
install_requires=[
'notmuch>=0.13',
- 'urwid>=1.1.0',
+ 'urwid>=1.3.0',
'urwidtrees>=1.0',
'twisted>=10.2.0',
'python-magic',
diff --git a/tests/commands/envelope_test.py b/tests/commands/envelope_test.py
index c1b928db..c48a0d91 100644
--- a/tests/commands/envelope_test.py
+++ b/tests/commands/envelope_test.py
@@ -35,6 +35,8 @@ from alot.errors import GPGProblem
from alot.settings.errors import NoMatchingAccount
from alot.settings.manager import SettingsManager
+from .. import utilities
+
# When using an assert from a mock a TestCase method might not use self. That's
# okay.
# pylint: disable=no-self-use
@@ -64,7 +66,7 @@ class TestAttachCommand(unittest.TestCase):
def test_single_path(self):
"""A test for an existing single path."""
- ui = mock.Mock()
+ ui = utilities.make_ui()
with temporary_directory() as d:
testfile = os.path.join(d, 'foo')
@@ -77,7 +79,7 @@ class TestAttachCommand(unittest.TestCase):
def test_user(self):
"""A test for an existing single path prefaced with ~/."""
- ui = mock.Mock()
+ ui = utilities.make_ui()
with temporary_directory() as d:
# This mock replaces expanduser to replace "~/" with a path to the
@@ -96,7 +98,7 @@ class TestAttachCommand(unittest.TestCase):
def test_glob(self):
"""A test using a glob."""
- ui = mock.Mock()
+ ui = utilities.make_ui()
with temporary_directory() as d:
testfile1 = os.path.join(d, 'foo')
@@ -112,7 +114,7 @@ class TestAttachCommand(unittest.TestCase):
def test_no_match(self):
"""A test for a file that doesn't exist."""
- ui = mock.Mock()
+ ui = utilities.make_ui()
with temporary_directory() as d:
cmd = envelope.AttachCommand(path=os.path.join(d, 'doesnt-exist'))
@@ -133,7 +135,7 @@ class TestTagCommands(unittest.TestCase):
:type expected: list(str)
"""
env = Envelope(tags=['one', 'two', 'three'])
- ui = mock.Mock()
+ ui = utilities.make_ui()
ui.current_buffer = mock.Mock()
ui.current_buffer.envelope = env
cmd = envelope.TagCommand(tags=tagstring, action=action)
@@ -177,7 +179,7 @@ class TestSignCommand(unittest.TestCase):
envelope['From'] = 'foo <foo@example.com>'
envelope.sign = mock.sentinel.default
envelope.sign_key = mock.sentinel.default
- ui = mock.Mock(current_buffer=mock.Mock(envelope=envelope))
+ ui = utilities.make_ui(current_buffer=mock.Mock(envelope=envelope))
return envelope, ui
@mock.patch('alot.commands.envelope.crypto.get_key',
diff --git a/tests/commands/global_test.py b/tests/commands/global_test.py
index 9489c909..e3d91f8a 100644
--- a/tests/commands/global_test.py
+++ b/tests/commands/global_test.py
@@ -18,6 +18,7 @@
from __future__ import absolute_import
import os
+import tempfile
from twisted.trial import unittest
from twisted.internet.defer import inlineCallbacks
@@ -25,6 +26,8 @@ import mock
from alot.commands import globals as g_commands
+from .. import utilities
+
class Stop(Exception):
"""exception for stopping testing of giant unmanagable functions."""
@@ -118,36 +121,63 @@ class TestComposeCommand(unittest.TestCase):
self.assertFalse(envelope.sign)
self.assertIs(envelope.sign_key, None)
+ @inlineCallbacks
+ def test_decode_template_on_loading(self):
+ subject = u'This is a täßϑ subject.'
+ to = u'recipient@mail.com'
+ _from = u'foo.bar@mail.fr'
+ body = u'Body\n地初店会継思識棋御招告外児山望掲領環。\n€mail body €nd.'
+ with tempfile.NamedTemporaryFile('wb', delete=False) as f:
+ txt = u'Subject: {}\nTo: {}\nFrom: {}\n{}'.format(subject, to,
+ _from, body)
+ f.write(txt.encode('utf-8'))
+ self.addCleanup(os.unlink, f.name)
+
+ cmd = g_commands.ComposeCommand(template=f.name)
+
+ # Crutch to exit the giant `apply` method early.
+ with mock.patch('alot.commands.globals.settings.get_account_by_address',
+ mock.Mock(side_effect=Stop)):
+ try:
+ yield cmd.apply(mock.Mock())
+ except Stop:
+ pass
+
+ self.assertEqual({'To': [to],
+ 'From': [_from],
+ 'Subject': [subject]}, cmd.envelope.headers)
+ self.assertEqual(body, cmd.envelope.body)
+
class TestExternalCommand(unittest.TestCase):
def test_no_spawn_no_stdin_success(self):
- ui = mock.Mock()
+ ui = utilities.make_ui()
cmd = g_commands.ExternalCommand(u'true', refocus=False)
cmd.apply(ui)
ui.notify.assert_not_called()
def test_no_spawn_stdin_success(self):
- ui = mock.Mock()
+ ui = utilities.make_ui()
cmd = g_commands.ExternalCommand(u"awk '{ exit $0 }'", stdin=u'0',
refocus=False)
cmd.apply(ui)
ui.notify.assert_not_called()
def test_no_spawn_no_stdin_attached(self):
- ui = mock.Mock()
+ ui = utilities.make_ui()
cmd = g_commands.ExternalCommand(u'test -t 0', refocus=False)
cmd.apply(ui)
ui.notify.assert_not_called()
def test_no_spawn_stdin_attached(self):
- ui = mock.Mock()
+ ui = utilities.make_ui()
cmd = g_commands.ExternalCommand(u"test -t 0", stdin=u'0', refocus=False)
cmd.apply(ui)
ui.notify.assert_called_once_with('', priority='error')
def test_no_spawn_failure(self):
- ui = mock.Mock()
+ ui = utilities.make_ui()
cmd = g_commands.ExternalCommand(u'false', refocus=False)
cmd.apply(ui)
ui.notify.assert_called_once_with('', priority='error')
@@ -155,7 +185,7 @@ class TestExternalCommand(unittest.TestCase):
@mock.patch('alot.commands.globals.settings.get', mock.Mock(return_value=''))
@mock.patch.dict(os.environ, {'DISPLAY': ':0'})
def test_spawn_no_stdin_success(self):
- ui = mock.Mock()
+ ui = utilities.make_ui()
cmd = g_commands.ExternalCommand(u'true', refocus=False, spawn=True)
cmd.apply(ui)
ui.notify.assert_not_called()
@@ -163,7 +193,7 @@ class TestExternalCommand(unittest.TestCase):
@mock.patch('alot.commands.globals.settings.get', mock.Mock(return_value=''))
@mock.patch.dict(os.environ, {'DISPLAY': ':0'})
def test_spawn_stdin_success(self):
- ui = mock.Mock()
+ ui = utilities.make_ui()
cmd = g_commands.ExternalCommand(
u"awk '{ exit $0 }'",
stdin=u'0', refocus=False, spawn=True)
@@ -173,7 +203,7 @@ class TestExternalCommand(unittest.TestCase):
@mock.patch('alot.commands.globals.settings.get', mock.Mock(return_value=''))
@mock.patch.dict(os.environ, {'DISPLAY': ':0'})
def test_spawn_failure(self):
- ui = mock.Mock()
+ ui = utilities.make_ui()
cmd = g_commands.ExternalCommand(u'false', refocus=False, spawn=True)
cmd.apply(ui)
ui.notify.assert_called_once_with('', priority='error')
diff --git a/tests/commands/utils_tests.py b/tests/commands/utils_tests.py
index 5339bbf8..5320aadd 100644
--- a/tests/commands/utils_tests.py
+++ b/tests/commands/utils_tests.py
@@ -78,7 +78,7 @@ class TestGetKeys(unittest.TestCase):
"""Test that getting keys works when all keys are present."""
expected = crypto.get_key(FPR, validate=True, encrypt=True,
signed_only=False)
- ui = mock.Mock()
+ ui = utilities.make_ui()
ids = [FPR]
actual = yield utils._get_keys(ui, ids)
self.assertIn(FPR, actual)
@@ -89,7 +89,7 @@ class TestGetKeys(unittest.TestCase):
"""Test that getting keys works when some keys are missing."""
expected = crypto.get_key(FPR, validate=True, encrypt=True,
signed_only=False)
- ui = mock.Mock()
+ ui = utilities.make_ui()
ids = [FPR, "6F6B15509CF8E59E6E469F327F438280EF8D349F"]
actual = yield utils._get_keys(ui, ids)
self.assertIn(FPR, actual)
@@ -98,7 +98,7 @@ class TestGetKeys(unittest.TestCase):
@inlineCallbacks
def test_get_keys_signed_only(self):
"""Test gettings keys when signed only is required."""
- ui = mock.Mock()
+ ui = utilities.make_ui()
ids = [FPR]
actual = yield utils._get_keys(ui, ids, signed_only=True)
self.assertEqual(actual, {})
@@ -107,7 +107,7 @@ class TestGetKeys(unittest.TestCase):
def test_get_keys_ambiguous(self):
"""Test gettings keys when when the key is ambiguous."""
key = crypto.get_key(FPR, validate=True, encrypt=True, signed_only=False)
- ui = mock.Mock()
+ ui = utilities.make_ui()
# Creat a ui.choice object that can satisfy twisted, but can also be
# queried for calls as a mock
@@ -136,7 +136,7 @@ class TestSetEncrypt(unittest.TestCase):
@inlineCallbacks
def test_get_keys_from_to(self):
- ui = mock.Mock()
+ ui = utilities.make_ui()
envelope = Envelope()
envelope['To'] = 'ambig@example.com, test@example.com'
yield utils.set_encrypt(ui, envelope)
@@ -147,7 +147,7 @@ class TestSetEncrypt(unittest.TestCase):
@inlineCallbacks
def test_get_keys_from_cc(self):
- ui = mock.Mock()
+ ui = utilities.make_ui()
envelope = Envelope()
envelope['Cc'] = 'ambig@example.com, test@example.com'
yield utils.set_encrypt(ui, envelope)
@@ -158,7 +158,7 @@ class TestSetEncrypt(unittest.TestCase):
@inlineCallbacks
def test_get_partial_keys(self):
- ui = mock.Mock()
+ ui = utilities.make_ui()
envelope = Envelope()
envelope['Cc'] = 'foo@example.com, test@example.com'
yield utils.set_encrypt(ui, envelope)
@@ -169,7 +169,7 @@ class TestSetEncrypt(unittest.TestCase):
@inlineCallbacks
def test_get_no_keys(self):
- ui = mock.Mock()
+ ui = utilities.make_ui()
envelope = Envelope()
envelope['To'] = 'foo@example.com'
yield utils.set_encrypt(ui, envelope)
@@ -178,7 +178,7 @@ class TestSetEncrypt(unittest.TestCase):
@inlineCallbacks
def test_encrypt_to_self_true(self):
- ui = mock.Mock()
+ ui = utilities.make_ui()
envelope = Envelope()
envelope['From'] = 'test@example.com'
envelope['To'] = 'ambig@example.com'
@@ -193,7 +193,7 @@ class TestSetEncrypt(unittest.TestCase):
@inlineCallbacks
def test_encrypt_to_self_false(self):
- ui = mock.Mock()
+ ui = utilities.make_ui()
envelope = Envelope()
envelope['From'] = 'test@example.com'
envelope['To'] = 'ambig@example.com'
diff --git a/tests/utilities.py b/tests/utilities.py
index 8123afd8..85f3d3eb 100644
--- a/tests/utilities.py
+++ b/tests/utilities.py
@@ -173,6 +173,13 @@ def make_key(revoked=False, expired=False, invalid=False, can_encrypt=True,
return mock_key
+def make_ui(**kwargs):
+ ui = mock.Mock(**kwargs)
+ ui.paused.return_value = mock.MagicMock()
+
+ return ui
+
+
def expected_failure(func):
"""For marking expected failures for twisted.trial based unit tests.