Skip to content

Commit d52184b

Browse files
[3.15] gh-109638: Fix exponential time in csv.Sniffer for doubled quotes (#154868)
1 parent 847d099 commit d52184b

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
@@ -247,6 +247,8 @@ def sniff(self, sample, delimiters=None):
247247
that order, no matter how many times each of them occurs.
248248
"""
249249

250+
sample = sample.replace('\r\n', '\n').replace('\r', '\n')
251+
250252
quotechar, doublequote, delimiter, skipinitialspace = \
251253
self._guess_quote_and_delimiter(sample, delimiters)
252254
if not delimiter:
@@ -332,18 +334,22 @@ def _guess_quote_and_delimiter(self, data, delimiters):
332334
delim = ''
333335
skipinitialspace = 0
334336

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

348354
return (quotechar, doublequote, delim, skipinitialspace)
349355

Lib/test/test_csv.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1564,6 +1564,50 @@ def test_zero_mode_tie_order_colon_first(self):
15641564
sniffer.sniff(sample)
15651565

15661566

1567+
def test_sniff_regex_backtracking(self):
1568+
# gh-109638: this artificial sample used to take minutes.
1569+
sniffer = csv.Sniffer()
1570+
sample = '"",' * 100 + '"' * 100 + '0' + '"' * 100 + '0'
1571+
self.assertEqual(sniffer.sniff(sample).delimiter, ',')
1572+
1573+
def test_sniff_doublequote_across_fields(self):
1574+
# A quoted field which contains the delimiter, followed by
1575+
# an empty quoted field, is not a doubled quote.
1576+
sniffer = csv.Sniffer()
1577+
sample = '",","",","\n' * 4
1578+
dialect = sniffer.sniff(sample)
1579+
self.assertEqual(dialect.delimiter, ',')
1580+
self.assertEqual(dialect.quotechar, '"')
1581+
self.assertIs(dialect.doublequote, False)
1582+
self.assertEqual(next(csv.reader(StringIO(sample), dialect)),
1583+
[',', '', ','])
1584+
1585+
def test_sniff_doublequote_record_separators(self):
1586+
# The record separator ends a field as a delimiter does.
1587+
sniffer = csv.Sniffer()
1588+
for sep in '\n', '\r\n', '\r':
1589+
with self.subTest(sep=sep):
1590+
sample = ('x,"a""b"' + sep + 'y,"c"' + sep) * 2
1591+
self.assertIs(sniffer.sniff(sample).doublequote, True)
1592+
sample = ('"",","' + sep) * 4
1593+
self.assertIs(sniffer.sniff(sample).doublequote, False)
1594+
1595+
def test_sniff_single_column(self):
1596+
# This sample used to be quadratic.
1597+
sniffer = csv.Sniffer()
1598+
sample = '"a"\n' + ' ' * 100000
1599+
with self.assertRaisesRegex(csv.Error, "Could not determine delimiter"):
1600+
sniffer.sniff(sample, delimiters=',;')
1601+
1602+
def test_sniff_space_delimiter(self):
1603+
# This sample used to be quadratic.
1604+
sniffer = csv.Sniffer()
1605+
sample = '"a" "b"\n' + ' ' * 100000
1606+
dialect = sniffer.sniff(sample)
1607+
self.assertEqual(dialect.delimiter, ' ')
1608+
self.assertIs(dialect.doublequote, False)
1609+
1610+
15671611
class NUL:
15681612
def write(s, *args):
15691613
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)