https://github.com/python/cpython/commit/eaa9bac70343f2e0a8499ac4a1894ef7608ef0fa
commit: eaa9bac70343f2e0a8499ac4a1894ef7608ef0fa
branch: 3.13
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-08-03T14:46:44+03:00
summary:
[3.13] gh-109638: Fix exponential time in csv.Sniffer for doubled quotes
(GH-154868) (GH-155113)
(cherry picked from commit d52184ba1ed0e7c0fe8afe4928fbb72a0f9526fb)
files:
A Misc/NEWS.d/next/Library/2026-07-29-11-25-00.gh-issue-109638.Vt2Rn9.rst
M Lib/csv.py
M Lib/test/test_csv.py
diff --git a/Lib/csv.py b/Lib/csv.py
index 905e5b6ac077d3..c2ce70352a8fd6 100644
--- a/Lib/csv.py
+++ b/Lib/csv.py
@@ -250,6 +250,8 @@ def sniff(self, sample, delimiters=None):
that order, no matter how many times each of them occurs.
"""
+ sample = sample.replace('\r\n', '\n').replace('\r', '\n')
+
quotechar, doublequote, delimiter, skipinitialspace = \
self._guess_quote_and_delimiter(sample, delimiters)
if not delimiter:
@@ -334,18 +336,22 @@ def _guess_quote_and_delimiter(self, data, delimiters):
delim = ''
skipinitialspace = 0
- # if we see an extra quote between delimiters, we've got a
- # double quoted format
- dq_regexp = re.compile(
-
r"((%(delim)s)|^)\W*%(quote)s[^%(delim)s\n]*%(quote)s[^%(delim)s\n]*%(quote)s\W*((%(delim)s)|$)"
% \
- {'delim':re.escape(delim), 'quote':quotechar},
re.MULTILINE)
-
-
-
- if dq_regexp.search(data):
- doublequote = True
- else:
- doublequote = False
+ # A doubled quote character inside a quoted field means
+ # a double quoted format. Match whole fields, so that a match
+ # cannot slide across field boundaries.
+ doublequote = False
+ if delim:
+ dq_regexp = re.compile(
+ r"(?:(?<=%(delim)s)|^)%(space)s%(quote)s" # ,"
+ r"((?:%(quote)s%(quote)s|[^%(quote)s]++)*+)" # the body
+ r"%(quote)s(?:%(delim)s|$)" # ",
+ % {'delim': re.escape(delim), 'quote': quotechar,
+ # Skipping spaces after a space rescans them.
+ 'space': ' *+' if delim != ' ' else ''},
+ re.MULTILINE)
+ dquotechar = quotechar * 2
+ doublequote = any(dquotechar in m[1]
+ for m in dq_regexp.finditer(data))
return (quotechar, doublequote, delim, skipinitialspace)
diff --git a/Lib/test/test_csv.py b/Lib/test/test_csv.py
index b6e578c40e7471..575bd9c949e4fb 100644
--- a/Lib/test/test_csv.py
+++ b/Lib/test/test_csv.py
@@ -1447,6 +1447,50 @@ def test_doublequote(self):
dialect = sniffer.sniff(self.sample9)
self.assertTrue(dialect.doublequote)
+ def test_sniff_regex_backtracking(self):
+ # gh-109638: this artificial sample used to take minutes.
+ sniffer = csv.Sniffer()
+ sample = '"",' * 100 + '"' * 100 + '0' + '"' * 100 + '0'
+ self.assertEqual(sniffer.sniff(sample).delimiter, ',')
+
+ def test_sniff_doublequote_across_fields(self):
+ # A quoted field which contains the delimiter, followed by
+ # an empty quoted field, is not a doubled quote.
+ sniffer = csv.Sniffer()
+ sample = '",","",","\n' * 4
+ dialect = sniffer.sniff(sample)
+ self.assertEqual(dialect.delimiter, ',')
+ self.assertEqual(dialect.quotechar, '"')
+ self.assertIs(dialect.doublequote, False)
+ self.assertEqual(next(csv.reader(StringIO(sample), dialect)),
+ [',', '', ','])
+
+ def test_sniff_doublequote_record_separators(self):
+ # The record separator ends a field as a delimiter does.
+ sniffer = csv.Sniffer()
+ for sep in '\n', '\r\n', '\r':
+ with self.subTest(sep=sep):
+ sample = ('x,"a""b"' + sep + 'y,"c"' + sep) * 2
+ self.assertIs(sniffer.sniff(sample).doublequote, True)
+ sample = ('"",","' + sep) * 4
+ self.assertIs(sniffer.sniff(sample).doublequote, False)
+
+ def test_sniff_single_column(self):
+ # This sample used to be quadratic.
+ sniffer = csv.Sniffer()
+ sample = '"a"\n' + ' ' * 100000
+ with self.assertRaisesRegex(csv.Error, "Could not determine
delimiter"):
+ sniffer.sniff(sample, delimiters=',;')
+
+ def test_sniff_space_delimiter(self):
+ # This sample used to be quadratic.
+ sniffer = csv.Sniffer()
+ sample = '"a" "b"\n' + ' ' * 100000
+ dialect = sniffer.sniff(sample)
+ self.assertEqual(dialect.delimiter, ' ')
+ self.assertIs(dialect.doublequote, False)
+
+
class NUL:
def write(s, *args):
pass
diff --git
a/Misc/NEWS.d/next/Library/2026-07-29-11-25-00.gh-issue-109638.Vt2Rn9.rst
b/Misc/NEWS.d/next/Library/2026-07-29-11-25-00.gh-issue-109638.Vt2Rn9.rst
new file mode 100644
index 00000000000000..f44163a20c8278
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-07-29-11-25-00.gh-issue-109638.Vt2Rn9.rst
@@ -0,0 +1,3 @@
+Fix exponential time in :meth:`csv.Sniffer.sniff` for a sample which contains
+many quote characters. A doubled quote character is now also detected in
+a field which contains the delimiter or a line break.
_______________________________________________
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]