[Python-checkins] cpython (merge default -> default): merge heads

benjamin.peterson python-checkins at python.org
Mon May 11 03:20:06 CEST 2015


https://hg.python.org/cpython/rev/a290885ec0da
changeset:   95946:a290885ec0da
parent:      95945:2ca5a37f996f
parent:      95942:195343b5e64f
user:        Benjamin Peterson <benjamin at python.org>
date:        Sun May 10 21:20:01 2015 -0400
summary:
  merge heads

files:
  Doc/library/imaplib.rst  |  30 ++++++++++-
  Doc/whatsnew/3.5.rst     |  11 +++
  Lib/imaplib.py           |  77 ++++++++++++++++++++------
  Lib/test/test_imaplib.py |  78 ++++++++++++++++++++++++++++
  Misc/NEWS                |   4 +
  Objects/genobject.c      |   2 +-
  6 files changed, 180 insertions(+), 22 deletions(-)


diff --git a/Doc/library/imaplib.rst b/Doc/library/imaplib.rst
--- a/Doc/library/imaplib.rst
+++ b/Doc/library/imaplib.rst
@@ -77,7 +77,8 @@
 There's also a subclass for secure connections:
 
 
-.. class:: IMAP4_SSL(host='', port=IMAP4_SSL_PORT, keyfile=None, certfile=None, ssl_context=None)
+.. class:: IMAP4_SSL(host='', port=IMAP4_SSL_PORT, keyfile=None, \
+                     certfile=None, ssl_context=None)
 
    This is a subclass derived from :class:`IMAP4` that connects over an SSL
    encrypted socket (to use this class you need a socket module that was compiled
@@ -211,6 +212,10 @@
    that will be base64 encoded and sent to the server.  It should return
    ``None`` if the client abort response ``*`` should be sent instead.
 
+   .. versionchanged:: 3.5
+      string usernames and passwords are now encoded to ``utf-8`` instead of
+      being limited to ASCII.
+
 
 .. method:: IMAP4.check()
 
@@ -243,6 +248,16 @@
    Delete the ACLs (remove any rights) set for who on mailbox.
 
 
+.. method:: IMAP4.enable(capability)
+
+   Enable *capability* (see :rfc:`5161`).  Most capabilities do not need to be
+   enabled.  Currently only the ``UTF8=ACCEPT`` capability is supported
+   (see :RFC:`6855`).
+
+   .. versionadded:: 3.5
+      The :meth:`enable` method itself, and :RFC:`6855` support.
+
+
 .. method:: IMAP4.expunge()
 
    Permanently remove deleted items from selected mailbox. Generates an ``EXPUNGE``
@@ -380,7 +395,9 @@
    Search mailbox for matching messages.  *charset* may be ``None``, in which case
    no ``CHARSET`` will be specified in the request to the server.  The IMAP
    protocol requires that at least one criterion be specified; an exception will be
-   raised when the server returns an error.
+   raised when the server returns an error.  *charset* must be ``None`` if
+   the ``UTF8=ACCEPT`` capability was enabled using the :meth:`enable`
+   command.
 
    Example::
 
@@ -542,6 +559,15 @@
    the module variable ``Debug``.  Values greater than three trace each command.
 
 
+.. attribute:: IMAP4.utf8_enabled
+
+   Boolean value that is normally ``False``, but is set to ``True`` if an
+   :meth:`enable` command is successfully issued for the ``UTF8=ACCEPT``
+   capability.
+
+   .. versionadded:: 3.5
+
+
 .. _imap4-example:
 
 IMAP4 Example
diff --git a/Doc/whatsnew/3.5.rst b/Doc/whatsnew/3.5.rst
--- a/Doc/whatsnew/3.5.rst
+++ b/Doc/whatsnew/3.5.rst
@@ -337,6 +337,17 @@
   automatically at the end of the block.  (Contributed by Tarek Ziadé and
   Serhiy Storchaka in :issue:`4972`.)
 
+* :mod:`imaplib` now supports :rfc:`5161`: the :meth:`~imaplib.IMAP4.enable`
+  extension), and :rfc:`6855`: utf-8 support (internationalized email, via the
+  ``UTF8=ACCEPT`` argument to :meth:`~imaplib.IMAP4.enable`).  A new attribute,
+  :attr:`~imaplib.IMAP4.utf8_enabled`, tracks whether or not :rfc:`6855`
+  support is enabled.  Milan Oberkirch, R. David Murray, and Maciej Szulik in
+  :issue:`21800`.)
+
+* :mod:`imaplib` now automatically encodes non-ASCII string usernames and
+  passwords using ``UTF8``, as recommended by the RFCs.  (Contributed by Milan
+  Oberkirch in :issue:`21800`.)
+
 imghdr
 ------
 
diff --git a/Lib/imaplib.py b/Lib/imaplib.py
--- a/Lib/imaplib.py
+++ b/Lib/imaplib.py
@@ -66,6 +66,7 @@
         'CREATE':       ('AUTH', 'SELECTED'),
         'DELETE':       ('AUTH', 'SELECTED'),
         'DELETEACL':    ('AUTH', 'SELECTED'),
+        'ENABLE':       ('AUTH', ),
         'EXAMINE':      ('AUTH', 'SELECTED'),
         'EXPUNGE':      ('SELECTED',),
         'FETCH':        ('SELECTED',),
@@ -107,12 +108,17 @@
         br' (?P<hour>[0-9][0-9]):(?P<min>[0-9][0-9]):(?P<sec>[0-9][0-9])'
         br' (?P<zonen>[-+])(?P<zoneh>[0-9][0-9])(?P<zonem>[0-9][0-9])'
         br'"')
+# Literal is no longer used; kept for backward compatibility.
 Literal = re.compile(br'.*{(?P<size>\d+)}$', re.ASCII)
 MapCRLF = re.compile(br'\r\n|\r|\n')
 Response_code = re.compile(br'\[(?P<type>[A-Z-]+)( (?P<data>[^\]]*))?\]')
 Untagged_response = re.compile(br'\* (?P<type>[A-Z-]+)( (?P<data>.*))?')
+# Untagged_status is no longer used; kept for backward compatibility
 Untagged_status = re.compile(
     br'\* (?P<data>\d+) (?P<type>[A-Z-]+)( (?P<data2>.*))?', re.ASCII)
+# We compile these in _mode_xxx.
+_Literal = br'.*{(?P<size>\d+)}$'
+_Untagged_status = br'\* (?P<data>\d+) (?P<type>[A-Z-]+)( (?P<data2>.*))?'
 
 
 
@@ -166,7 +172,7 @@
     class abort(error): pass        # Service errors - close and retry
     class readonly(abort): pass     # Mailbox status changed to READ-ONLY
 
-    def __init__(self, host = '', port = IMAP4_PORT):
+    def __init__(self, host='', port=IMAP4_PORT):
         self.debug = Debug
         self.state = 'LOGOUT'
         self.literal = None             # A literal argument to a command
@@ -176,6 +182,7 @@
         self.is_readonly = False        # READ-ONLY desired state
         self.tagnum = 0
         self._tls_established = False
+        self._mode_ascii()
 
         # Open socket to server.
 
@@ -190,6 +197,19 @@
                 pass
             raise
 
+    def _mode_ascii(self):
+        self.utf8_enabled = False
+        self._encoding = 'ascii'
+        self.Literal = re.compile(_Literal, re.ASCII)
+        self.Untagged_status = re.compile(_Untagged_status, re.ASCII)
+
+
+    def _mode_utf8(self):
+        self.utf8_enabled = True
+        self._encoding = 'utf-8'
+        self.Literal = re.compile(_Literal)
+        self.Untagged_status = re.compile(_Untagged_status)
+
 
     def _connect(self):
         # Create unique tag for this session,
@@ -360,7 +380,10 @@
             date_time = Time2Internaldate(date_time)
         else:
             date_time = None
-        self.literal = MapCRLF.sub(CRLF, message)
+        literal = MapCRLF.sub(CRLF, message)
+        if self.utf8_enabled:
+            literal = b'UTF8 (' + literal + b')'
+        self.literal = literal
         return self._simple_command(name, mailbox, flags, date_time)
 
 
@@ -455,6 +478,18 @@
         """
         return self._simple_command('DELETEACL', mailbox, who)
 
+    def enable(self, capability):
+        """Send an RFC5161 enable string to the server.
+
+        (typ, [data]) = <intance>.enable(capability)
+        """
+        if 'ENABLE' not in self.capabilities:
+            raise IMAP4.error("Server does not support ENABLE")
+        typ, data = self._simple_command('ENABLE', capability)
+        if typ == 'OK' and 'UTF8=ACCEPT' in capability.upper():
+            self._mode_utf8()
+        return typ, data
+
     def expunge(self):
         """Permanently remove deleted items from selected mailbox.
 
@@ -561,7 +596,7 @@
     def _CRAM_MD5_AUTH(self, challenge):
         """ Authobject to use with CRAM-MD5 authentication. """
         import hmac
-        pwd = (self.password.encode('ASCII') if isinstance(self.password, str)
+        pwd = (self.password.encode('utf-8') if isinstance(self.password, str)
                                              else self.password)
         return self.user + " " + hmac.HMAC(pwd, challenge, 'md5').hexdigest()
 
@@ -661,9 +696,12 @@
         (typ, [data]) = <instance>.search(charset, criterion, ...)
 
         'data' is space separated list of matching message numbers.
+        If UTF8 is enabled, charset MUST be None.
         """
         name = 'SEARCH'
         if charset:
+            if self.utf8_enabled:
+                raise IMAP4.error("Non-None charset not valid in UTF8 mode")
             typ, dat = self._simple_command(name, 'CHARSET', charset, *criteria)
         else:
             typ, dat = self._simple_command(name, *criteria)
@@ -877,7 +915,7 @@
     def _check_bye(self):
         bye = self.untagged_responses.get('BYE')
         if bye:
-            raise self.abort(bye[-1].decode('ascii', 'replace'))
+            raise self.abort(bye[-1].decode(self._encoding, 'replace'))
 
 
     def _command(self, name, *args):
@@ -898,12 +936,12 @@
             raise self.readonly('mailbox status changed to READ-ONLY')
 
         tag = self._new_tag()
-        name = bytes(name, 'ASCII')
+        name = bytes(name, self._encoding)
         data = tag + b' ' + name
         for arg in args:
             if arg is None: continue
             if isinstance(arg, str):
-                arg = bytes(arg, "ASCII")
+                arg = bytes(arg, self._encoding)
             data = data + b' ' + arg
 
         literal = self.literal
@@ -913,7 +951,7 @@
                 literator = literal
             else:
                 literator = None
-                data = data + bytes(' {%s}' % len(literal), 'ASCII')
+                data = data + bytes(' {%s}' % len(literal), self._encoding)
 
         if __debug__:
             if self.debug >= 4:
@@ -978,7 +1016,7 @@
         typ, dat = self.capability()
         if dat == [None]:
             raise self.error('no CAPABILITY response from server')
-        dat = str(dat[-1], "ASCII")
+        dat = str(dat[-1], self._encoding)
         dat = dat.upper()
         self.capabilities = tuple(dat.split())
 
@@ -997,10 +1035,10 @@
         if self._match(self.tagre, resp):
             tag = self.mo.group('tag')
             if not tag in self.tagged_commands:
-                raise self.abort('unexpected tagged response: %s' % resp)
+                raise self.abort('unexpected tagged response: %r' % resp)
 
             typ = self.mo.group('type')
-            typ = str(typ, 'ASCII')
+            typ = str(typ, self._encoding)
             dat = self.mo.group('data')
             self.tagged_commands[tag] = (typ, [dat])
         else:
@@ -1009,7 +1047,7 @@
             # '*' (untagged) responses?
 
             if not self._match(Untagged_response, resp):
-                if self._match(Untagged_status, resp):
+                if self._match(self.Untagged_status, resp):
                     dat2 = self.mo.group('data2')
 
             if self.mo is None:
@@ -1019,17 +1057,17 @@
                     self.continuation_response = self.mo.group('data')
                     return None     # NB: indicates continuation
 
-                raise self.abort("unexpected response: '%s'" % resp)
+                raise self.abort("unexpected response: %r" % resp)
 
             typ = self.mo.group('type')
-            typ = str(typ, 'ascii')
+            typ = str(typ, self._encoding)
             dat = self.mo.group('data')
             if dat is None: dat = b''        # Null untagged response
             if dat2: dat = dat + b' ' + dat2
 
             # Is there a literal to come?
 
-            while self._match(Literal, dat):
+            while self._match(self.Literal, dat):
 
                 # Read literal direct from connection.
 
@@ -1053,7 +1091,7 @@
 
         if typ in ('OK', 'NO', 'BAD') and self._match(Response_code, dat):
             typ = self.mo.group('type')
-            typ = str(typ, "ASCII")
+            typ = str(typ, self._encoding)
             self._append_untagged(typ, self.mo.group('data'))
 
         if __debug__:
@@ -1123,7 +1161,7 @@
 
     def _new_tag(self):
 
-        tag = self.tagpre + bytes(str(self.tagnum), 'ASCII')
+        tag = self.tagpre + bytes(str(self.tagnum), self._encoding)
         self.tagnum = self.tagnum + 1
         self.tagged_commands[tag] = None
         return tag
@@ -1213,7 +1251,8 @@
         """
 
 
-        def __init__(self, host='', port=IMAP4_SSL_PORT, keyfile=None, certfile=None, ssl_context=None):
+        def __init__(self, host='', port=IMAP4_SSL_PORT, keyfile=None,
+                     certfile=None, ssl_context=None):
             if ssl_context is not None and keyfile is not None:
                 raise ValueError("ssl_context and keyfile arguments are mutually "
                                  "exclusive")
@@ -1251,7 +1290,7 @@
 
     Instantiate with: IMAP4_stream(command)
 
-            where "command" is a string that can be passed to subprocess.Popen()
+            "command" - a string that can be passed to subprocess.Popen()
 
     for more documentation see the docstring of the parent class IMAP4.
     """
@@ -1328,7 +1367,7 @@
         #
         oup = b''
         if isinstance(inp, str):
-            inp = inp.encode('ASCII')
+            inp = inp.encode('utf-8')
         while inp:
             if len(inp) > 48:
                 t = inp[:48]
diff --git a/Lib/test/test_imaplib.py b/Lib/test/test_imaplib.py
--- a/Lib/test/test_imaplib.py
+++ b/Lib/test/test_imaplib.py
@@ -265,6 +265,84 @@
             self.assertRaises(imaplib.IMAP4.abort,
                               self.imap_class, *server.server_address)
 
+    class UTF8Server(SimpleIMAPHandler):
+        capabilities = 'AUTH ENABLE UTF8=ACCEPT'
+
+        def cmd_ENABLE(self, tag, args):
+            self._send_tagged(tag, 'OK', 'ENABLE successful')
+
+        def cmd_AUTHENTICATE(self, tag, args):
+            self._send_textline('+')
+            self.server.response = yield
+            self._send_tagged(tag, 'OK', 'FAKEAUTH successful')
+
+    @reap_threads
+    def test_enable_raises_error_if_not_AUTH(self):
+        with self.reaped_pair(self.UTF8Server) as (server, client):
+            self.assertFalse(client.utf8_enabled)
+            self.assertRaises(imaplib.IMAP4.error, client.enable, 'foo')
+            self.assertFalse(client.utf8_enabled)
+
+    # XXX Also need a test that enable after SELECT raises an error.
+
+    @reap_threads
+    def test_enable_raises_error_if_no_capability(self):
+        class NoEnableServer(self.UTF8Server):
+            capabilities = 'AUTH'
+        with self.reaped_pair(NoEnableServer) as (server, client):
+            self.assertRaises(imaplib.IMAP4.error, client.enable, 'foo')
+
+    @reap_threads
+    def test_enable_UTF8_raises_error_if_not_supported(self):
+        class NonUTF8Server(SimpleIMAPHandler):
+            pass
+        with self.assertRaises(imaplib.IMAP4.error):
+            with self.reaped_pair(NonUTF8Server) as (server, client):
+                typ, data = client.login('user', 'pass')
+                self.assertEqual(typ, 'OK')
+                client.enable('UTF8=ACCEPT')
+                pass
+
+    @reap_threads
+    def test_enable_UTF8_True_append(self):
+
+        class UTF8AppendServer(self.UTF8Server):
+            def cmd_APPEND(self, tag, args):
+                self._send_textline('+')
+                self.server.response = yield
+                self._send_tagged(tag, 'OK', 'okay')
+
+        with self.reaped_pair(UTF8AppendServer) as (server, client):
+            self.assertEqual(client._encoding, 'ascii')
+            code, _ = client.authenticate('MYAUTH', lambda x: b'fake')
+            self.assertEqual(code, 'OK')
+            self.assertEqual(server.response,
+                             b'ZmFrZQ==\r\n')  # b64 encoded 'fake'
+            code, _ = client.enable('UTF8=ACCEPT')
+            self.assertEqual(code, 'OK')
+            self.assertEqual(client._encoding, 'utf-8')
+            msg_string = 'Subject: üñí©öðé'
+            typ, data = client.append(
+                None, None, None, msg_string.encode('utf-8'))
+            self.assertEqual(typ, 'OK')
+            self.assertEqual(
+                server.response,
+                ('UTF8 (%s)\r\n' % msg_string).encode('utf-8')
+            )
+
+    # XXX also need a test that makes sure that the Literal and Untagged_status
+    # regexes uses unicode in UTF8 mode instead of the default ASCII.
+
+    @reap_threads
+    def test_search_disallows_charset_in_utf8_mode(self):
+        with self.reaped_pair(self.UTF8Server) as (server, client):
+            typ, _ = client.authenticate('MYAUTH', lambda x: b'fake')
+            self.assertEqual(typ, 'OK')
+            typ, _ = client.enable('UTF8=ACCEPT')
+            self.assertEqual(typ, 'OK')
+            self.assertTrue(client.utf8_enabled)
+            self.assertRaises(imaplib.IMAP4.error, client.search, 'foo', 'bar')
+
     @reap_threads
     def test_bad_auth_name(self):
 
diff --git a/Misc/NEWS b/Misc/NEWS
--- a/Misc/NEWS
+++ b/Misc/NEWS
@@ -38,6 +38,10 @@
 Library
 -------
 
+- Issue #21800: imaplib now supports RFC 5161 (enable), RFC 6855
+  (utf8/internationalized email) and automatically encodes non-ASCII
+  usernames and passwords to UTF8.
+
 - Issue #24134: assertRaises(), assertRaisesRegex(), assertWarns() and
   assertWarnsRegex() checks are not longer successful if the callable is None.
 
diff --git a/Objects/genobject.c b/Objects/genobject.c
--- a/Objects/genobject.c
+++ b/Objects/genobject.c
@@ -149,9 +149,9 @@
                 "generator raised StopIteration");
             PyErr_Fetch(&exc, &val2, &tb);
             PyErr_NormalizeException(&exc, &val2, &tb);
+            Py_INCREF(val);
             PyException_SetCause(val2, val);
             PyException_SetContext(val2, val);
-            Py_INCREF(val);
             PyErr_Restore(exc, val2, tb);
         }
     }

-- 
Repository URL: https://hg.python.org/cpython


More information about the Python-checkins mailing list