Skip to content

Commit eaa9bac

Browse files
[3.13] gh-109638: Fix exponential time in csv.Sniffer for doubled quotes (GH-154868) (GH-155113)
(cherry picked from commit d52184b)
1 parent ed94ace commit eaa9bac

3 files changed

Lines changed: 65 additions & 12 deletions

File tree

Lib/csv.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,8 @@ def sniff(self, sample, delimiters=None):
250250
that order, no matter how many times each of them occurs.
251251
"""
252252

253+
sample = sample.replace('\r\n', '\n').replace('\r', '\n')
254+
253255
quotechar, doublequote, delimiter, skipinitialspace = \
254256
self._guess_quote_and_delimiter(sample, delimiters)
255257
if not delimiter:
@@ -334,18 +336,22 @@ def _guess_quote_and_delimiter(self, data, delimiters):
334336
delim = ''
335337
skipinitialspace = 0
336338

337-
# if we see an extra quote between delimiters, we've got a
338-
# double quoted format
339-
dq_regexp = re.compile(
340-
r"((%(delim)s)|^)\W*%(quote)s[^%(delim)s\n]*%(quote)s[^%(delim)s\n]*%(quote)s\W*((%(delim)s)|$)" % \
341-
{'delim':re.escape(delim), 'quote':quotechar}, re.MULTILINE)
342-
343-
344-
345-
if dq_regexp.search(data):
346-
doublequote = True
347-
else:
348-
doublequote = False
339+
# A doubled quote character inside a quoted field means
340+
# a double quoted format. Match whole fields, so that a match
341+
# cannot slide across field boundaries.
342+
doublequote = False
343+
if delim:
344+
dq_regexp = re.compile(
345+
r"(?:(?<=%(delim)s)|^)%(space)s%(quote)s" # ,"
346+
r"((?:%(quote)s%(quote)s|[^%(quote)s]++)*+)" # the body
347+
r"%(quote)s(?:%(delim)s|$)" # ",
348+
% {'delim': re.escape(delim), 'quote': quotechar,
349+
# Skipping spaces after a space rescans them.
350+
'space': ' *+' if delim != ' ' else ''},
351+
re.MULTILINE)
352+
dquotechar = quotechar * 2
353+
doublequote = any(dquotechar in m[1]
354+
for m in dq_regexp.finditer(data))
349355

350356
return (quotechar, doublequote, delim, skipinitialspace)
351357

Lib/test/test_csv.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1447,6 +1447,50 @@ def test_doublequote(self):
14471447
dialect = sniffer.sniff(self.sample9)
14481448
self.assertTrue(dialect.doublequote)
14491449

1450+
def test_sniff_regex_backtracking(self):
1451+
# gh-109638: this artificial sample used to take minutes.
1452+
sniffer = csv.Sniffer()
1453+
sample = '"",' * 100 + '"' * 100 + '0' + '"' * 100 + '0'
1454+
self.assertEqual(sniffer.sniff(sample).delimiter, ',')
1455+
1456+
def test_sniff_doublequote_across_fields(self):
1457+
# A quoted field which contains the delimiter, followed by
1458+
# an empty quoted field, is not a doubled quote.
1459+
sniffer = csv.Sniffer()
1460+
sample = '",","",","\n' * 4
1461+
dialect = sniffer.sniff(sample)
1462+
self.assertEqual(dialect.delimiter, ',')
1463+
self.assertEqual(dialect.quotechar, '"')
1464+
self.assertIs(dialect.doublequote, False)
1465+
self.assertEqual(next(csv.reader(StringIO(sample), dialect)),
1466+
[',', '', ','])
1467+
1468+
def test_sniff_doublequote_record_separators(self):
1469+
# The record separator ends a field as a delimiter does.
1470+
sniffer = csv.Sniffer()
1471+
for sep in '\n', '\r\n', '\r':
1472+
with self.subTest(sep=sep):
1473+
sample = ('x,"a""b"' + sep + 'y,"c"' + sep) * 2
1474+
self.assertIs(sniffer.sniff(sample).doublequote, True)
1475+
sample = ('"",","' + sep) * 4
1476+
self.assertIs(sniffer.sniff(sample).doublequote, False)
1477+
1478+
def test_sniff_single_column(self):
1479+
# This sample used to be quadratic.
1480+
sniffer = csv.Sniffer()
1481+
sample = '"a"\n' + ' ' * 100000
1482+
with self.assertRaisesRegex(csv.Error, "Could not determine delimiter"):
1483+
sniffer.sniff(sample, delimiters=',;')
1484+
1485+
def test_sniff_space_delimiter(self):
1486+
# This sample used to be quadratic.
1487+
sniffer = csv.Sniffer()
1488+
sample = '"a" "b"\n' + ' ' * 100000
1489+
dialect = sniffer.sniff(sample)
1490+
self.assertEqual(dialect.delimiter, ' ')
1491+
self.assertIs(dialect.doublequote, False)
1492+
1493+
14501494
class NUL:
14511495
def write(s, *args):
14521496
pass
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fix exponential time in :meth:`csv.Sniffer.sniff` for a sample which contains
2+
many quote characters. A doubled quote character is now also detected in
3+
a field which contains the delimiter or a line break.

0 commit comments

Comments
 (0)