From 3d2ee6466d3cf9474dc7f9ae918c0de32c7973c2 Mon Sep 17 00:00:00 2001 From: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:17:07 -0300 Subject: [PATCH] Drop the narrow-build surrogate pairing from unicode_escape (#8569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unicodeescape_string is a port of CPython 2.7's PyUnicode_EncodeUnicodeEscape, where the branch mapping a character at or above 0x10000 to \U00xxxxxx sits under #ifdef Py_UNICODE_WIDE and the branch folding a surrogate pair sits under the #else. The port kept the "# ifdef Py_UNICODE_WIDE" and "# endif" comments around the first one but dropped the #else, so both branches run. The pairing branch reads the next character without checking that there is one, so a high surrogate at the end of the string walks off it: >>> "\ud800".encode("unicode_escape") IndexError: index out of range # CPython: b'\ud800' And when there is a next character it folds the two, which no wide build does: >>> "𐀀".encode("unicode_escape") b'\U00010000' # CPython: b'\ud800\udc00' With the branch gone a high surrogate falls through to the 16-bit case and comes out as \udXXX, which is where a low surrogate already went. test_incremental_surrogatepass in UnicodeEscapeTest carried an expectedFailure for the IndexError. It passes now, so the override goes with it. Assisted-by: Claude Code:claude-opus-5 --- Lib/_pycodecs.py | 14 -------------- Lib/test/test_codecs.py | 3 --- extra_tests/snippets/builtin_str_encode.py | 9 +++++++++ 3 files changed, 9 insertions(+), 17 deletions(-) diff --git a/Lib/_pycodecs.py b/Lib/_pycodecs.py index 98dec3c61..2c043f0ec 100644 --- a/Lib/_pycodecs.py +++ b/Lib/_pycodecs.py @@ -1102,20 +1102,6 @@ def unicodeescape_string(s, size, quotes): pos += 1 continue # endif - # /* Map UTF-16 surrogate pairs to Unicode \UXXXXXXXX escapes */ - elif ord(ch) >= 0xD800 and ord(ch) < 0xDC00: - pos += 1 - ch2 = s[pos] - - if ord(ch2) >= 0xDC00 and ord(ch2) <= 0xDFFF: - ucs = (((ord(ch) & 0x03FF) << 10) | (ord(ch2) & 0x03FF)) + 0x00010000 - p.append(b"\\U%08x" % ucs) - pos += 1 - continue - - # /* Fall through: isolated surrogates are copied as-is */ - pos -= 1 - # /* Map 16-bit characters to '\uxxxx' */ if ord(ch) >= 256: p.append(b"\\u%04x" % ord(ch)) diff --git a/Lib/test/test_codecs.py b/Lib/test/test_codecs.py index 58a7d6f3a..a55057d91 100644 --- a/Lib/test/test_codecs.py +++ b/Lib/test/test_codecs.py @@ -2776,9 +2776,6 @@ class UnicodeEscapeTest(ReadTest, unittest.TestCase): ] ) - @unittest.expectedFailure # TODO: RUSTPYTHON; IndexError: index out of range - def test_incremental_surrogatepass(self): - return super().test_incremental_surrogatepass() class RawUnicodeEscapeTest(ReadTest, unittest.TestCase): encoding = "raw-unicode-escape" diff --git a/extra_tests/snippets/builtin_str_encode.py b/extra_tests/snippets/builtin_str_encode.py index fc72e6ad4..7e7cff577 100644 --- a/extra_tests/snippets/builtin_str_encode.py +++ b/extra_tests/snippets/builtin_str_encode.py @@ -32,3 +32,12 @@ assert not b"\x80\x80".islower() assert not b"\x80\x80".isupper() assert b"\x80cat\x80".islower() assert b"\x80CAT\x80".isupper() + +# A lone surrogate gets an escape of its own wherever it sits, and a high one +# followed by a low one stays two escapes rather than being folded into one. +assert "\ud800".encode("unicode_escape") == b"\\ud800" +assert "a\ud800".encode("unicode_escape") == b"a\\ud800" +assert "\ud800b".encode("unicode_escape") == b"\\ud800b" +assert "\ud800\ud800".encode("unicode_escape") == b"\\ud800\\ud800" +assert "\ud800\udc00".encode("unicode_escape") == b"\\ud800\\udc00" +assert "\U00010000".encode("unicode_escape") == b"\\U00010000"