diff options
-rw-r--r-- | NEWS | 10 | ||||
-rw-r--r-- | alot/__init__.py | 4 | ||||
-rw-r--r-- | alot/commands/globals.py | 33 | ||||
-rw-r--r-- | alot/commands/thread.py | 21 | ||||
-rw-r--r-- | alot/ui.py | 22 | ||||
-rw-r--r-- | docs/source/usage/modes/global.rst | 1 | ||||
-rwxr-xr-x | setup.py | 2 | ||||
-rw-r--r-- | tests/commands/envelope_test.py | 14 | ||||
-rw-r--r-- | tests/commands/global_test.py | 46 | ||||
-rw-r--r-- | tests/commands/utils_tests.py | 20 | ||||
-rw-r--r-- | tests/utilities.py | 7 |
11 files changed, 127 insertions, 53 deletions
@@ -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 @@ -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. @@ -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. |