From d916684cb901c8dbc765697c62a8538d11ab1a04 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Mon, 6 Jul 2026 17:06:14 +0300 Subject: [PATCH 1/6] gh-153521: Support structured arguments in imaplib commands Command methods now accept a structured *message_set* (an integer, or a sequence of integers, (start, stop) ranges and range objects) and lists of flags or other atoms in place of preformatted parenthesized strings. The search, fetch, sort, thread and uid methods gain a keyword-only *params* argument that substitutes and quotes '?' placeholders in their value-bearing arguments, in the manner of sqlite3 parameter substitution. Co-Authored-By: Claude Opus 4.8 --- Doc/library/imaplib.rst | 106 ++++++++-- Doc/whatsnew/3.16.rst | 11 ++ Lib/imaplib.py | 147 +++++++++++++- Lib/test/test_imaplib.py | 182 ++++++++++++++++++ ...-07-06-14-30-00.gh-issue-153521.iMaP47.rst | 7 + 5 files changed, 429 insertions(+), 24 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-06-14-30-00.gh-issue-153521.iMaP47.rst diff --git a/Doc/library/imaplib.rst b/Doc/library/imaplib.rst index 7b79d81790b667..afdc0f0daf8cc6 100644 --- a/Doc/library/imaplib.rst +++ b/Doc/library/imaplib.rst @@ -217,6 +217,62 @@ of message numbers (``'2:4'``), or a group of non-contiguous ranges separated by commas (``'1:3,6:9'``). A range can contain an asterisk to indicate an infinite upper bound (``'3:*'``). +It may also be given in a structured form: +an integer, +or a sequence whose items are integers, +``(start, stop)`` range tuples (where ``None`` or ``'*'`` stands for the last +message), +or :class:`range` objects. +For example, ``[1, (3, 5), 8]`` and ``[range(1, 6), 8]`` are both equivalent to +``'1,3:5,8'``. + +.. versionchanged:: next + Added support for the structured *message_set*. + +Arguments that are parenthesized lists of atoms --- +such as the *flag_list* argument of :meth:`~IMAP4.store` and the *flags* +argument of :meth:`~IMAP4.append`, +the *names* argument of :meth:`~IMAP4.status`, +the *sort_criteria* argument of :meth:`~IMAP4.sort`, +or the *message_parts* argument of :meth:`~IMAP4.fetch` --- +can be passed as a sequence of strings instead of a preformatted string. +For example, ``[r'\Seen', r'\Answered']`` is sent as ``(\Seen \Answered)``. + +.. versionchanged:: next + Added support for passing these arguments as a sequence. + +.. _imap4-params: + +The value-bearing arguments of the search and fetch commands must otherwise be +quoted by hand. +Instead, they may contain ``?`` placeholders that are substituted, and quoted +as required, from a *params* keyword argument, +in the manner of :mod:`sqlite3` parameter substitution:: + + # SEARCH FROM me@example.com SUBJECT "trip report" + M.search(None, 'FROM ? SUBJECT ?', params=['me@example.com', 'trip report']) + + # FETCH 1:5 (FLAGS BODY[HEADER.FIELDS (DATE FROM)]) + M.fetch('1:5', 'FLAGS BODY[HEADER.FIELDS ?]', params=[['DATE', 'FROM']]) + +The placeholders are: + +* ``?`` --- an astring: a string (quoted if necessary), an integer, or a + list of them (sent as a parenthesized list); +* ``?f`` --- a flag or a list of flags, sent verbatim without quoting; +* ``?s`` --- a *message_set* in the structured form described above. + +``??`` stands for a literal ``?``. + +Substitution is only performed when *params* is given, +so an existing call that contains a literal ``?`` is unaffected. +The *params* keyword is accepted by :meth:`~IMAP4.search`, +:meth:`~IMAP4.fetch`, :meth:`~IMAP4.sort`, :meth:`~IMAP4.thread` and +:meth:`~IMAP4.uid`. + +.. versionadded:: next + The *params* keyword argument. + An :class:`IMAP4` instance has the following methods: @@ -324,7 +380,7 @@ An :class:`IMAP4` instance has the following methods: Added the *message_set* and *uid* parameters. -.. method:: IMAP4.fetch(message_set, message_parts, *, uid=False) +.. method:: IMAP4.fetch(message_set, message_parts, *, uid=False, params=None) Fetch (parts of) messages. *message_parts* should be a string of message part names enclosed within parentheses, eg: ``"(UID BODY[TEXT])"``. Returned data @@ -333,8 +389,11 @@ An :class:`IMAP4` instance has the following methods: If *uid* is true, *message_set* is a set of UIDs and the message numbers in the response are UIDs (``UID FETCH``). + If *params* is given, ``?`` placeholders in *message_parts* are substituted + with the quoted parameters (see :ref:`the placeholders `). + .. versionchanged:: next - Added the *uid* parameter. + Added the *params* and *uid* parameters. .. method:: IMAP4.getacl(mailbox) @@ -598,7 +657,7 @@ An :class:`IMAP4` instance has the following methods: code, instead of the usual type. -.. method:: IMAP4.search(charset, criterion[, ...], *, uid=False) +.. method:: IMAP4.search(charset, criterion[, ...], *, uid=False, params=None) 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 @@ -617,18 +676,22 @@ An :class:`IMAP4` instance has the following methods: When *charset* is ``None`` (as it must be under ``UTF8=ACCEPT``), the criterion is sent using the connection's encoding instead. + If *params* is given, ``?`` placeholders in the criteria are substituted + with the quoted parameters (see :ref:`the placeholders `). + Example:: # M is a connected IMAP4 instance... - typ, msgnums = M.search(None, 'FROM', '"LDJ"') + typ, msgnums = M.search(None, 'FROM', '"John Smith"') # or: - typ, msgnums = M.search(None, '(FROM "LDJ")') + typ, msgnums = M.search(None, '(FROM "John Smith")') - .. versionchanged:: next - Added the *uid* parameter. + # or, letting the module quote the value: + typ, msgnums = M.search(None, 'FROM ?', params=['John Smith']) .. versionchanged:: next + Added the *params* and *uid* parameters. ``str`` search criteria are encoded to *charset*. @@ -675,7 +738,7 @@ An :class:`IMAP4` instance has the following methods: Returns socket instance used to connect to server. -.. method:: IMAP4.sort(sort_criteria, charset, search_criterion[, ...], *, uid=False) +.. method:: IMAP4.sort(sort_criteria, charset, search_criterion[, ...], *, uid=False, params=None) The ``sort`` command is a variant of ``search`` with sorting semantics for the results. Returned data contains a space separated list of matching message @@ -696,12 +759,13 @@ An :class:`IMAP4` instance has the following methods: a *search_criterion* passed as :class:`str` is encoded to *charset*; pass :class:`bytes` to send one already encoded. - This is an ``IMAP4rev1`` extension command. + If *params* is given, ``?`` placeholders in the search criteria are + substituted with the quoted parameters (see :ref:`the placeholders `). - .. versionchanged:: next - Added the *uid* parameter. + This is an ``IMAP4rev1`` extension command. .. versionchanged:: next + Added the *params* and *uid* parameters. ``str`` search criteria are encoded to *charset*. @@ -745,7 +809,7 @@ An :class:`IMAP4` instance has the following methods: typ, data = M.search(None, 'ALL') for num in data[0].split(): - M.store(num, '+FLAGS', '\\Deleted') + M.store(num, '+FLAGS', r'\Deleted') M.expunge() .. note:: @@ -768,7 +832,7 @@ An :class:`IMAP4` instance has the following methods: Subscribe to new mailbox. -.. method:: IMAP4.thread(threading_algorithm, charset, search_criterion[, ...], *, uid=False) +.. method:: IMAP4.thread(threading_algorithm, charset, search_criterion[, ...], *, uid=False, params=None) The ``thread`` command is a variant of ``search`` with threading semantics for the results. Returned data contains a space separated list of thread members. @@ -793,22 +857,30 @@ An :class:`IMAP4` instance has the following methods: a *search_criterion* passed as :class:`str` is encoded to *charset*; pass :class:`bytes` to send one already encoded. - This is an ``IMAP4rev1`` extension command. + If *params* is given, ``?`` placeholders in the search criteria are + substituted with the quoted parameters (see :ref:`the placeholders `). - .. versionchanged:: next - Added the *uid* parameter. + This is an ``IMAP4rev1`` extension command. .. versionchanged:: next + Added the *params* and *uid* parameters. ``str`` search criteria are encoded to *charset*. -.. method:: IMAP4.uid(command, arg[, ...]) +.. method:: IMAP4.uid(command, arg[, ...], *, params=None) Execute command args with messages identified by UID, rather than message number. Returns response appropriate to command. At least one argument must be supplied; if none are provided, the server will return an error and an exception will be raised. + If *params* is given, ``?`` placeholders in the ``SEARCH``, ``SORT`` and + ``THREAD`` criteria or in the ``FETCH`` parts are substituted with the quoted + parameters (see :ref:`the placeholders `). + + .. versionchanged:: next + Added the *params* parameter. + .. method:: IMAP4.unsubscribe(mailbox) diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 06c3dbb2f0f1ad..d3e621e62a18ca 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -284,6 +284,17 @@ imaplib the criteria are sent using the connection encoding instead. (Contributed by Serhiy Storchaka in :gh:`153494`.) +* Command methods now accept structured arguments, + so the module takes care of quoting instead of the caller. + A *message_set* and lists of flags or other atoms + can be passed as sequences instead of preformatted strings, + and the :meth:`~imaplib.IMAP4.search`, :meth:`~imaplib.IMAP4.fetch`, + :meth:`~imaplib.IMAP4.sort`, :meth:`~imaplib.IMAP4.thread` and + :meth:`~imaplib.IMAP4.uid` methods accept a *params* keyword argument + that substitutes and quotes ``?`` placeholders, + in the manner of :mod:`sqlite3` parameter substitution. + (Contributed by Serhiy Storchaka in :gh:`153521`.) + ipaddress --------- diff --git a/Lib/imaplib.py b/Lib/imaplib.py index ed5b528976448a..a0d04a878f152a 100644 --- a/Lib/imaplib.py +++ b/Lib/imaplib.py @@ -133,6 +133,7 @@ # Only NUL, CR and LF are unsafe (they cannot be represented even in # a quoted string); other control characters are sent quoted. _control_chars = re.compile(b'[\x00\r\n]') +_control_chars_str = re.compile('[\x00\r\n]') _non_astring_char = re.compile(br'[(){ \x00-\x1f\x7f-\xff%*\\"]') _non_list_char = re.compile(br'[(){ \x00-\x1f\x7f-\xff\\"]') _quoted = re.compile(br'"(?:[^"\\]|\\.)*+"') @@ -144,6 +145,98 @@ def _paren_depth(data, depth=0): return depth + data.count(b'(') - data.count(b')') +def _seq_number(n): + # A single message number; None or '*' is the last message. + if n is None or n == '*': + return '*' + if not isinstance(n, int): + raise TypeError('message number must be an integer, not %s' + % type(n).__name__) + return str(n) + + +def _seq_range(item): + if isinstance(item, range): + if item.step == 1 and len(item): + item = (item.start, item[-1]) + else: + return ','.join(map(str, item)) + if isinstance(item, tuple): + start, stop = item + return '%s:%s' % (_seq_number(start), _seq_number(stop)) + return _seq_number(item) + + +def _format_sequence_set(arg): + if isinstance(arg, (int, str)): + return str(arg) + # A sequence of message numbers and ranges. + return ','.join(map(_seq_range, arg)) + + +# Characters that prevent a string from being sent as a bare atom. +_astring_special = re.compile(r'[(){ %*\\"\x00-\x1f\x7f-\U0010ffff]') +# A flag: an atom, optionally prefixed by a backslash. +_flag = re.compile(r'\\?[^(){ %*"\\\]\x00-\x1f\x7f-\U0010ffff]+') +# A placeholder in a format string: '?', '?f', '?s' or the escape '??'. +_placeholder = re.compile(r'\?[?fs]?') + + +def _format_astring(value): + if isinstance(value, (list, tuple)): + return '(' + ' '.join(map(_format_astring, value)) + ')' + if isinstance(value, bool): + raise TypeError('a boolean is not a valid IMAP4 string') + if isinstance(value, int): + return str(value) + if isinstance(value, (bytes, bytearray)): + value = str(value, 'ascii') + elif not isinstance(value, str): + raise TypeError('expected a string, an integer or a list, not %s' + % type(value).__name__) + if value and _astring_special.search(value) is None: + return value # an atom, sent unquoted + if _control_chars_str.search(value): + raise ValueError('NUL, CR and LF cannot be represented inline: %r' + % value) + return '"' + value.replace('\\', r'\\').replace('"', r'\"') + '"' + + +def _format_flags(value): + if isinstance(value, (list, tuple)): + return '(' + ' '.join(map(_format_flags, value)) + ')' + if isinstance(value, (bytes, bytearray)): + value = str(value, 'ascii') + elif not isinstance(value, str): + raise TypeError('expected a flag string or a list, not %s' + % type(value).__name__) + if not _flag.fullmatch(value): + raise ValueError('invalid flag: %r' % value) + return value + + +def _substitute(format, params): + params = iter(params) + def replace(match): + spec = match.group() + if spec == '??': # an escaped literal '?' + return '?' + try: + value = next(params) + except StopIteration: + raise TypeError('not enough parameters for the format string') \ + from None + if spec == '?f': # a flag or a list of flags + return _format_flags(value) + if spec == '?s': # a message sequence set + return _format_sequence_set(value) + return _format_astring(value) # an astring or a list of astrings + result = _placeholder.sub(replace, format) + for value in params: + raise TypeError('too many parameters for the format string') + return result + + class IMAP4: r"""IMAP4 client class. @@ -665,7 +758,7 @@ def expunge(self, message_set=None, *, uid=False): return self._untagged_response(typ, dat, name) - def fetch(self, message_set, message_parts, *, uid=False): + def fetch(self, message_set, message_parts, *, uid=False, params=None): """Fetch (parts of) messages. (typ, [data, ...]) = .fetch(message_set, message_parts) @@ -673,12 +766,17 @@ def fetch(self, message_set, message_parts, *, uid=False): 'message_parts' should be a string of selected parts enclosed in parentheses, eg: "(UID BODY[TEXT])". + If 'params' is given, '?' placeholders in 'message_parts' are + substituted with the quoted parameters. + 'data' are tuples of message part envelope and data. If 'uid' is true, 'message_set' is a set of UIDs and the message numbers in the response are UIDs (UID FETCH). """ name = 'FETCH' + if params is not None: + message_parts = _substitute(message_parts, params) args = (self._sequence_set(message_set), self._fetch_parts(message_parts)) if uid: @@ -941,7 +1039,7 @@ def rename(self, oldmailbox, newmailbox): self._mailbox(newmailbox)) - def search(self, charset, *criteria, uid=False): + def search(self, charset, *criteria, uid=False, params=None): """Search mailbox for matching messages. (typ, [data]) = .search(charset, criterion, ...) @@ -953,8 +1051,13 @@ def search(self, charset, *criteria, uid=False): A 'criteria' passed as str is encoded to 'charset'; pass bytes to send criteria that are already encoded. + + If 'params' is given, '?' placeholders in the criteria are + substituted with the quoted parameters. """ name = 'SEARCH' + if params is not None: + criteria = (_substitute(' '.join(criteria), params),) if charset is not None: if self.utf8_enabled: raise IMAP4.error("Non-None charset not valid in UTF8 mode") @@ -1028,18 +1131,24 @@ def setquota(self, root, limits): return self._untagged_response(typ, dat, 'QUOTA') - def sort(self, sort_criteria, charset, *search_criteria, uid=False): + def sort(self, sort_criteria, charset, *search_criteria, uid=False, + params=None): """IMAP4rev1 extension SORT command. (typ, [data]) = .sort(sort_criteria, charset, search_criteria, ...) If 'uid' is true, the message numbers in the response are UIDs (UID SORT). + + If 'params' is given, '?' placeholders in the search criteria are + substituted with the quoted parameters. """ name = 'SORT' #if not name in self.capabilities: # Let the server decide! # raise self.error('unimplemented extension command: %s' % name) sort_criteria = self._set_quote(sort_criteria) + if params is not None: + search_criteria = (_substitute(' '.join(search_criteria), params),) search_criteria = self._encode_criteria(charset, search_criteria) if charset is not None: charset = self._astring(charset) @@ -1113,15 +1222,21 @@ def subscribe(self, mailbox): return self._simple_command('SUBSCRIBE', self._mailbox(mailbox)) - def thread(self, threading_algorithm, charset, *search_criteria, uid=False): + def thread(self, threading_algorithm, charset, *search_criteria, uid=False, + params=None): """IMAPrev1 extension THREAD command. (type, [data]) = .thread(threading_algorithm, charset, search_criteria, ...) If 'uid' is true, the message numbers in the response are UIDs (UID THREAD). + + If 'params' is given, '?' placeholders in the search criteria are + substituted with the quoted parameters. """ name = 'THREAD' + if params is not None: + search_criteria = (_substitute(' '.join(search_criteria), params),) search_criteria = self._encode_criteria(charset, search_criteria) if charset is not None: charset = self._astring(charset) @@ -1133,13 +1248,17 @@ def thread(self, threading_algorithm, charset, *search_criteria, uid=False): return self._untagged_response(typ, dat, name) - def uid(self, command, *args): + def uid(self, command, *args, params=None): """Execute "command arg ..." with messages identified by UID, rather than message number. (typ, [data]) = .uid(command, arg1, arg2, ...) Returns response appropriate to 'command'. + + If 'params' is given, '?' placeholders in the SEARCH, SORT and + THREAD criteria or in the FETCH parts are substituted with the + quoted parameters. """ command = command.upper() if not command in Commands: @@ -1156,6 +1275,8 @@ def uid(self, command, *args): self._mailbox(new_mailbox)) elif command == 'FETCH': message_set, message_parts = args + if params is not None: + message_parts = _substitute(message_parts, params) args = (self._sequence_set(message_set), self._fetch_parts(message_parts)) elif command == 'STORE': @@ -1164,6 +1285,9 @@ def uid(self, command, *args): self._set_quote(flags)) elif command == 'SORT': sort_criteria, charset, *search_criteria = args + if params is not None: + search_criteria = (_substitute(' '.join(search_criteria), + params),) search_criteria = self._encode_criteria(charset, search_criteria) if charset is not None: charset = self._astring(charset) @@ -1171,11 +1295,16 @@ def uid(self, command, *args): *search_criteria) elif command == 'THREAD': threading_algorithm, charset, *search_criteria = args + if params is not None: + search_criteria = (_substitute(' '.join(search_criteria), + params),) search_criteria = self._encode_criteria(charset, search_criteria) if charset is not None: charset = self._astring(charset) args = (self._atom(threading_algorithm), charset, *search_criteria) + elif command == 'SEARCH' and params is not None: + args = (_substitute(' '.join(args), params),) typ, dat = self._simple_command(name, self._atom(command), *args) if command in ('SEARCH', 'SORT', 'THREAD'): name = command @@ -1570,9 +1699,13 @@ def _atom(self, arg): return arg def _sequence_set(self, arg): - return arg + return _format_sequence_set(arg) def _set_quote(self, arg): + if not isinstance(arg, str): + # A sequence of atoms (flags, criteria, item names, etc.); + # wrap them in parentheses as a single argument. + return '(' + ' '.join(arg) + ')' if arg and arg[0] == '(' and arg[-1] == ')': return arg return '(' + arg + ')' @@ -1580,7 +1713,7 @@ def _set_quote(self, arg): def _fetch_parts(self, arg): # "ALL", "FULL" and "FAST" are macros, not data item names; # they cannot be enclosed in parentheses. - if arg.upper() in ('ALL', 'FULL', 'FAST'): + if isinstance(arg, str) and arg.upper() in ('ALL', 'FULL', 'FAST'): return arg return self._set_quote(arg) diff --git a/Lib/test/test_imaplib.py b/Lib/test/test_imaplib.py index 3eaca67299e7c3..7a45ed1ab38ea4 100644 --- a/Lib/test/test_imaplib.py +++ b/Lib/test/test_imaplib.py @@ -287,6 +287,64 @@ def test_mailbox(self): self.assertEqual(m._mailbox('Entwürfe'), '"Entwürfe"'.encode()) self.assertEqual(m._mailbox('Entw&APw-rfe'), b'Entw&APw-rfe') + def test_sequence_set(self): + m = imaplib.IMAP4.__new__(imaplib.IMAP4) + # A scalar is passed through as a string. + self.assertEqual(m._sequence_set(5), '5') + self.assertEqual(m._sequence_set('1:3,7'), '1:3,7') + # A sequence of numbers and ranges is formatted as a sequence set. + self.assertEqual(m._sequence_set([1, 2, 5]), '1,2,5') + self.assertEqual(m._sequence_set([1, (3, 5), (8, '*')]), '1,3:5,8:*') + self.assertEqual(m._sequence_set([(5, None)]), '5:*') + # A range is inclusive; a non-unit step falls back to explicit numbers. + self.assertEqual(m._sequence_set([range(1, 4), 7]), '1:3,7') + self.assertEqual(m._sequence_set([range(1, 10, 2)]), '1,3,5,7,9') + # Message numbers must be integers: a string is not coerced (the + # string form is the whole preformatted set), nor is a float. + self.assertRaises(TypeError, m._sequence_set, ['7']) + self.assertRaises(TypeError, m._sequence_set, [2.9]) + self.assertRaises(TypeError, m._sequence_set, [(1, 2.9)]) + + def test_set_quote(self): + m = imaplib.IMAP4.__new__(imaplib.IMAP4) + # A string is parenthesized unless it already is. + self.assertEqual(m._set_quote(r'\Seen'), r'(\Seen)') + self.assertEqual(m._set_quote(r'(\Seen)'), r'(\Seen)') + # A sequence of atoms is joined and parenthesized. + self.assertEqual(m._set_quote([r'\Seen', r'\Answered']), + r'(\Seen \Answered)') + self.assertEqual(m._set_quote(['MESSAGES', 'UNSEEN']), + '(MESSAGES UNSEEN)') + + def test_substitute(self): + sub = imaplib._substitute + # '?' quotes an astring; an atom-safe value is left unquoted. + self.assertEqual(sub('FROM ?', ['me@host']), 'FROM me@host') + self.assertEqual(sub('SUBJECT ?', ['hello world']), + 'SUBJECT "hello world"') + self.assertEqual(sub('SUBJECT ?', ['a"b']), 'SUBJECT "a\\"b"') + # An integer becomes a number, a list a parenthesized list. + self.assertEqual(sub('LARGER ?', [1000]), 'LARGER 1000') + self.assertEqual(sub('HEADER.FIELDS ?', [['DATE', 'FROM']]), + 'HEADER.FIELDS (DATE FROM)') + # '?f' emits flags verbatim, never quoted. + self.assertEqual(sub('?f', [r'\Seen']), r'\Seen') + self.assertEqual(sub('?f', [[r'\Seen', r'\Answered']]), + r'(\Seen \Answered)') + # '?s' formats a message sequence set. + self.assertEqual(sub('?s', [[1, (3, 5), (8, '*')]]), '1,3:5,8:*') + # '??' is a literal '?'. + self.assertEqual(sub('a?? b', []), 'a? b') + + def test_substitute_errors(self): + sub = imaplib._substitute + self.assertRaises(TypeError, sub, '? ?', ['x']) # too few parameters + self.assertRaises(TypeError, sub, '?', ['x', 'y']) # too many parameters + self.assertRaises(ValueError, sub, '?f', ['a b']) # not a valid flag + self.assertRaises(ValueError, sub, '?', ['a\r\nb']) # CR/LF not inline + self.assertRaises(TypeError, sub, '?', [True]) # bool is not a string + self.assertRaises(TypeError, sub, '?', [1.5]) # float is not a string + if ssl: class SecureTCPServer(socketserver.TCPServer): @@ -1342,6 +1400,11 @@ def test_copy(self): self.assertEqual(data, [b'COPY completed']) self.assertEqual(server.args, ['2:4', '"New folder"']) + # A structured message set is formatted into a sequence set. + typ, data = client.copy([2, (3, 5)], 'MEETING') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['2,3:5', 'MEETING']) + def test_uid_copy(self): client, server = self._setup(make_simple_handler('UID', completed='UID COPY completed')) @@ -1363,6 +1426,11 @@ def test_uid_copy(self): self.assertEqual(data, [b'UID COPY completed']) self.assertEqual(server.args, ['COPY', '4827313:4828442', 'MEETING']) + # A structured message set is formatted into a sequence set. + typ, data = client.uid('copy', [1, (3, 5)], 'MEETING') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['COPY', '1,3:5', 'MEETING']) + def test_move(self): client, server = self._setup(make_simple_handler('MOVE')) client.login('user', 'pass') @@ -1377,6 +1445,11 @@ def test_move(self): self.assertEqual(data, [b'MOVE completed']) self.assertEqual(server.args, ['2:4', '"New folder"']) + # A structured message set is formatted into a sequence set. + typ, data = client.move([2, (3, 5)], 'MEETING') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['2,3:5', 'MEETING']) + def test_uid_move(self): client, server = self._setup(make_simple_handler('UID', completed='UID MOVE completed')) @@ -1398,6 +1471,11 @@ def test_uid_move(self): self.assertEqual(data, [b'UID MOVE completed']) self.assertEqual(server.args, ['MOVE', '4827313:4828442', 'MEETING']) + # A structured message set is formatted into a sequence set. + typ, data = client.uid('move', [1, (3, 5)], 'MEETING') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['MOVE', '1,3:5', 'MEETING']) + def test_store(self): client, server = self._setup(make_simple_handler('STORE', [ r'* 2 FETCH (FLAGS (\Deleted \Seen))', @@ -1419,6 +1497,12 @@ def test_store(self): self.assertEqual(typ, 'OK') self.assertEqual(server.args, ['2:4', '+FLAGS', r'(\Deleted)']) + # The flags may be a sequence, and the message set may be structured. + typ, data = client.store([2, (3, 4)], '+FLAGS', + [r'\Deleted', r'\Seen']) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['2,3:4', '+FLAGS', r'(\Deleted \Seen)']) + def test_uid_store(self): client, server = self._setup(make_simple_handler('UID', [ r'* 23 FETCH (FLAGS (\Deleted \Seen) UID 4827313)', @@ -1451,6 +1535,13 @@ def test_uid_store(self): ]) self.assertEqual(server.args, ['STORE', '4827313:4828442', '+FLAGS', r'(\Deleted)']) + # The flags may be a sequence. + typ, data = client.uid('store', '4827313:4828442', '+FLAGS', + [r'\Deleted', r'\Seen']) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, + ['STORE', '4827313:4828442', '+FLAGS', r'(\Deleted \Seen)']) + def test_fetch(self): # The handler expands the requested sequence set and answers for # exactly those messages, so the test exercises the round trip of @@ -1508,6 +1599,23 @@ def cmd_FETCH(self, tag, args): self.assertEqual(typ, 'OK') self.assertEqual(server.args, ['2:4', 'fast']) + # A structured message set is formatted into a sequence set. + typ, data = client.fetch([2, (3, 4)], '(FLAGS)') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['2,3:4', '(FLAGS)']) + + # message_parts may be a sequence of items. + typ, data = client.fetch('1', ['UID', 'FLAGS']) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['1', '(UID FLAGS)']) + + # 'params' substitutes and quotes '?' placeholders. + typ, data = client.fetch('1', 'FLAGS BODY[HEADER.FIELDS ?]', + params=[['DATE', 'FROM']]) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, + ['1', '(FLAGS BODY[HEADER.FIELDS (DATE FROM)])']) + def test_uid_fetch(self): client, server = self._setup(make_simple_handler('UID', [ r'* 23 FETCH (FLAGS (\Seen) UID 4827313)', @@ -1543,6 +1651,18 @@ def test_uid_fetch(self): ]) self.assertEqual(server.args, ['FETCH', '4827313:4828442', '(FLAGS)']) + # message_parts may be a sequence, and 'params' substitutes '?'. + typ, data = client.uid('fetch', '4827313:4828442', ['UID', 'FLAGS']) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['FETCH', '4827313:4828442', '(UID FLAGS)']) + + typ, data = client.uid('fetch', '4827313:4828442', + 'BODY[HEADER.FIELDS ?]', params=[['DATE', 'FROM']]) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, + ['FETCH', '4827313:4828442', + '(BODY[HEADER.FIELDS (DATE FROM)])']) + def test_partial(self): client, server = self._setup(make_simple_handler('PARTIAL', ['* 1 FETCH (RFC822.TEXT<0.10> "0123456789")'])) @@ -1590,6 +1710,19 @@ def test_search(self): self.assertEqual(typ, 'OK') self.assertEqual(server.args, ['CHARSET', '"NF_Z_62-010_(1973)"', 'TEXT', 'XXXXXX']) + # 'params' substitutes and quotes '?' placeholders. + response[:] = ['* SEARCH 1'] + typ, data = client.search(None, 'FROM ? SUBJECT ?', + params=['me@host', 'trip report']) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, + ['FROM', 'me@host', 'SUBJECT', '"trip report"']) + + # Without 'params', a literal '?' is sent unchanged. + typ, data = client.search(None, 'SUBJECT', '"what?"') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['SUBJECT', '"what?"']) + def test_uid_search(self): response = [] client, server = self._setup(make_simple_handler('UID', response, @@ -1633,6 +1766,14 @@ def test_uid_search(self): self.assertEqual(data, [b'43']) self.assertEqual(server.args, ['SEARCH', 'CHARSET', 'UTF-8', 'TEXT', 'XXXXXX']) + # 'params' substitutes and quotes '?' placeholders. + response[:] = ['* SEARCH 1'] + typ, data = client.uid('SEARCH', 'FROM ? SUBJECT ?', + params=['me@host', 'trip report']) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, + ['SEARCH', 'FROM', 'me@host', 'SUBJECT', '"trip report"']) + def test_sort(self): response = [] client, server = self._setup(make_simple_handler('SORT', response)) @@ -1666,6 +1807,15 @@ def test_sort(self): self.assertEqual(typ, 'OK') self.assertIn('"Київ"'.encode('koi8-u'), server.line) + # sort_criteria may be a sequence, and 'params' substitutes and + # quotes '?' (a value with a space becomes a quoted string). + response[:] = ['* SORT 1'] + typ, data = client.sort(['REVERSE', 'DATE'], 'UTF-8', 'SUBJECT ?', + params=['trip report']) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, + ['(REVERSE DATE)', 'UTF-8', 'SUBJECT', '"trip report"']) + def test_uid_sort(self): response = [] client, server = self._setup(make_simple_handler('UID', response, @@ -1707,6 +1857,15 @@ def test_uid_sort(self): self.assertEqual(data, [br'2 84 882']) self.assertEqual(server.args, ['SORT', '(SUBJECT)', 'UTF-8', 'SINCE', '1-Feb-1994']) + # sort_criteria may be a sequence, and 'params' substitutes and + # quotes '?' (a value with a space becomes a quoted string). + response[:] = ['* SORT 1'] + typ, data = client.uid('sort', ['REVERSE', 'DATE'], 'UTF-8', 'SUBJECT ?', + params=['trip report']) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, + ['SORT', '(REVERSE DATE)', 'UTF-8', 'SUBJECT', '"trip report"']) + def test_thread(self): response = [] client, server = self._setup(make_simple_handler('THREAD', response)) @@ -1754,6 +1913,15 @@ def test_thread(self): self.assertEqual(typ, 'OK') self.assertIn('"Київ"'.encode('koi8-u'), server.line) + # 'params' substitutes and quotes '?' (a value with a space becomes + # a quoted string). + response[:] = ['* THREAD (1)'] + typ, data = client.thread('REFERENCES', 'UTF-8', 'SUBJECT ?', + params=['trip report']) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, + ['REFERENCES', 'UTF-8', 'SUBJECT', '"trip report"']) + def test_uid_thread(self): response = [] client, server = self._setup(make_simple_handler('UID', response, @@ -1810,6 +1978,15 @@ def test_uid_thread(self): self.assertEqual(data, [b'(166)(167)(168)']) self.assertEqual(server.args, ['THREAD', 'ORDEREDSUBJECT', 'UTF-8', 'SINCE', '5-MAR-2000']) + # 'params' substitutes and quotes '?' (a value with a space becomes + # a quoted string). + response[:] = ['* THREAD (1)'] + typ, data = client.uid('THREAD', 'REFERENCES', 'UTF-8', 'SUBJECT ?', + params=['trip report']) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, + ['THREAD', 'REFERENCES', 'UTF-8', 'SUBJECT', '"trip report"']) + def test_delete(self): client, server = self._setup(make_simple_handler('DELETE')) client.login('user', 'pass') @@ -1900,6 +2077,11 @@ def test_status(self): self.assertEqual(typ, 'OK') self.assertEqual(server.args, ['"New folder"', '(UIDNEXT MESSAGES)']) + # The names argument may be a sequence of item names. + typ, data = client.status('blurdybloop', ['UIDNEXT', 'MESSAGES']) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['blurdybloop', '(UIDNEXT MESSAGES)']) + def test_getacl(self): client, server = self._setup(make_simple_handler('GETACL', ['* ACL INBOX Fred rwipslxetad'])) diff --git a/Misc/NEWS.d/next/Library/2026-07-06-14-30-00.gh-issue-153521.iMaP47.rst b/Misc/NEWS.d/next/Library/2026-07-06-14-30-00.gh-issue-153521.iMaP47.rst new file mode 100644 index 00000000000000..93edb1532d5d41 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-06-14-30-00.gh-issue-153521.iMaP47.rst @@ -0,0 +1,7 @@ +Add support for structured arguments in :mod:`imaplib` command methods. A +*message_set* and lists of flags or other atoms can now be passed as +sequences instead of preformatted strings, and the +:meth:`~imaplib.IMAP4.search`, :meth:`~imaplib.IMAP4.fetch`, +:meth:`~imaplib.IMAP4.sort`, :meth:`~imaplib.IMAP4.thread` and +:meth:`~imaplib.IMAP4.uid` methods accept a *params* keyword argument that +substitutes and quotes ``?`` placeholders. From b3ae91bc1ecd8bb3dfa9a021452b96b2d839b842 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 11 Jul 2026 18:41:40 +0300 Subject: [PATCH 2/6] Apply suggestions from code review Co-authored-by: R. David Murray --- Doc/library/imaplib.rst | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/Doc/library/imaplib.rst b/Doc/library/imaplib.rst index afdc0f0daf8cc6..0961b20fee117e 100644 --- a/Doc/library/imaplib.rst +++ b/Doc/library/imaplib.rst @@ -190,7 +190,7 @@ you want to avoid having an argument string quoted (eg: the *flags* argument to ``STORE``) then enclose the string in parentheses (eg: ``r'(\Deleted)'``). In general, pass arguments unquoted and let the module quote them as needed. An argument that is already enclosed in double quotes is left unchanged, -so that code which quotes arguments itself keeps working. +Or you can quote the string yourself; an argument that is already enclosed in double quotes is left unchanged. In general, however, it is better to pass the arguments unquoted and let the module quote them as needed. Mailbox names are encoded as modified UTF-7 (:rfc:`3501`, section 5.1.3), so a mailbox name containing non-ASCII characters can be passed as an @@ -224,19 +224,29 @@ or a sequence whose items are integers, message), or :class:`range` objects. For example, ``[1, (3, 5), 8]`` and ``[range(1, 6), 8]`` are both equivalent to -``'1,3:5,8'``. +The *message_set* options to the commands below can be a string specifying one or more +messages to be acted upon. It may be a simple message number (``'1'``), a range +of message numbers (``'2:4'``), or a group of non-contiguous ranges separated by +commas (``'1:3,6:9'``). A range can contain an asterisk to indicate an infinite +upper bound (``'3:*'``). + +Alternatively it can be specified using integers and :class:`range` objects. It may be a +single message number or a sequence. The sequence items may be integers, +``(start, stop)`` tuples (where ``None`` or ``'*'`` stands for the last message), +or :class:`range` objects. For example, ``[1, (3, 5), 8]`` and ``[range(1, 6), 8]`` are both +equivalent to ``'1,3:5,8'``. .. versionchanged:: next Added support for the structured *message_set*. -Arguments that are parenthesized lists of atoms --- +Command arguments that are parenthesized lists of atoms --- such as the *flag_list* argument of :meth:`~IMAP4.store` and the *flags* argument of :meth:`~IMAP4.append`, the *names* argument of :meth:`~IMAP4.status`, the *sort_criteria* argument of :meth:`~IMAP4.sort`, or the *message_parts* argument of :meth:`~IMAP4.fetch` --- -can be passed as a sequence of strings instead of a preformatted string. -For example, ``[r'\Seen', r'\Answered']`` is sent as ``(\Seen \Answered)``. +can be passed as a sequence of strings instead of a single preformatted string. +For example, ``[r'\Seen', r'\Answered']`` is equivalent to ``(\Seen \Answered)``. .. versionchanged:: next Added support for passing these arguments as a sequence. @@ -245,6 +255,8 @@ For example, ``[r'\Seen', r'\Answered']`` is sent as ``(\Seen \Answered)``. The value-bearing arguments of the search and fetch commands must otherwise be quoted by hand. +The value-bearing arguments of the search and fetch commands can be +quoted by hand, but this is error prone. Instead, they may contain ``?`` placeholders that are substituted, and quoted as required, from a *params* keyword argument, in the manner of :mod:`sqlite3` parameter substitution:: @@ -258,14 +270,16 @@ in the manner of :mod:`sqlite3` parameter substitution:: The placeholders are: * ``?`` --- an astring: a string (quoted if necessary), an integer, or a - list of them (sent as a parenthesized list); +* ``?`` --- an ``astring``: a string (which will be quoted if necessary), an integer, or a + list of integers and/or strings (which will be sent as a parenthesized list); * ``?f`` --- a flag or a list of flags, sent verbatim without quoting; * ``?s`` --- a *message_set* in the structured form described above. ``??`` stands for a literal ``?``. Substitution is only performed when *params* is given, -so an existing call that contains a literal ``?`` is unaffected. +Substitution is only performed when *params* is given, +if no *params* are given an argument containing a literal ``?`` is unchanged. The *params* keyword is accepted by :meth:`~IMAP4.search`, :meth:`~IMAP4.fetch`, :meth:`~IMAP4.sort`, :meth:`~IMAP4.thread` and :meth:`~IMAP4.uid`. @@ -687,7 +701,7 @@ An :class:`IMAP4` instance has the following methods: # or: typ, msgnums = M.search(None, '(FROM "John Smith")') - # or, letting the module quote the value: + # or, letting the module quote the value (this is recommended): typ, msgnums = M.search(None, 'FROM ?', params=['John Smith']) .. versionchanged:: next From b28df85126d1cf55e029b4d84a55a00ba49df90a Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 11 Jul 2026 18:52:26 +0300 Subject: [PATCH 3/6] Reconcile applied review suggestions GitHub could only attach each suggestion to a line within the PR diff, so David's multi-line suggestions landed next to the nearest changed line and left the original prose duplicated. Merge each into clean text, wrap to the line limit with semantic line breaks, and move the substitution note into the versionadded directive. Co-authored-by: R. David Murray Co-Authored-By: Claude Opus 4.8 --- Doc/library/imaplib.rst | 63 ++++++++++++++++++----------------------- 1 file changed, 28 insertions(+), 35 deletions(-) diff --git a/Doc/library/imaplib.rst b/Doc/library/imaplib.rst index 0961b20fee117e..6e90b33cd02059 100644 --- a/Doc/library/imaplib.rst +++ b/Doc/library/imaplib.rst @@ -188,9 +188,10 @@ enclosed with either parentheses or double quotes) each string is quoted. However, the *password* argument to the ``LOGIN`` command is always quoted. If you want to avoid having an argument string quoted (eg: the *flags* argument to ``STORE``) then enclose the string in parentheses (eg: ``r'(\Deleted)'``). -In general, pass arguments unquoted and let the module quote them as needed. -An argument that is already enclosed in double quotes is left unchanged, -Or you can quote the string yourself; an argument that is already enclosed in double quotes is left unchanged. In general, however, it is better to pass the arguments unquoted and let the module quote them as needed. +Or you can quote the string yourself; +an argument that is already enclosed in double quotes is left unchanged. +In general, however, it is better to pass arguments unquoted +and let the module quote them as needed. Mailbox names are encoded as modified UTF-7 (:rfc:`3501`, section 5.1.3), so a mailbox name containing non-ASCII characters can be passed as an @@ -211,30 +212,22 @@ or mandated results from the command. Each *data* is either a ``bytes``, or a tuple. If a tuple, then the first part is the header of the response, and the second part contains the data (ie: 'literal' value). -The *message_set* options to commands below is a string specifying one or more -messages to be acted upon. It may be a simple message number (``'1'``), a range -of message numbers (``'2:4'``), or a group of non-contiguous ranges separated by -commas (``'1:3,6:9'``). A range can contain an asterisk to indicate an infinite -upper bound (``'3:*'``). - -It may also be given in a structured form: -an integer, -or a sequence whose items are integers, -``(start, stop)`` range tuples (where ``None`` or ``'*'`` stands for the last -message), +The *message_set* options to the commands below can be a string specifying +one or more messages to be acted upon. +It may be a simple message number (``'1'``), +a range of message numbers (``'2:4'``), +or a group of non-contiguous ranges separated by commas (``'1:3,6:9'``). +A range can contain an asterisk +to indicate an infinite upper bound (``'3:*'``). + +Alternatively it can be specified using integers and :class:`range` objects. +It may be a single message number or a sequence. +The sequence items may be integers, +``(start, stop)`` tuples +(where ``None`` or ``'*'`` stands for the last message), or :class:`range` objects. -For example, ``[1, (3, 5), 8]`` and ``[range(1, 6), 8]`` are both equivalent to -The *message_set* options to the commands below can be a string specifying one or more -messages to be acted upon. It may be a simple message number (``'1'``), a range -of message numbers (``'2:4'``), or a group of non-contiguous ranges separated by -commas (``'1:3,6:9'``). A range can contain an asterisk to indicate an infinite -upper bound (``'3:*'``). - -Alternatively it can be specified using integers and :class:`range` objects. It may be a -single message number or a sequence. The sequence items may be integers, -``(start, stop)`` tuples (where ``None`` or ``'*'`` stands for the last message), -or :class:`range` objects. For example, ``[1, (3, 5), 8]`` and ``[range(1, 6), 8]`` are both -equivalent to ``'1,3:5,8'``. +For example, ``[1, (3, 5), 8]`` and ``[range(1, 6), 8]`` +are both equivalent to ``'1,3:5,8'``. .. versionchanged:: next Added support for the structured *message_set*. @@ -246,15 +239,14 @@ the *names* argument of :meth:`~IMAP4.status`, the *sort_criteria* argument of :meth:`~IMAP4.sort`, or the *message_parts* argument of :meth:`~IMAP4.fetch` --- can be passed as a sequence of strings instead of a single preformatted string. -For example, ``[r'\Seen', r'\Answered']`` is equivalent to ``(\Seen \Answered)``. +For example, ``[r'\Seen', r'\Answered']`` +is equivalent to ``(\Seen \Answered)``. .. versionchanged:: next Added support for passing these arguments as a sequence. .. _imap4-params: -The value-bearing arguments of the search and fetch commands must otherwise be -quoted by hand. The value-bearing arguments of the search and fetch commands can be quoted by hand, but this is error prone. Instead, they may contain ``?`` placeholders that are substituted, and quoted @@ -269,23 +261,24 @@ in the manner of :mod:`sqlite3` parameter substitution:: The placeholders are: -* ``?`` --- an astring: a string (quoted if necessary), an integer, or a -* ``?`` --- an ``astring``: a string (which will be quoted if necessary), an integer, or a - list of integers and/or strings (which will be sent as a parenthesized list); +* ``?`` --- an ``astring``: + a string (which will be quoted if necessary), + an integer, + or a list of integers and/or strings + (which will be sent as a parenthesized list); * ``?f`` --- a flag or a list of flags, sent verbatim without quoting; * ``?s`` --- a *message_set* in the structured form described above. ``??`` stands for a literal ``?``. -Substitution is only performed when *params* is given, -Substitution is only performed when *params* is given, -if no *params* are given an argument containing a literal ``?`` is unchanged. The *params* keyword is accepted by :meth:`~IMAP4.search`, :meth:`~IMAP4.fetch`, :meth:`~IMAP4.sort`, :meth:`~IMAP4.thread` and :meth:`~IMAP4.uid`. .. versionadded:: next The *params* keyword argument. + Substitution is only performed when *params* is given, + so an existing call that contains a literal ``?`` is unaffected. An :class:`IMAP4` instance has the following methods: From 1e89a1979d29a8a65188bf5df2379a691251c848 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 11 Jul 2026 18:59:48 +0300 Subject: [PATCH 4/6] Improve line breaks in the reworked docs Keep every changed line within the 79-character limit and break at clause boundaries, without splitting into overly short fragments. Co-Authored-By: Claude Opus 4.8 --- Doc/library/imaplib.rst | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/Doc/library/imaplib.rst b/Doc/library/imaplib.rst index 6e90b33cd02059..38487b56db0292 100644 --- a/Doc/library/imaplib.rst +++ b/Doc/library/imaplib.rst @@ -222,8 +222,7 @@ to indicate an infinite upper bound (``'3:*'``). Alternatively it can be specified using integers and :class:`range` objects. It may be a single message number or a sequence. -The sequence items may be integers, -``(start, stop)`` tuples +The sequence items may be integers, ``(start, stop)`` tuples (where ``None`` or ``'*'`` stands for the last message), or :class:`range` objects. For example, ``[1, (3, 5), 8]`` and ``[range(1, 6), 8]`` @@ -247,8 +246,8 @@ is equivalent to ``(\Seen \Answered)``. .. _imap4-params: -The value-bearing arguments of the search and fetch commands can be -quoted by hand, but this is error prone. +The value-bearing arguments of the search and fetch commands +can be quoted by hand, but this is error prone. Instead, they may contain ``?`` placeholders that are substituted, and quoted as required, from a *params* keyword argument, in the manner of :mod:`sqlite3` parameter substitution:: @@ -261,10 +260,8 @@ in the manner of :mod:`sqlite3` parameter substitution:: The placeholders are: -* ``?`` --- an ``astring``: - a string (which will be quoted if necessary), - an integer, - or a list of integers and/or strings +* ``?`` --- an ``astring``: a string (which will be quoted if necessary), + an integer, or a list of integers and/or strings (which will be sent as a parenthesized list); * ``?f`` --- a flag or a list of flags, sent verbatim without quoting; * ``?s`` --- a *message_set* in the structured form described above. From 99a671ceea8eac6f2b329094d5eebcfe1434d786 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 11 Jul 2026 19:50:10 +0300 Subject: [PATCH 5/6] Apply suggestions from code review Co-authored-by: R. David Murray --- Lib/test/test_imaplib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_imaplib.py b/Lib/test/test_imaplib.py index 7a45ed1ab38ea4..9f620def321a56 100644 --- a/Lib/test/test_imaplib.py +++ b/Lib/test/test_imaplib.py @@ -322,7 +322,7 @@ def test_substitute(self): self.assertEqual(sub('FROM ?', ['me@host']), 'FROM me@host') self.assertEqual(sub('SUBJECT ?', ['hello world']), 'SUBJECT "hello world"') - self.assertEqual(sub('SUBJECT ?', ['a"b']), 'SUBJECT "a\\"b"') + self.assertEqual(sub('SUBJECT ?', ['a"b']), r'SUBJECT "a\"b"') # An integer becomes a number, a list a parenthesized list. self.assertEqual(sub('LARGER ?', [1000]), 'LARGER 1000') self.assertEqual(sub('HEADER.FIELDS ?', [['DATE', 'FROM']]), From 27ec18558bae571ccf70df450c6916517b9ddedd Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 11 Jul 2026 21:00:01 +0300 Subject: [PATCH 6/6] Address code review Let _format_astring() fall through to the generic TypeError for a bool value instead of a special-case message (suggested by David Murray), and move the substitution note back into the body rather than the versionadded directive. Co-authored-by: R. David Murray Co-Authored-By: Claude Opus 4.8 --- Doc/library/imaplib.rst | 4 ++-- Lib/imaplib.py | 6 +++--- Lib/test/test_imaplib.py | 15 ++++++++++----- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/Doc/library/imaplib.rst b/Doc/library/imaplib.rst index 38487b56db0292..910b0f00c0e7de 100644 --- a/Doc/library/imaplib.rst +++ b/Doc/library/imaplib.rst @@ -268,14 +268,14 @@ The placeholders are: ``??`` stands for a literal ``?``. +Substitution is only performed when *params* is given; +if no *params* are given, an argument containing a literal ``?`` is unchanged. The *params* keyword is accepted by :meth:`~IMAP4.search`, :meth:`~IMAP4.fetch`, :meth:`~IMAP4.sort`, :meth:`~IMAP4.thread` and :meth:`~IMAP4.uid`. .. versionadded:: next The *params* keyword argument. - Substitution is only performed when *params* is given, - so an existing call that contains a literal ``?`` is unaffected. An :class:`IMAP4` instance has the following methods: diff --git a/Lib/imaplib.py b/Lib/imaplib.py index a0d04a878f152a..e99e41fd58a2eb 100644 --- a/Lib/imaplib.py +++ b/Lib/imaplib.py @@ -185,9 +185,7 @@ def _format_sequence_set(arg): def _format_astring(value): if isinstance(value, (list, tuple)): return '(' + ' '.join(map(_format_astring, value)) + ')' - if isinstance(value, bool): - raise TypeError('a boolean is not a valid IMAP4 string') - if isinstance(value, int): + if not isinstance(value, bool) and isinstance(value, int): return str(value) if isinstance(value, (bytes, bytearray)): value = str(value, 'ascii') @@ -204,6 +202,8 @@ def _format_astring(value): def _format_flags(value): if isinstance(value, (list, tuple)): + # A nested sequence is not part of the API; it produces invalid + # syntax that is rejected by the server. return '(' + ' '.join(map(_format_flags, value)) + ')' if isinstance(value, (bytes, bytearray)): value = str(value, 'ascii') diff --git a/Lib/test/test_imaplib.py b/Lib/test/test_imaplib.py index 9f620def321a56..100791a9c3eb2c 100644 --- a/Lib/test/test_imaplib.py +++ b/Lib/test/test_imaplib.py @@ -184,8 +184,8 @@ def test_astring(self): self.assertEqual(m._astring(b'INBOX'), b'INBOX') # Names with protocol-sensitive characters are quoted. self.assertEqual(m._astring('New folder'), b'"New folder"') - self.assertEqual(m._astring('a"b'), b'"a\\"b"') - self.assertEqual(m._astring('a\\b'), b'"a\\\\b"') + self.assertEqual(m._astring('a"b'), rb'"a\"b"') + self.assertEqual(m._astring(r'a\b'), rb'"a\\b"') self.assertEqual(m._astring(''), b'""') self.assertEqual(m._astring('*'), b'"*"') # A well-formed quoted string is passed through unchanged. @@ -193,12 +193,12 @@ def test_astring(self): self.assertEqual(m._astring('""'), b'""') # Including a lenient (non-RFC) backslash escape, which the server # may accept. - self.assertEqual(m._astring('"a\\b"'), b'"a\\b"') + self.assertEqual(m._astring(r'"a\b"'), rb'"a\b"') # A string that only looks quoted but is not a single token is # quoted as data, closing the argument injection vector. self.assertEqual(m._astring('"a" SELECT evil "'), - b'"\\"a\\" SELECT evil \\""') - self.assertEqual(m._astring('"'), b'"\\""') + rb'"\"a\" SELECT evil \""') + self.assertEqual(m._astring('"'), rb'"\""') # Non-ASCII names are only allowed in a quoted string or a # literal, never in an atom (RFC 6855). m._encoding = 'utf-8' @@ -344,6 +344,11 @@ def test_substitute_errors(self): self.assertRaises(ValueError, sub, '?', ['a\r\nb']) # CR/LF not inline self.assertRaises(TypeError, sub, '?', [True]) # bool is not a string self.assertRaises(TypeError, sub, '?', [1.5]) # float is not a string + self.assertRaises(TypeError, sub, '?s', [['a']]) # not a message number + self.assertRaises(TypeError, sub, '?s', [[1.5]]) # not a message number + self.assertRaises(TypeError, sub, '?s', [[(1, 'a')]]) # not a message number + self.assertRaises(ValueError, sub, '?s', [[(1,)]]) # not a range pair + self.assertRaises(ValueError, sub, '?s', [[(1, 2, 3)]]) # not a range pair if ssl: