From 92b3133c442f5c6ef2f5a39c592e48b407e1fa52 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Wed, 29 Jul 2026 13:43:45 +0300 Subject: [PATCH 1/3] gh-109638: Fix exponential time in csv.Sniffer for doubled quotes The regular expression which looks for a doubled quote character let its runs match the quote character too, so every quote could be taken either as a part of a run or as one of the matched quotes. Exclude the quote character from the runs and match them possessively. The runs no longer exclude the delimiter and the line break either, so a doubled quote is now also found in a field which contains them. --- Lib/csv.py | 10 ++++++---- Lib/test/test_csv.py | 7 +++++++ .../2026-07-29-11-25-00.gh-issue-109638.Vt2Rn9.rst | 3 +++ 3 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-29-11-25-00.gh-issue-109638.Vt2Rn9.rst diff --git a/Lib/csv.py b/Lib/csv.py index 6cae34c705777dd..26390d3b0bf2f14 100644 --- a/Lib/csv.py +++ b/Lib/csv.py @@ -331,10 +331,12 @@ def _guess_quote_and_delimiter(self, data, delimiters): # 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) - - + r"(?:%(delim)s|^) *+%(quote)s" # ," + r"[^%(quote)s]*+%(quote)s%(quote)s" # the doubled quote + r"(?:%(quote)s%(quote)s|[^%(quote)s]++)*+" # the rest of the field + r"%(quote)s(?:%(delim)s|$)" # ", + % {'delim': re.escape(delim), 'quote': quotechar}, + re.MULTILINE) if dq_regexp.search(data): doublequote = True diff --git a/Lib/test/test_csv.py b/Lib/test/test_csv.py index 2ab529b51c207d0..c273efc6df791a1 100644 --- a/Lib/test/test_csv.py +++ b/Lib/test/test_csv.py @@ -1564,6 +1564,13 @@ def test_zero_mode_tie_order_colon_first(self): sniffer.sniff(sample) + 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, ',') + + 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 000000000000000..f44163a20c8278e --- /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. From 88969d7caa1f058beaf6123fd95a15b0bbdce0ed Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Thu, 30 Jul 2026 11:15:11 +0300 Subject: [PATCH 2/3] Do not report a doubled quote across field boundaries The pattern required a doubled quote character in the middle, so it could match from a delimiter inside one quoted field to a quote in another one, and reported a double quoted format for '",","",","'. Match whole quoted fields instead and test their bodies for a doubled quote separately. Only look behind the delimiter, because the trailing one is consumed, so that consecutive fields are all matched. --- Lib/csv.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/Lib/csv.py b/Lib/csv.py index 26390d3b0bf2f14..fb4baf83ae3f5d4 100644 --- a/Lib/csv.py +++ b/Lib/csv.py @@ -328,20 +328,17 @@ 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 + # 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. dq_regexp = re.compile( - r"(?:%(delim)s|^) *+%(quote)s" # ," - r"[^%(quote)s]*+%(quote)s%(quote)s" # the doubled quote - r"(?:%(quote)s%(quote)s|[^%(quote)s]++)*+" # the rest of the field - r"%(quote)s(?:%(delim)s|$)" # ", + r"(?:(?<=%(delim)s)|^) *+%(quote)s" # ," + r"((?:%(quote)s%(quote)s|[^%(quote)s]++)*+)" # the body + r"%(quote)s(?:%(delim)s|$)" # ", % {'delim': re.escape(delim), 'quote': quotechar}, re.MULTILINE) - - if dq_regexp.search(data): - doublequote = True - else: - doublequote = False + dquotechar = quotechar * 2 + doublequote = any(dquotechar in m[1] for m in dq_regexp.finditer(data)) return (quotechar, doublequote, delim, skipinitialspace) From e78bb80875aca9ae2c53427a0bc8a59d7ecb4d87 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Thu, 30 Jul 2026 11:19:33 +0300 Subject: [PATCH 3/3] Add a test for a doubled quote across field boundaries --- Lib/test/test_csv.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Lib/test/test_csv.py b/Lib/test/test_csv.py index c273efc6df791a1..8bb6bed36b43752 100644 --- a/Lib/test/test_csv.py +++ b/Lib/test/test_csv.py @@ -1570,6 +1570,18 @@ def test_sniff_regex_backtracking(self): 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)), + [',', '', ',']) + class NUL: def write(s, *args):