[3.13] gh-109638: Fix exponential time in csv.Sniffer for doubled quotes (GH-154868) (GH-155113)

serhiy-storchaka <[email protected]> Mon, 03 Aug 2026 07:46:57 -0400 (EDT)
Newsgroups gmane.comp.python.cvs
Message-ID <[email protected]>
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]