diff options
-rw-r--r-- | alot/commands/globals.py | 27 | ||||
-rw-r--r-- | alot/helper.py | 4 | ||||
-rw-r--r-- | docs/source/configuration/contacts_completion.rst | 14 | ||||
-rw-r--r-- | docs/source/configuration/hooks.rst | 2 | ||||
-rw-r--r-- | tests/commands/global_test.py | 61 | ||||
-rw-r--r-- | tests/helper_test.py | 78 | ||||
-rw-r--r-- | tests/utilities.py | 10 |
7 files changed, 166 insertions, 30 deletions
diff --git a/alot/commands/globals.py b/alot/commands/globals.py index f8f58749..565bd742 100644 --- a/alot/commands/globals.py +++ b/alot/commands/globals.py @@ -247,7 +247,7 @@ class ExternalCommand(Command): # set standard input for subcommand stdin = None if self.stdin is not None: - # wrap strings in StrinIO so that they behaves like a file + # wrap strings in StringIO so that they behave like files if isinstance(self.stdin, unicode): stdin = StringIO(self.stdin) else: @@ -255,7 +255,7 @@ class ExternalCommand(Command): def afterwards(data): if data == 'success': - if callable(self.on_success): + if self.on_success is not None: self.on_success() else: ui.notify(data, priority='error') @@ -267,24 +267,17 @@ class ExternalCommand(Command): def thread_code(*_): try: - if stdin is None: - proc = subprocess.Popen(self.cmdlist, shell=self.shell, - stderr=subprocess.PIPE) - ret = proc.wait() - err = proc.stderr.read() - else: - proc = subprocess.Popen(self.cmdlist, shell=self.shell, - stdin=subprocess.PIPE, - stderr=subprocess.PIPE) - _, err = proc.communicate(stdin.read()) - ret = proc.wait() - if ret == 0: - return 'success' - else: - return err.strip() + proc = subprocess.Popen(self.cmdlist, shell=self.shell, + stdin=subprocess.PIPE if stdin else None, + stderr=subprocess.PIPE) except OSError as e: return str(e) + _, err = proc.communicate(stdin.read() if stdin else None) + if proc.returncode == 0: + return 'success' + return err.strip() + if self.in_thread: d = threads.deferToThread(thread_code) d.addCallback(afterwards) diff --git a/alot/helper.py b/alot/helper.py index 1c569a23..26787fa8 100644 --- a/alot/helper.py +++ b/alot/helper.py @@ -129,7 +129,7 @@ def string_decode(string, enc='ascii'): def shorten(string, maxlen): """shortens string if longer than maxlen, appending ellipsis""" if 1 < maxlen < len(string): - string = string[:maxlen - 1] + u'\u2026' + string = string[:maxlen - 1] + u'…' return string[:maxlen] @@ -333,7 +333,7 @@ def call_cmd_async(cmdlist, stdin=None, env=None): self.deferred.errback(terminated_obj) d = Deferred() - environment = os.environ + environment = os.environ.copy() if env is not None: environment.update(env) logging.debug('ENV = %s', environment) diff --git a/docs/source/configuration/contacts_completion.rst b/docs/source/configuration/contacts_completion.rst index 9185424f..5f18831c 100644 --- a/docs/source/configuration/contacts_completion.rst +++ b/docs/source/configuration/contacts_completion.rst @@ -56,11 +56,12 @@ Both respect the `ignorecase` option which defaults to `True` and results in cas command = notmuch_abook.py lookup regexp = ^((?P<name>[^(\\s+\<)]*)\s+<)?(?P<email>[^@]+?@[^>]+)>?$ - `notmuch address` + `notmuch address <https://notmuchmail.org/manpages/notmuch-address-1/>`_ Since version `0.19`, notmuch itself offers a subcommand `address`, that returns email addresses found in the notmuch index. Combined with the `date:` syntax to query for mails within a certain - timeframe, this allows to search for all recently used contacts: + timeframe, this allows to search contacts that you've sent emails to + (output all addresses from the `To`, `Cc` and `Bcc` headers): .. code-block:: ini @@ -68,6 +69,15 @@ Both respect the `ignorecase` option which defaults to `True` and results in cas regexp = '\[?{"name": "(?P<name>.*)", "address": "(?P<email>.+)", "name-addr": ".*"}[,\]]?' shellcommand_external_filtering = False + If you want to search for senders in the `From` header (which should be + must faster according to `notmuch address docs + <https://notmuchmail.org/manpages/notmuch-address-1/>`_), then use the + following command: + + .. code-block:: ini + + command = 'notmuch address --format=json date:1Y..' + Don't hesitate to send me your custom `regexp` values to list them here. .. describe:: abook diff --git a/docs/source/configuration/hooks.rst b/docs/source/configuration/hooks.rst index 352701c3..0b2b6a23 100644 --- a/docs/source/configuration/hooks.rst +++ b/docs/source/configuration/hooks.rst @@ -28,7 +28,7 @@ Consider this pre-hook for the exit command, that logs a personalized goodbye message:: import logging - from alot.settings import settings + from alot.settings.const import settings def pre_global_exit(**kwargs): accounts = settings.get_accounts() if accounts: diff --git a/tests/commands/global_test.py b/tests/commands/global_test.py index 860a6ccb..9489c909 100644 --- a/tests/commands/global_test.py +++ b/tests/commands/global_test.py @@ -17,6 +17,7 @@ """Tests for global commands.""" from __future__ import absolute_import +import os from twisted.trial import unittest from twisted.internet.defer import inlineCallbacks @@ -116,3 +117,63 @@ class TestComposeCommand(unittest.TestCase): self.assertFalse(envelope.sign) self.assertIs(envelope.sign_key, None) + + +class TestExternalCommand(unittest.TestCase): + + def test_no_spawn_no_stdin_success(self): + ui = mock.Mock() + 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() + 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() + 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() + 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() + cmd = g_commands.ExternalCommand(u'false', refocus=False) + cmd.apply(ui) + ui.notify.assert_called_once_with('', priority='error') + + @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() + cmd = g_commands.ExternalCommand(u'true', refocus=False, spawn=True) + cmd.apply(ui) + ui.notify.assert_not_called() + + @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() + cmd = g_commands.ExternalCommand( + u"awk '{ exit $0 }'", + stdin=u'0', refocus=False, spawn=True) + cmd.apply(ui) + ui.notify.assert_not_called() + + @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() + 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/helper_test.py b/tests/helper_test.py index 62e37ed8..c2927725 100644 --- a/tests/helper_test.py +++ b/tests/helper_test.py @@ -18,17 +18,21 @@ """Test suite for alot.helper module.""" from __future__ import absolute_import - import datetime import email import errno +import os import random -import unittest import mock +from twisted.trial import unittest +from twisted.internet.defer import inlineCallbacks +from twisted.internet.error import ProcessTerminated from alot import helper +from . import utilities + # Descriptive names for tests often violate PEP8. That's not an issue, users # aren't meant to call these functions. # pylint: disable=invalid-name @@ -257,7 +261,7 @@ class TestPrettyDatetime(unittest.TestCase): self.assertEqual(actual, expected) # Returns 'just now', instead of 'from future' or something similar - @unittest.expectedFailure + @utilities.expected_failure def test_future_minutes(self): test = self.now + datetime.timedelta(minutes=5) actual = helper.pretty_datetime(test) @@ -265,7 +269,7 @@ class TestPrettyDatetime(unittest.TestCase): self.assertEqual(actual, expected) # Returns 'just now', instead of 'from future' or something similar - @unittest.expectedFailure + @utilities.expected_failure def test_future_hours(self): test = self.now + datetime.timedelta(hours=1) actual = helper.pretty_datetime(test) @@ -273,7 +277,7 @@ class TestPrettyDatetime(unittest.TestCase): self.assertEqual(actual, expected) # Returns 'just now', instead of 'from future' or something similar - @unittest.expectedFailure + @utilities.expected_failure def test_future_days(self): def make_expected(): # Uses the hourfmt instead of the hourminfmt from pretty_datetime @@ -290,7 +294,7 @@ class TestPrettyDatetime(unittest.TestCase): self.assertEqual(actual, expected) # Returns 'just now', instead of 'from future' or something similar - @unittest.expectedFailure + @utilities.expected_failure def test_future_week(self): test = self.now + datetime.timedelta(days=7) actual = helper.pretty_datetime(test) @@ -298,7 +302,7 @@ class TestPrettyDatetime(unittest.TestCase): self.assertEqual(actual, expected) # Returns 'just now', instead of 'from future' or something similar - @unittest.expectedFailure + @utilities.expected_failure def test_future_month(self): test = self.now + datetime.timedelta(days=31) actual = helper.pretty_datetime(test) @@ -306,7 +310,7 @@ class TestPrettyDatetime(unittest.TestCase): self.assertEqual(actual, expected) # Returns 'just now', instead of 'from future' or something similar - @unittest.expectedFailure + @utilities.expected_failure def test_future_year(self): test = self.now + datetime.timedelta(days=365) actual = helper.pretty_datetime(test) @@ -402,3 +406,61 @@ class TestEmailAsString(unittest.TestCase): actual = helper.email_as_string(message) expected = 'X-Unicode-Header: dummy value\r\n\r\n' self.assertEqual(actual, expected) + + +class TestShorten(unittest.TestCase): + + def test_lt_maxlen(self): + expected = u'a string' + actual = helper.shorten(expected, 25) + self.assertEqual(expected, actual) + + def test_eq_maxlen(self): + expected = 'a string' + actual = helper.shorten(expected, len(expected)) + self.assertEqual(expected, actual) + + def test_gt_maxlen(self): + expected = u'a long string…' + actual = helper.shorten('a long string that is full of text', 14) + self.assertEqual(expected, actual) + + +class TestCallCmdAsync(unittest.TestCase): + + @inlineCallbacks + def test_no_stdin(self): + ret = yield helper.call_cmd_async(['echo', '-n', 'foo']) + self.assertEqual(ret, 'foo') + + @inlineCallbacks + def test_stdin(self): + ret = yield helper.call_cmd_async(['cat', '-'], stdin='foo') + self.assertEqual(ret, 'foo') + + @inlineCallbacks + def test_env_set(self): + with mock.patch.dict(os.environ, {}, clear=True): + ret = yield helper.call_cmd_async( + # Thanks to the future import it doesn't matter if python is + # python2 or python3 + ['python', '-c', 'from __future__ import print_function; ' + 'import os; ' + 'print(os.environ.get("foo", "fail"), end="")' + ], + env={'foo': 'bar'}) + self.assertEqual(ret, 'bar') + + @inlineCallbacks + def test_env_doesnt_pollute(self): + with mock.patch.dict(os.environ, {}, clear=True): + yield helper.call_cmd_async(['echo', '-n', 'foo'], + env={'foo': 'bar'}) + self.assertEqual(os.environ, {}) + + @inlineCallbacks + def test_command_fails(self): + with self.assertRaises(ProcessTerminated) as cm: + yield helper.call_cmd_async(['_____better_not_exist']) + self.assertEqual(cm.exception.exitCode, 1) + self.assertTrue(cm.exception.stderr) diff --git a/tests/utilities.py b/tests/utilities.py index 402feb38..8123afd8 100644 --- a/tests/utilities.py +++ b/tests/utilities.py @@ -171,3 +171,13 @@ def make_key(revoked=False, expired=False, invalid=False, can_encrypt=True, mock_key.can_sign = can_sign return mock_key + + +def expected_failure(func): + """For marking expected failures for twisted.trial based unit tests. + + The builtin unittest.expectedFailure does not work with twisted.trail, + there is an outstanding bug for this, but no one has ever fixed it. + """ + func.todo = 'expected failure' + return func |