Repository: mjs/imapclient

Issue:
Clarify who is responsible for checking for capability
It is not clear who is responsible for checking if a capability is supported by a server prior to using it. Currently `ID` and `SORT` are checked by IMAPClient, but `IDLE` or `UNSELECT` are not.

It could be:
1. IMAPClient, checking before each call to a non-standard command
2. Let the user call `has_capability` himself
3. No check at all, execute the command and let the server throw an error



Comments:

Patch:
diff --git a/imapclient/imapclient.py b/imapclient/imapclient.py
index a15027c..c383772 100644
--- a/imapclient/imapclient.py
+++ b/imapclient/imapclient.py
@@ -4,6 +4,7 @@
 
 from __future__ import unicode_literals
 
+import functools
 import imaplib
 import itertools
 import select
@@ -120,6 +121,23 @@ class SocketTimeout(namedtuple("SocketTimeout", "connect read")):
     """
 
 
+def require_capability(capability):
+    """Decorator raising CapabilityError when a capability is not available."""
+
+    def actual_decorator(func):
+
+        @functools.wraps(func)
+        def wrapper(client, *args, **kwargs):
+            if not client.has_capability(capability):
+                raise exceptions.CapabilityError(
+                    'Server does not support {} capability'.format(capability)
+                )
+            return func(client, *args, **kwargs)
+
+        return wrapper
+    return actual_decorator
+
+
 class IMAPClient(object):
     """A connection to the IMAP server specified by *host* is made when
     this class is instantiated.
@@ -261,6 +279,7 @@ class IMAPClient(object):
         # In the py3 version it's just sock.
         return getattr(self._imap, 'sslobj', self._imap.sock)
 
+    @require_capability('STARTTLS')
     def starttls(self, ssl_context=None):
         """Switch to an SSL encrypted connection by sending a STARTTLS command.
 
@@ -350,6 +369,7 @@ class IMAPClient(object):
         self._imap.shutdown()
         logger.info('Connection closed')
 
+    @require_capability('ENABLE')
     def enable(self, *capabilities):
         """Activate one or more server side capability extensions.
 
@@ -381,6 +401,7 @@ class IMAPClient(object):
             return []
         return resp.split()
 
+    @require_capability('ID')
     def id_(self, parameters=None):
         """Issue the ID command, returning a dict of server implementation
         fields.
@@ -388,9 +409,6 @@ class IMAPClient(object):
         *parameters* should be specified as a dictionary of field/value pairs,
         for example: ``{"name": "IMAPClient", "version": "0.12"}``
         """
-        if not self.has_capability('ID'):
-            raise exceptions.CapabilityError('server does not support IMAP ID extension')
-
         if parameters is None:
             args = 'NIL'
         else:
@@ -462,6 +480,7 @@ class IMAPClient(object):
         # be detected by this method.
         return to_bytes(capability).upper() in self.capabilities()
 
+    @require_capability('NAMESPACE')
     def namespace(self):
         """Return the namespace for the account as a (personal, other,
         shared) tuple.
@@ -512,6 +531,7 @@ class IMAPClient(object):
         """
         return self._do_list('LIST', directory, pattern)
 
+    @require_capability('XLIST')
     def xlist_folders(self, directory="", pattern="*"):
         """Execute the XLIST command, returning ``(flags, delimiter,
         name)`` tuples.
@@ -536,9 +556,7 @@ class IMAPClient(object):
 
         This is a *deprecated* Gmail-specific IMAP extension (See
         https://developers.google.com/gmail/imap_extensions#xlist_is_deprecated
-        for more information). It is the responsibility of the caller
-        to either check for ``XLIST`` in the server capabilites, or to
-        handle the error if the server doesn't support this extension.
+        for more information).
 
         The *directory* and *pattern* arguments are as per
         list_folders().
@@ -621,7 +639,6 @@ class IMAPClient(object):
 
         return None
 
-
     def select_folder(self, folder, readonly=False):
         """Set the current folder on the server.
 
@@ -643,6 +660,7 @@ class IMAPClient(object):
         self._command_and_check('select', self._normalise_folder(folder), readonly)
         return self._process_select_response(self._imap.untagged_responses)
 
+    @require_capability('UNSELECT')
     def unselect_folder(self):
         """Unselect the current folder and release associated resources.
 
@@ -701,6 +719,7 @@ class IMAPClient(object):
         tag = self._imap._command('NOOP')
         return self._consume_until_tagged_response(tag, 'NOOP')
 
+    @require_capability('IDLE')
     def idle(self):
         """Put the server into IDLE mode.
 
@@ -721,6 +740,7 @@ class IMAPClient(object):
         if resp is not None:
             raise exceptions.IMAPClientError('Unexpected IDLE response: %s' % resp)
 
+    @require_capability('IDLE')
     def idle_check(self, timeout=None):
         """Check for any IDLE responses sent by the server.
 
@@ -769,6 +789,7 @@ class IMAPClient(object):
             sock.setblocking(1)
             self._set_read_timeout()
 
+    @require_capability('IDLE')
     def idle_done(self):
         """Take the server out of IDLE mode.
 
@@ -913,6 +934,7 @@ class IMAPClient(object):
         """
         return self._search(criteria, charset)
 
+    @require_capability('X-GM-EXT-1')
     def gmail_search(self, query, charset='UTF-8'):
         """Search using Gmail's X-GM-RAW attribute.
 
@@ -958,6 +980,7 @@ class IMAPClient(object):
 
         return parse_message_list(data)
 
+    @require_capability('SORT')
     def sort(self, sort_criteria, criteria='ALL', charset='UTF-8'):
         """Return a list of message ids from the currently selected
         folder, sorted by *sort_criteria* and optionally filtered by
@@ -980,9 +1003,6 @@ class IMAPClient(object):
         Note that SORT is an extension to the IMAP4 standard so it may
         not be supported by all IMAP servers.
         """
-        if not self.has_capability('SORT'):
-            raise exceptions.CapabilityError('The server does not support the SORT extension')
-
         args = [
             _normalise_sort_criteria(sort_criteria),
             to_bytes(charset),
@@ -1228,6 +1248,7 @@ class IMAPClient(object):
                                        self._normalise_folder(folder),
                                        uid=True, unpack=True)
 
+    @require_capability('MOVE')
     def move(self, messages, folder):
         """Atomically move messages to another folder.
 
@@ -1278,6 +1299,7 @@ class IMAPClient(object):
         tag = self._imap._command('EXPUNGE')
         return self._consume_until_tagged_response(tag, 'EXPUNGE')
 
+    @require_capability('ACL')
     def getacl(self, folder):
         """Returns a list of ``(who, acl)`` tuples describing the
         access controls for *folder*.
@@ -1287,6 +1309,7 @@ class IMAPClient(object):
         parts = parts[1:]       # First item is folder name
         return [(parts[i], parts[i + 1]) for i in xrange(0, len(parts), 2)]
 
+    @require_capability('ACL')
     def setacl(self, folder, who, what):
         """Set an ACL (*what*) for user (*who*) for a folder.
 


Test patch:
diff --git a/tests/test_enable.py b/tests/test_enable.py
index 2831e83..eaecaf4 100644
--- a/tests/test_enable.py
+++ b/tests/test_enable.py
@@ -18,6 +18,7 @@ class TestEnable(IMAPClientTest):
         self.command = Mock()
         self.client._raw_command_untagged = self.command
         self.client._imap.state = 'AUTH'
+        self.client._cached_capabilities = [b'ENABLE']
 
     def test_success(self):
         self.command.return_value = b'CONDSTORE'
diff --git a/tests/test_imapclient.py b/tests/test_imapclient.py
index d3580bb..f0bafa5 100644
--- a/tests/test_imapclient.py
+++ b/tests/test_imapclient.py
@@ -15,7 +15,7 @@ import six
 from imapclient.exceptions import (
     CapabilityError, IMAPClientError, ProtocolError
 )
-from imapclient.imapclient import IMAPlibLoggerAdapter
+from imapclient.imapclient import IMAPlibLoggerAdapter, require_capability
 from imapclient.fixed_offset import FixedOffset
 from imapclient.testable_imapclient import TestableIMAPClient as IMAPClient
 
@@ -219,6 +219,7 @@ class TestSelectFolder(IMAPClientTest):
         })
 
     def test_unselect(self):
+        self.client._cached_capabilities = [b'UNSELECT']
         self.client._imap._simple_command.return_value = ('OK', ['Unselect completed.'])
         #self.client._imap._untagged_response.return_value = (
         #    b'OK', [b'("name" "GImap" "vendor" "Google, Inc.")'])
@@ -255,6 +256,10 @@ class TestAppend(IMAPClientTest):
 
 class TestAclMethods(IMAPClientTest):
 
+    def setUp(self):
+        super(TestAclMethods, self).setUp()
+        self.client._cached_capabilities = [b'ACL']
+
     def test_getacl(self):
         self.client._imap.getacl.return_value = ('OK', [b'INBOX Fred rwipslda Sally rwip'])
         acl = self.client.getacl('INBOX')
@@ -273,6 +278,10 @@ class TestAclMethods(IMAPClientTest):
 
 class TestIdleAndNoop(IMAPClientTest):
 
+    def setUp(self):
+        super(TestIdleAndNoop, self).setUp()
+        self.client._cached_capabilities = [b'IDLE']
+
     def assert_sock_calls(self, sock):
         self.assertListEqual(sock.method_calls, [
             ('settimeout', (None,), {}),
@@ -452,6 +461,10 @@ class TestTimeNormalisation(IMAPClientTest):
 
 class TestNamespace(IMAPClientTest):
 
+    def setUp(self):
+        super(TestNamespace, self).setUp()
+        self.client._cached_capabilities = [b'NAMESPACE']
+
     def set_return(self, value):
         self.client._imap.namespace.return_value = ('OK', [value])
 
@@ -544,11 +557,35 @@ class TestCapabilities(IMAPClientTest):
         self.assertTrue(self.client.has_capability('foo'))
         self.assertFalse(self.client.has_capability('BAR'))
 
+    def test_decorator(self):
+
+        class Foo(object):
+
+            def has_capability(self, capability):
+                if capability == 'TRUE':
+                    return True
+                return False
+
+            @require_capability('TRUE')
+            def yes(self):
+                return True
+
+            @require_capability('FALSE')
+            def no(self):
+                return False
+
+        foo = Foo()
+        self.assertTrue(foo.yes())
+        self.assertRaises(CapabilityError, foo.no)
+
 
 class TestId(IMAPClientTest):
 
+    def setUp(self):
+        super(TestId, self).setUp()
+        self.client._cached_capabilities = [b'ID']
+
     def test_id(self):
-        self.client._cached_capabilities = (b'ID',)
         self.client._imap._simple_command.return_value = ('OK', [b'Success'])
         self.client._imap._untagged_response.return_value = (
             b'OK', [b'("name" "GImap" "vendor" "Google, Inc.")'])
diff --git a/tests/test_search.py b/tests/test_search.py
index 9aadf34..4b0efa6 100644
--- a/tests/test_search.py
+++ b/tests/test_search.py
@@ -135,6 +135,10 @@ class TestSearch(TestSearchBase):
 
 class TestGmailSearch(TestSearchBase):
 
+    def setUp(self):
+        super(TestGmailSearch, self).setUp()
+        self.client._cached_capabilities = [b'X-GM-EXT-1']
+
     def test_bytes_query(self):
         result = self.client.gmail_search(b'foo bar')
 
diff --git a/tests/test_starttls.py b/tests/test_starttls.py
index 483729d..edd3a59 100644
--- a/tests/test_starttls.py
+++ b/tests/test_starttls.py
@@ -30,6 +30,7 @@ class TestStarttls(IMAPClientTest):
         self.client.ssl = False
         self.client._starttls_done = False
         self.client._imap._simple_command.return_value = "OK", [b'start TLS negotiation']
+        self.client._cached_capabilities = [b'STARTTLS']
 
     def test_works(self):
         resp = self.client.starttls(sentinel.ssl_context)