https://github.com/python/cpython/commit/ab5187ce84a1e6641b5a640f964f87c780339075
commit: ab5187ce84a1e6641b5a640f964f87c780339075
branch: main
author: Serhiy Storchaka <[email protected]>
committer: gpshead <[email protected]>
date: 2026-10-22T00:22:49Z
summary:

gh-62222: Fix and extend quoted-printable decoding (GH-152679)

binascii.a2b_qp() and quopri now leave an invalid "=" escape in place and
rescan the following character instead of consuming it, like email.quoprimime
and other decoders (b'==41' now decodes to b'=A').

Add an opt-in strip_ws parameter to binascii.a2b_qp(), quopri.decode(),
quopri.decodestring() and email.quoprimime.decode() to strip trailing
whitespace per RFC 2045.  It is false by default everywhere except
email.quoprimime.decode(), preserving every existing default.

Also document that email's get_payload(decode=True) does not strip trailing
whitespace, contrary to RFC 2045.

Register the new "strip_ws" argument name in the global objects tables
(make regen-global-objects), needed by static builds where binascii is
compiled into the core, and replace an unresolved :func: reference to the
undocumented email.quoprimime.decode with a literal.

* gh-62222: Make strip_ws parameters keyword-only

---------

Co-authored-by: Gregory P. Smith <[email protected]>

files:
A Misc/NEWS.d/next/Library/2026-06-30-08-39-07.gh-issue-62222.Zq7x2P.rst
M Doc/library/binascii.rst
M Doc/library/email.compat32-message.rst
M Doc/library/quopri.rst
M Doc/whatsnew/3.16.rst
M Include/internal/pycore_global_objects_fini_generated.h
M Include/internal/pycore_global_strings.h
M Include/internal/pycore_runtime_init_generated.h
M Include/internal/pycore_unicodeobject_generated.h
M Lib/email/quoprimime.py
M Lib/quopri.py
M Lib/test/test_binascii.py
M Lib/test/test_email/test_email.py
M Lib/test/test_quopri.py
M Modules/binascii.c
M Modules/clinic/binascii.c.h

diff --git a/Doc/library/binascii.rst b/Doc/library/binascii.rst
index ceb80a35a1a76bb..be9ad897bcf2ee3 100644
--- a/Doc/library/binascii.rst
+++ b/Doc/library/binascii.rst
@@ -283,11 +283,16 @@ The :mod:`!binascii` module defines the following 
functions:
 
    .. versionadded:: 3.15
 
-.. function:: a2b_qp(data, header=False)
+.. function:: a2b_qp(data, header=False, *, strip_ws=False)
 
    Convert a block of quoted-printable data back to binary and return the 
binary
    data. More than one line may be passed at a time. If the optional argument
    *header* is present and true, underscores will be decoded as spaces.
+   If the optional argument *strip_ws* is true,
+   trailing whitespace is stripped from each line, as required by :rfc:`2045`.
+
+   .. versionchanged:: next
+      Added the *strip_ws* parameter.
 
 
 .. function:: b2a_qp(data, quotetabs=False, istext=True, header=False)
diff --git a/Doc/library/email.compat32-message.rst 
b/Doc/library/email.compat32-message.rst
index 11b4d908fcd7d60..d1f8c15103aa9f5 100644
--- a/Doc/library/email.compat32-message.rst
+++ b/Doc/library/email.compat32-message.rst
@@ -214,6 +214,14 @@ Here are the methods of the :class:`Message` class:
       defect property (:class:`~email.errors.InvalidBase64PaddingDefect` or
       :class:`~email.errors.InvalidBase64CharactersDefect`, respectively).
 
+      .. note::
+
+         A ``quoted-printable`` payload is decoded without stripping
+         trailing whitespace, contrary to :rfc:`2045` but matching
+         common mail clients.
+         Use :func:`binascii.a2b_qp` with ``strip_ws=True``
+         (or ``email.quoprimime.decode``) for RFC-compliant decoding.
+
       When *decode* is ``False`` (the default) the body is returned as a string
       without decoding the :mailheader:`Content-Transfer-Encoding`.  However,
       for a :mailheader:`Content-Transfer-Encoding` of 8bit, an attempt is made
diff --git a/Doc/library/quopri.rst b/Doc/library/quopri.rst
index 977cb08d836afe3..60b4d1c182d6414 100644
--- a/Doc/library/quopri.rst
+++ b/Doc/library/quopri.rst
@@ -20,7 +20,7 @@ few nonprintable characters; the base64 encoding scheme 
available via the
 :mod:`base64` module is more compact if there are many such characters, as when
 sending a graphics file.
 
-.. function:: decode(input, output, header=False)
+.. function:: decode(input, output, header=False, *, strip_ws=False)
 
    Decode the contents of the *input* file and write the resulting decoded 
binary
    data to the *output* file. *input* and *output* must be :term:`binary file 
objects
@@ -28,6 +28,11 @@ sending a graphics file.
    will be decoded as space. This is used to decode "Q"-encoded headers as
    described in :rfc:`1522`: "MIME (Multipurpose Internet Mail Extensions)
    Part Two: Message Header Extensions for Non-ASCII Text".
+   If the optional argument *strip_ws* is true,
+   trailing whitespace is stripped from each line, as required by :rfc:`2045`.
+
+   .. versionchanged:: next
+      Added the *strip_ws* parameter.
 
 
 .. function:: encode(input, output, quotetabs, header=False)
@@ -43,11 +48,14 @@ sending a graphics file.
    as underscores as per :rfc:`1522`.
 
 
-.. function:: decodestring(s, header=False)
+.. function:: decodestring(s, header=False, *, strip_ws=False)
 
    Like :func:`decode`, except that it accepts a source :class:`bytes` and
    returns the corresponding decoded :class:`bytes`.
 
+   .. versionchanged:: next
+      Added the *strip_ws* parameter.
+
 
 .. function:: encodestring(s, quotetabs=False, header=False)
 
diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst
index bd92a0a48f1f70c..925f425aba58693 100644
--- a/Doc/whatsnew/3.16.rst
+++ b/Doc/whatsnew/3.16.rst
@@ -614,6 +614,16 @@ pickle
   :class:`!WeakMethod` and :class:`!WeakSet` can now also be copied.
   (Contributed by Serhiy Storchaka in :gh:`74876`.)
 
+quopri
+------
+
+* :func:`quopri.decode`, :func:`quopri.decodestring` and
+  :func:`binascii.a2b_qp` gained a *strip_ws* parameter.  When true, trailing
+  whitespace is stripped from each line while decoding, as required by
+  :rfc:`2045` for a quoted-printable body.
+  (Contributed by Serhiy Storchaka in :gh:`62222`.)
+
+
 re
 --
 
diff --git a/Include/internal/pycore_global_objects_fini_generated.h 
b/Include/internal/pycore_global_objects_fini_generated.h
index cf5bcbb2941d3d4..416448a74385b0c 100644
--- a/Include/internal/pycore_global_objects_fini_generated.h
+++ b/Include/internal/pycore_global_objects_fini_generated.h
@@ -841,6 +841,7 @@ _PyStaticObjects_CheckAll(PyInterpreterState *interp) {
     _PyStaticObject_CheckUnicodeSingleton((PyObject *)&_Py_ID(strict), 
"strict", 6);
     _PyStaticObject_CheckUnicodeSingleton((PyObject *)&_Py_ID(strict_mode), 
"strict_mode", 11);
     _PyStaticObject_CheckUnicodeSingleton((PyObject *)&_Py_ID(string), 
"string", 6);
+    _PyStaticObject_CheckUnicodeSingleton((PyObject *)&_Py_ID(strip_ws), 
"strip_ws", 8);
     _PyStaticObject_CheckUnicodeSingleton((PyObject *)&_Py_ID(sub_key), 
"sub_key", 7);
     _PyStaticObject_CheckUnicodeSingleton((PyObject *)&_Py_ID(subcalls), 
"subcalls", 8);
     _PyStaticObject_CheckUnicodeSingleton((PyObject 
*)&_Py_ID(symmetric_difference_update), "symmetric_difference_update", 27);
diff --git a/Include/internal/pycore_global_strings.h 
b/Include/internal/pycore_global_strings.h
index bc0a149bc939736..7a6082035f5c979 100644
--- a/Include/internal/pycore_global_strings.h
+++ b/Include/internal/pycore_global_strings.h
@@ -853,6 +853,7 @@ struct _Py_global_strings {
         STRUCT_FOR_ID(strict)
         STRUCT_FOR_ID(strict_mode)
         STRUCT_FOR_ID(string)
+        STRUCT_FOR_ID(strip_ws)
         STRUCT_FOR_ID(sub_key)
         STRUCT_FOR_ID(subcalls)
         STRUCT_FOR_ID(symmetric_difference_update)
diff --git a/Include/internal/pycore_runtime_init_generated.h 
b/Include/internal/pycore_runtime_init_generated.h
index 630b5d786713354..e9fce38ac8c4ac3 100644
--- a/Include/internal/pycore_runtime_init_generated.h
+++ b/Include/internal/pycore_runtime_init_generated.h
@@ -2128,6 +2128,7 @@ extern "C" {
     INIT_ID(strict), \
     INIT_ID(strict_mode), \
     INIT_ID(string), \
+    INIT_ID(strip_ws), \
     INIT_ID(sub_key), \
     INIT_ID(subcalls), \
     INIT_ID(symmetric_difference_update), \
diff --git a/Include/internal/pycore_unicodeobject_generated.h 
b/Include/internal/pycore_unicodeobject_generated.h
index 621a03016301ab4..ac814a8de84c3d3 100644
--- a/Include/internal/pycore_unicodeobject_generated.h
+++ b/Include/internal/pycore_unicodeobject_generated.h
@@ -805,6 +805,7 @@ _PyUnicode_InitStaticStrings(PyInterpreterState *interp) {
         offsetof(struct _Py_global_strings, 
identifiers._py_strict._ascii.ob_base),
         offsetof(struct _Py_global_strings, 
identifiers._py_strict_mode._ascii.ob_base),
         offsetof(struct _Py_global_strings, 
identifiers._py_string._ascii.ob_base),
+        offsetof(struct _Py_global_strings, 
identifiers._py_strip_ws._ascii.ob_base),
         offsetof(struct _Py_global_strings, 
identifiers._py_sub_key._ascii.ob_base),
         offsetof(struct _Py_global_strings, 
identifiers._py_subcalls._ascii.ob_base),
         offsetof(struct _Py_global_strings, 
identifiers._py_symmetric_difference_update._ascii.ob_base),
diff --git a/Lib/email/quoprimime.py b/Lib/email/quoprimime.py
index bc53b3768213102..0be416fca7c100a 100644
--- a/Lib/email/quoprimime.py
+++ b/Lib/email/quoprimime.py
@@ -229,10 +229,14 @@ def body_encode(body, maxlinelen=76, eol=NL):
 
 # BAW: I'm not sure if the intent was for the signature of this function to be
 # the same as base64MIME.decode() or not...
-def decode(encoded, eol=NL):
+def decode(encoded, eol=NL, *, strip_ws=True):
     """Decode a quoted-printable string.
 
     Lines are separated with eol, which defaults to \\n.
+
+    If strip_ws is true (the default), whitespace at the end of a line is
+    stripped, as required by RFC 2045 when decoding a quoted-printable body.
+    Pass strip_ws=False to keep it.
     """
     if not encoded:
         return encoded
@@ -242,7 +246,8 @@ def decode(encoded, eol=NL):
     decoded = ''
 
     for line in encoded.splitlines():
-        line = line.rstrip()
+        if strip_ws:
+            line = line.rstrip()
         if not line:
             decoded += eol
             continue
diff --git a/Lib/quopri.py b/Lib/quopri.py
index 129fd2f5c7c28a8..bab70672b73e601 100644
--- a/Lib/quopri.py
+++ b/Lib/quopri.py
@@ -109,14 +109,16 @@ def encodestring(s, quotetabs=False, header=False):
 
 
 
-def decode(input, output, header=False):
+def decode(input, output, header=False, *, strip_ws=False):
     """Read 'input', apply quoted-printable decoding, and write to 'output'.
     'input' and 'output' are binary file objects.
-    If 'header' is true, decode underscore as space (per RFC 1522)."""
+    If 'header' is true, decode underscore as space (per RFC 1522).
+    If 'strip_ws' is true, strip whitespace at the end of a line (per
+    RFC 2045)."""
 
     if a2b_qp is not None:
         data = input.read()
-        odata = a2b_qp(data, header=header)
+        odata = a2b_qp(data, header=header, strip_ws=strip_ws)
         output.write(odata)
         return
 
@@ -125,11 +127,19 @@ def decode(input, output, header=False):
         i, n = 0, len(line)
         if n > 0 and line[n-1:n] == b'\n':
             partial = 0; n = n-1
-            # Strip trailing whitespace
-            while n > 0 and line[n-1:n] in b" \t\r":
-                n = n-1
+            # Separate off the line ending (keeping it to re-add after
+            # decoding) so that a trailing "=" -- possibly before the "\r" of
+            # a "\r\n" pair -- is recognized as a soft line break.
+            if n > 0 and line[n-1:n] == b'\r':
+                n = n-1; eol = b'\r\n'
+            else:
+                eol = b'\n'
         else:
-            partial = 1
+            partial = 1; eol = b''
+        if strip_ws:
+            # Strip trailing whitespace (RFC 2045).
+            while n > 0 and line[n-1:n] in b" \t":
+                n = n-1
         while i < n:
             c = line[i:i+1]
             if c == b'_' and header:
@@ -138,25 +148,23 @@ def decode(input, output, header=False):
                 new = new + c; i = i+1
             elif i+1 == n and not partial:
                 partial = 1; break
-            elif i+1 < n and line[i+1:i+2] == ESCAPE:
-                new = new + ESCAPE; i = i+2
             elif i+2 < n and ishex(line[i+1:i+2]) and ishex(line[i+2:i+3]):
                 new = new + bytes((unhex(line[i+1:i+3]),)); i = i+3
             else: # Bad escape sequence -- leave it in
                 new = new + c; i = i+1
         if not partial:
-            output.write(new + b'\n')
+            output.write(new + eol)
             new = b''
     if new:
         output.write(new)
 
-def decodestring(s, header=False):
+def decodestring(s, header=False, *, strip_ws=False):
     if a2b_qp is not None:
-        return a2b_qp(s, header=header)
+        return a2b_qp(s, header=header, strip_ws=strip_ws)
     from io import BytesIO
     infp = BytesIO(s)
     outfp = BytesIO()
-    decode(infp, outfp, header=header)
+    decode(infp, outfp, header=header, strip_ws=strip_ws)
     return outfp.getvalue()
 
 
diff --git a/Lib/test/test_binascii.py b/Lib/test/test_binascii.py
index cedbdc61f18f341..77ea77ed37bdc59 100644
--- a/Lib/test/test_binascii.py
+++ b/Lib/test/test_binascii.py
@@ -1418,6 +1418,10 @@ def test_qp(self):
         self.assertEqual(a2b_qp(type2test(b"=")), b"")
         self.assertEqual(a2b_qp(type2test(b"= ")), b"= ")
         self.assertEqual(a2b_qp(type2test(b"==")), b"=")
+        # A stray "=" is left in place and the next character is rescanned,
+        # so "=41" after it is decoded as a fresh escape (gh-62222).
+        self.assertEqual(a2b_qp(type2test(b"==41")), b"=A")
+        self.assertEqual(a2b_qp(type2test(b"==g")), b"==g")
         self.assertEqual(a2b_qp(type2test(b"=\nAB")), b"AB")
         self.assertEqual(a2b_qp(type2test(b"=\r\nAB")), b"AB")
         self.assertEqual(a2b_qp(type2test(b"=\rAB")), b"")  # ?
@@ -1431,6 +1435,21 @@ def test_qp(self):
         self.assertEqual(a2b_qp(type2test(b'_')), b'_')
         self.assertEqual(a2b_qp(type2test(b'_'), header=True), b' ')
 
+        # strip_ws strips whitespace at the end of a line (RFC 2045), but
+        # leaves whitespace that was encoded (=20/=09) untouched. By default
+        # trailing whitespace is kept.
+        self.assertEqual(a2b_qp(type2test(b"foo   \n")), b"foo   \n")
+        self.assertEqual(a2b_qp(type2test(b"foo   \n"), strip_ws=True), 
b"foo\n")
+        self.assertEqual(a2b_qp(type2test(b"foo   "), strip_ws=True), b"foo")
+        self.assertEqual(a2b_qp(type2test(b"a b \nc\n"), strip_ws=True), b"a 
b\nc\n")
+        self.assertEqual(a2b_qp(type2test(b"a=20  \n"), strip_ws=True), b"a 
\n")
+        self.assertEqual(a2b_qp(type2test(b"= \n"), strip_ws=True), b"")
+        self.assertEqual(a2b_qp(type2test(b"foo =\n"), strip_ws=True), b"foo ")
+        self.assertEqual(a2b_qp(type2test(b"foo  \r\n"), strip_ws=True), 
b"foo\r\n")
+        # A bare CR is not a line separator (RFC 2045: CR occurs only in CRLF),
+        # so whitespace before it is kept.
+        self.assertEqual(a2b_qp(type2test(b"foo \rbar"), strip_ws=True), b"foo 
\rbar")
+
         self.assertRaises(TypeError, b2a_qp, foo="bar")
         self.assertEqual(a2b_qp(type2test(b"=00\r\n=00")), b"\x00\r\n\x00")
         self.assertEqual(b2a_qp(type2test(b"\xff\r\n\xff\n\xff")),
diff --git a/Lib/test/test_email/test_email.py 
b/Lib/test/test_email/test_email.py
index e40c82bba9af426..2a5794a5b66fc5b 100644
--- a/Lib/test/test_email/test_email.py
+++ b/Lib/test/test_email/test_email.py
@@ -4835,6 +4835,14 @@ def test_decode_one_line_trailing_spaces(self):
     def test_decode_two_lines_trailing_spaces(self):
         self._test_decode('hello    \r\nworld   \r\n', 'hello\nworld\n')
 
+    def test_decode_keep_trailing_spaces(self):
+        # strip_ws=False keeps trailing whitespace (the default strips it).
+        self.assertEqual(quoprimime.decode('hello    \r\n', strip_ws=False),
+                         'hello    \n')
+        self.assertEqual(quoprimime.decode('hello    \r\nworld   \r\n',
+                                           strip_ws=False),
+                         'hello    \nworld   \n')
+
     def test_decode_quoted_word(self):
         self._test_decode('=22quoted=20words=22', '"quoted words"')
 
diff --git a/Lib/test/test_quopri.py b/Lib/test/test_quopri.py
index 152d1858dcdd242..2e013e9e4c80b31 100644
--- a/Lib/test/test_quopri.py
+++ b/Lib/test/test_quopri.py
@@ -140,8 +140,10 @@ def test_decodestring(self):
     @withpythonimplementation
     def test_decodestring_double_equals(self):
         # Issue 21511 - Ensure that byte string is compared to byte string
-        # instead of int byte value
-        decoded_value, encoded_value = (b"123=four", b"123==four")
+        # instead of int byte value.
+        # A stray "=" not starting a valid escape is left in place, so the
+        # following "=four" is decoded as a fresh (invalid) escape too.
+        decoded_value, encoded_value = (b"123==four", b"123==four")
         self.assertEqual(quopri.decodestring(encoded_value), decoded_value)
 
     @withpythonimplementation
@@ -171,6 +173,30 @@ def test_embedded_ws(self):
             self.assertEqual(quopri.encodestring(p, quotetabs=True), e)
             self.assertEqual(quopri.decodestring(e), p)
 
+    @withpythonimplementation
+    def test_decode_strip_ws(self):
+        # By default trailing whitespace is kept; with strip_ws it is
+        # removed at the end of a line (RFC 2045), while encoded whitespace
+        # (=20/=09) is preserved.
+        self.assertEqual(quopri.decodestring(b"foo   \n"), b"foo   \n")
+        self.assertEqual(quopri.decodestring(b"foo   \n", strip_ws=True), 
b"foo\n")
+        self.assertEqual(quopri.decodestring(b"a b \nc\n", strip_ws=True), b"a 
b\nc\n")
+        self.assertEqual(quopri.decodestring(b"a=20  \n", strip_ws=True), b"a 
\n")
+        self.assertEqual(quopri.decodestring(b"= \n", strip_ws=True), b"")
+        self.assertEqual(quopri.decodestring(b"foo =\n", strip_ws=True), b"foo 
")
+        self.assertEqual(quopri.decodestring(b"foo  \r\n", strip_ws=True), 
b"foo\r\n")
+        # A bare CR is not a line separator, so whitespace before it is kept.
+        self.assertEqual(quopri.decodestring(b"foo \rbar", strip_ws=True), 
b"foo \rbar")
+
+    @withpythonimplementation
+    def test_decode_soft_line_break(self):
+        # A "=" at the end of a line is a soft line break, for both "\n" and
+        # "\r\n" line endings; other line endings are preserved as-is.
+        self.assertEqual(quopri.decodestring(b"=\nAB"), b"AB")
+        self.assertEqual(quopri.decodestring(b"=\r\nAB"), b"AB")
+        self.assertEqual(quopri.decodestring(b"foo=\r\nbar"), b"foobar")
+        self.assertEqual(quopri.decodestring(b"foo\r\nbar"), b"foo\r\nbar")
+
     @withpythonimplementation
     def test_encode_header(self):
         for p, e in self.HSTRINGS:
diff --git 
a/Misc/NEWS.d/next/Library/2026-06-30-08-39-07.gh-issue-62222.Zq7x2P.rst 
b/Misc/NEWS.d/next/Library/2026-06-30-08-39-07.gh-issue-62222.Zq7x2P.rst
new file mode 100644
index 000000000000000..d70b183b7cfa502
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-06-30-08-39-07.gh-issue-62222.Zq7x2P.rst
@@ -0,0 +1,6 @@
+Fix :func:`binascii.a2b_qp` and :mod:`quopri` discarding the character after
+a ``=`` that does not start a valid escape: ``b'==41'`` now decodes to
+``b'=A'``, as in the :mod:`email` package. Add a keyword-only *strip_ws*
+parameter to :func:`binascii.a2b_qp`, :func:`quopri.decode` and
+:func:`quopri.decodestring` to strip trailing whitespace from each line, as
+required by :rfc:`2045`.
diff --git a/Modules/binascii.c b/Modules/binascii.c
index 0e7af135a6f6ce4..57684f8336e64ed 100644
--- a/Modules/binascii.c
+++ b/Modules/binascii.c
@@ -2373,28 +2373,81 @@ binascii.a2b_qp
 
     data: ascii_buffer
     header: bool = False
+    *
+    strip_ws: bool = False
 
 Decode a string of qp-encoded data.
+
+If strip_ws is true, whitespace at the end of a line is stripped, as
+required by RFC 2045 when decoding a quoted-printable body.
 [clinic start generated code]*/
 
 static PyObject *
-binascii_a2b_qp_impl(PyObject *module, Py_buffer *data, int header)
-/*[clinic end generated code: output=e99f7846cfb9bc53 input=bdfb31598d4e47b9]*/
+binascii_a2b_qp_impl(PyObject *module, Py_buffer *data, int header,
+                     int strip_ws)
+/*[clinic end generated code: output=7e9431376a28709d input=679b714f08932dd1]*/
 {
     Py_ssize_t in, out;
     char ch;
     const unsigned char *ascii_data;
     unsigned char *odata;
+    unsigned char *stripped = NULL;
     Py_ssize_t datalen = 0;
     PyObject *rv;
 
     ascii_data = data->buf;
     datalen = data->len;
 
+    /* Strip trailing space and tab before each line break and at the end of
+     * the data (RFC 2045).  Done before decoding, so that a "=" left at the
+     * end of a line becomes a soft line break and a "=20" is preserved.
+     */
+    if (strip_ws) {
+        Py_ssize_t i = 0, j = 0, start = 0;
+        while (i < datalen) {
+            if (ascii_data[i] != ' ' && ascii_data[i] != '\t') {
+                i++;
+                continue;
+            }
+            /* Find the run of space and tab. */
+            Py_ssize_t k = i;
+            while (k < datalen &&
+                   (ascii_data[k] == ' ' || ascii_data[k] == '\t')) {
+                k++;
+            }
+            /* Drop it if it ends a line: before "\n", "\r\n" or the end of the
+             * data, but not a bare "\r" (RFC 2045: CR occurs only in CRLF).
+             */
+            if (k == datalen || ascii_data[k] == '\n' ||
+                (ascii_data[k] == '\r' && k + 1 < datalen &&
+                 ascii_data[k+1] == '\n')) {
+                if (stripped == NULL) {
+                    /* Allocate only once something is actually stripped. */
+                    stripped = (unsigned char *) PyMem_Malloc(datalen);
+                    if (stripped == NULL) {
+                        PyErr_NoMemory();
+                        return NULL;
+                    }
+                }
+                memcpy(stripped + j, ascii_data + start, i - start);
+                j += i - start;
+                start = k;
+            }
+            i = k;
+        }
+        if (stripped != NULL) {
+            memcpy(stripped + j, ascii_data + start, datalen - start);
+            j += datalen - start;
+            ascii_data = stripped;
+            datalen = j;
+        }
+    }
+
     /* We allocate the output same size as input, this is overkill.
      */
     odata = (unsigned char *) PyMem_Calloc(1, datalen);
     if (odata == NULL) {
+        PyMem_Free(stripped);
         PyErr_NoMemory();
         return NULL;
     }
@@ -2411,11 +2464,6 @@ binascii_a2b_qp_impl(PyObject *module, Py_buffer *data, 
int header)
                 }
                 if (in < datalen) in++;
             }
-            else if (ascii_data[in] == '=') {
-                /* broken case from broken python qp */
-                odata[out++] = '=';
-                in++;
-            }
             else if ((in + 1 < datalen) &&
                      ((ascii_data[in] >= 'A' && ascii_data[in] <= 'F') ||
                       (ascii_data[in] >= 'a' && ascii_data[in] <= 'f') ||
@@ -2446,6 +2494,7 @@ binascii_a2b_qp_impl(PyObject *module, Py_buffer *data, 
int header)
     }
     rv = PyBytes_FromStringAndSize((char *)odata, out);
     PyMem_Free(odata);
+    PyMem_Free(stripped);
     return rv;
 }
 
diff --git a/Modules/clinic/binascii.c.h b/Modules/clinic/binascii.c.h
index 29fa9e87de87c7a..0c5e35b1516da21 100644
--- a/Modules/clinic/binascii.c.h
+++ b/Modules/clinic/binascii.c.h
@@ -1513,16 +1513,20 @@ binascii_unhexlify(PyObject *module, PyObject *const 
*args, Py_ssize_t nargs, Py
 }
 
 PyDoc_STRVAR(binascii_a2b_qp__doc__,
-"a2b_qp($module, /, data, header=False)\n"
+"a2b_qp($module, /, data, header=False, *, strip_ws=False)\n"
 "--\n"
 "\n"
-"Decode a string of qp-encoded data.");
+"Decode a string of qp-encoded data.\n"
+"\n"
+"If strip_ws is true, whitespace at the end of a line is stripped, as\n"
+"required by RFC 2045 when decoding a quoted-printable body.");
 
 #define BINASCII_A2B_QP_METHODDEF    \
     {"a2b_qp", _PyCFunction_CAST(binascii_a2b_qp), 
METH_FASTCALL|METH_KEYWORDS, binascii_a2b_qp__doc__},
 
 static PyObject *
-binascii_a2b_qp_impl(PyObject *module, Py_buffer *data, int header);
+binascii_a2b_qp_impl(PyObject *module, Py_buffer *data, int header,
+                     int strip_ws);
 
 static PyObject *
 binascii_a2b_qp(PyObject *module, PyObject *const *args, Py_ssize_t nargs, 
PyObject *kwnames)
@@ -1530,7 +1534,7 @@ binascii_a2b_qp(PyObject *module, PyObject *const *args, 
Py_ssize_t nargs, PyObj
     PyObject *return_value = NULL;
     #if defined(Py_BUILD_CORE) && !defined(Py_BUILD_CORE_MODULE)
 
-    #define NUM_KEYWORDS 2
+    #define NUM_KEYWORDS 3
     static struct {
         PyGC_Head _this_is_not_used;
         PyObject_VAR_HEAD
@@ -1539,7 +1543,7 @@ binascii_a2b_qp(PyObject *module, PyObject *const *args, 
Py_ssize_t nargs, PyObj
     } _kwtuple = {
         .ob_base = PyVarObject_HEAD_INIT(&PyTuple_Type, NUM_KEYWORDS)
         .ob_hash = -1,
-        .ob_item = { &_Py_ID(data), &_Py_ID(header), },
+        .ob_item = { &_Py_ID(data), &_Py_ID(header), &_Py_ID(strip_ws), },
     };
     #undef NUM_KEYWORDS
     #define KWTUPLE (&_kwtuple.ob_base.ob_base)
@@ -1548,17 +1552,18 @@ binascii_a2b_qp(PyObject *module, PyObject *const 
*args, Py_ssize_t nargs, PyObj
     #  define KWTUPLE NULL
     #endif  // !Py_BUILD_CORE
 
-    static const char * const _keywords[] = {"data", "header", NULL};
+    static const char * const _keywords[] = {"data", "header", "strip_ws", 
NULL};
     static _PyArg_Parser _parser = {
         .keywords = _keywords,
         .fname = "a2b_qp",
         .kwtuple = KWTUPLE,
     };
     #undef KWTUPLE
-    PyObject *argsbuf[2];
+    PyObject *argsbuf[3];
     Py_ssize_t noptargs = nargs + (kwnames ? PyTuple_GET_SIZE(kwnames) : 0) - 
1;
     Py_buffer data = {NULL, NULL};
     int header = 0;
+    int strip_ws = 0;
 
     args = _PyArg_UnpackKeywords(args, nargs, NULL, kwnames, &_parser,
             /*minpos*/ 1, /*maxpos*/ 2, /*minkw*/ 0, /*varpos*/ 0, argsbuf);
@@ -1571,12 +1576,25 @@ binascii_a2b_qp(PyObject *module, PyObject *const 
*args, Py_ssize_t nargs, PyObj
     if (!noptargs) {
         goto skip_optional_pos;
     }
-    header = PyObject_IsTrue(args[1]);
-    if (header < 0) {
-        goto exit;
+    if (args[1]) {
+        header = PyObject_IsTrue(args[1]);
+        if (header < 0) {
+            goto exit;
+        }
+        if (!--noptargs) {
+            goto skip_optional_pos;
+        }
     }
 skip_optional_pos:
-    return_value = binascii_a2b_qp_impl(module, &data, header);
+    if (!noptargs) {
+        goto skip_optional_kwonly;
+    }
+    strip_ws = PyObject_IsTrue(args[2]);
+    if (strip_ws < 0) {
+        goto exit;
+    }
+skip_optional_kwonly:
+    return_value = binascii_a2b_qp_impl(module, &data, header, strip_ws);
 
 exit:
     /* Cleanup for data */
@@ -1685,4 +1703,4 @@ binascii_b2a_qp(PyObject *module, PyObject *const *args, 
Py_ssize_t nargs, PyObj
 
     return return_value;
 }
-/*[clinic end generated code: output=42dd48f323cbb118 input=a9049054013a1b77]*/
+/*[clinic end generated code: output=ec23792754b04657 input=a9049054013a1b77]*/

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to