From 91115ae3a4e91335fd63eaa6f801d6503fa9e18c Mon Sep 17 00:00:00 2001 From: Brendan Fahy Date: Sun, 25 Oct 2026 22:29:43 -0400 Subject: [PATCH 1/3] Speed up urllib.parse.unquote() for ASCII input --- Lib/test/test_urllib.py | 14 ++++++++++++++ Lib/urllib/parse.py | 2 ++ 2 files changed, 16 insertions(+) diff --git a/Lib/test/test_urllib.py b/Lib/test/test_urllib.py index ab59727a8fd1820..d5027e99ef126b9 100644 --- a/Lib/test/test_urllib.py +++ b/Lib/test/test_urllib.py @@ -1257,6 +1257,20 @@ def test_unquote_with_unicode(self): self.assertEqual(expect, result, "using unquote(): %r != %r" % (expect, result)) + def test_unquote_ascii_and_non_ascii_agree(self): + # ASCII input takes a fast path; a non-ASCII prefix forces the + # general path. Both must decode the escapes the same way. + for given in ['%41%zz%', 'a%20b%2Fc%E2%82%AC', '%F3%B1', '%C3%BC+%2B']: + for encoding, errors in [('utf-8', 'replace'), + ('latin-1', 'strict'), + ('ascii', 'ignore')]: + with self.subTest(given=given, encoding=encoding, + errors=errors): + expect = urllib.parse.unquote(given, encoding, errors) + result = urllib.parse.unquote('\u00e9' + given, + encoding, errors) + self.assertEqual(result, '\u00e9' + expect) + def test_unquoting_with_bytes_input(self): # ASCII characters decoded to a string given = b'blueberryjam' diff --git a/Lib/urllib/parse.py b/Lib/urllib/parse.py index 4247b9a4b07fa3f..6f5d4a2ef66d7b7 100644 --- a/Lib/urllib/parse.py +++ b/Lib/urllib/parse.py @@ -853,6 +853,8 @@ def unquote(string, encoding='utf-8', errors='replace'): encoding = 'utf-8' if errors is None: errors = 'replace' + if string.isascii(): + return _unquote_impl(string).decode(encoding, errors) return ''.join(_generate_unquoted_parts(string, encoding, errors)) From 7eeba765434083a1941485f865aa96763d47cd32 Mon Sep 17 00:00:00 2001 From: Brendan Fahy Date: Sun, 25 Oct 2026 22:32:44 -0400 Subject: [PATCH 2/3] Add NEWS entry --- .../Library/2026-10-26-02-31-31.gh-issue-160690.W7gxuR.rst | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-10-26-02-31-31.gh-issue-160690.W7gxuR.rst diff --git a/Misc/NEWS.d/next/Library/2026-10-26-02-31-31.gh-issue-160690.W7gxuR.rst b/Misc/NEWS.d/next/Library/2026-10-26-02-31-31.gh-issue-160690.W7gxuR.rst new file mode 100644 index 000000000000000..3adda193bd81a97 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-26-02-31-31.gh-issue-160690.W7gxuR.rst @@ -0,0 +1,3 @@ +Speed up :func:`urllib.parse.unquote` and :func:`urllib.parse.unquote_plus` +by up to ~2x for ASCII input, which also makes :func:`urllib.parse.parse_qs` +and :func:`urllib.parse.parse_qsl` faster. Patch by Brendan Fahy. From 262ee8bce9a1a059b80c25ef7ee4f0d6fe7e142e Mon Sep 17 00:00:00 2001 From: Brendan Fahy Date: Mon, 26 Oct 2026 21:39:01 -0400 Subject: [PATCH 3/3] address code review --- Lib/test/test_urllib.py | 32 ++++++++++++++++--- Lib/urllib/parse.py | 7 ++++ ...-10-26-02-31-31.gh-issue-160690.W7gxuR.rst | 5 +-- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/Lib/test/test_urllib.py b/Lib/test/test_urllib.py index d5027e99ef126b9..a4f6138b30ad5e4 100644 --- a/Lib/test/test_urllib.py +++ b/Lib/test/test_urllib.py @@ -1258,12 +1258,13 @@ def test_unquote_with_unicode(self): "using unquote(): %r != %r" % (expect, result)) def test_unquote_ascii_and_non_ascii_agree(self): - # ASCII input takes a fast path; a non-ASCII prefix forces the - # general path. Both must decode the escapes the same way. + # ASCII input takes a fast path; with a non-UTF-8 encoding, a + # non-ASCII prefix forces the general path. Both must decode the + # escapes the same way. for given in ['%41%zz%', 'a%20b%2Fc%E2%82%AC', '%F3%B1', '%C3%BC+%2B']: - for encoding, errors in [('utf-8', 'replace'), - ('latin-1', 'strict'), - ('ascii', 'ignore')]: + for encoding, errors in [('latin-1', 'strict'), + ('ascii', 'ignore'), + ('cp1252', 'replace')]: with self.subTest(given=given, encoding=encoding, errors=errors): expect = urllib.parse.unquote(given, encoding, errors) @@ -1271,6 +1272,27 @@ def test_unquote_ascii_and_non_ascii_agree(self): encoding, errors) self.assertEqual(result, '\u00e9' + expect) + def test_unquote_non_ascii_utf8(self): + # Non-ASCII input decoded as UTF-8 takes a fast path that encodes + # the whole string at once. Literal characters must not combine + # with adjacent invalid escapes. + cases = [ + ('\u00e9%ff', '\u00e9\ufffd'), + ('%C3\u00e9', '\ufffd\u00e9'), + ('%F0%9F\U0001F600', '\ufffd\U0001F600'), + ('\u6f22%20\u5b57%E2%82%AC', '\u6f22 \u5b57\u20ac'), + # Lone surrogates cannot be encoded and are passed through. + ('\ud800%41', '\ud800A'), + ] + for given, expect in cases: + with self.subTest(given=given): + self.assertEqual(urllib.parse.unquote(given), expect) + self.assertEqual(urllib.parse.unquote(given, 'utf8'), expect) + self.assertEqual(urllib.parse.unquote_plus('caf\u00e9+%2B'), + 'caf\u00e9 +') + self.assertRaises(UnicodeDecodeError, urllib.parse.unquote, + '\u00e9%ff', errors='strict') + def test_unquoting_with_bytes_input(self): # ASCII characters decoded to a string given = b'blueberryjam' diff --git a/Lib/urllib/parse.py b/Lib/urllib/parse.py index 6f5d4a2ef66d7b7..55f057d1ec950c9 100644 --- a/Lib/urllib/parse.py +++ b/Lib/urllib/parse.py @@ -855,6 +855,13 @@ def unquote(string, encoding='utf-8', errors='replace'): errors = 'replace' if string.isascii(): return _unquote_impl(string).decode(encoding, errors) + if encoding in ('utf-8', 'utf8'): + try: + data = string.encode('utf-8') + except UnicodeEncodeError: + pass # lone surrogates must be passed through verbatim + else: + return _unquote_impl(data).decode(encoding, errors) return ''.join(_generate_unquoted_parts(string, encoding, errors)) diff --git a/Misc/NEWS.d/next/Library/2026-10-26-02-31-31.gh-issue-160690.W7gxuR.rst b/Misc/NEWS.d/next/Library/2026-10-26-02-31-31.gh-issue-160690.W7gxuR.rst index 3adda193bd81a97..754caaf1c7cca62 100644 --- a/Misc/NEWS.d/next/Library/2026-10-26-02-31-31.gh-issue-160690.W7gxuR.rst +++ b/Misc/NEWS.d/next/Library/2026-10-26-02-31-31.gh-issue-160690.W7gxuR.rst @@ -1,3 +1,4 @@ Speed up :func:`urllib.parse.unquote` and :func:`urllib.parse.unquote_plus` -by up to ~2x for ASCII input, which also makes :func:`urllib.parse.parse_qs` -and :func:`urllib.parse.parse_qsl` faster. Patch by Brendan Fahy. +by up to ~2x for ASCII input and for non-ASCII input decoded as UTF-8 (the +default), which also makes :func:`urllib.parse.parse_qs` and +:func:`urllib.parse.parse_qsl` faster. Patch by Brendan Fahy.