Skip to content

Commit 94ea52f

Browse files
[3.12] gh-109638: Fix exponential time in csv.Sniffer for doubled quotes (GH-154868) (GH-155113) (#155114)
[3.13] gh-109638: Fix exponential time in csv.Sniffer for doubled quotes (GH-154868) (GH-155113) (cherry picked from commit eaa9bac) (cherry picked from commit d52184b) Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
1 parent 96510a3 commit 94ea52f

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
@@ -184,6 +184,8 @@ def sniff(self, sample, delimiters=None):
184184
Returns a dialect (or None) corresponding to the sample
185185
"""
186186

187+
sample = sample.replace('\r\n', '\n').replace('\r', '\n')
188+
187189
quotechar, doublequote, delimiter, skipinitialspace = \
188190
self._guess_quote_and_delimiter(sample, delimiters)
189191
if not delimiter:
@@ -268,18 +270,22 @@ def _guess_quote_and_delimiter(self, data, delimiters):
268270
delim = ''
269271
skipinitialspace = 0
270272

271-
# if we see an extra quote between delimiters, we've got a
272-
# double quoted format
273-
dq_regexp = re.compile(
274-
r"((%(delim)s)|^)\W*%(quote)s[^%(delim)s\n]*%(quote)s[^%(delim)s\n]*%(quote)s\W*((%(delim)s)|$)" % \
275-
{'delim':re.escape(delim), 'quote':quotechar}, re.MULTILINE)
276-
277-
278-
279-
if dq_regexp.search(data):
280-
doublequote = True
281-
else:
282-
doublequote = False
273+
# A doubled quote character inside a quoted field means
274+
# a double quoted format. Match whole fields, so that a match
275+
# cannot slide across field boundaries.
276+
doublequote = False
277+
if delim:
278+
dq_regexp = re.compile(
279+
r"(?:(?<=%(delim)s)|^)%(space)s%(quote)s" # ,"
280+
r"((?:%(quote)s%(quote)s|[^%(quote)s]++)*+)" # the body
281+
r"%(quote)s(?:%(delim)s|$)" # ",
282+
% {'delim': re.escape(delim), 'quote': quotechar,
283+
# Skipping spaces after a space rescans them.
284+
'space': ' *+' if delim != ' ' else ''},
285+
re.MULTILINE)
286+
dquotechar = quotechar * 2
287+
doublequote = any(dquotechar in m[1]
288+
for m in dq_regexp.finditer(data))
283289

284290
return (quotechar, doublequote, delim, skipinitialspace)
285291

Lib/test/test_csv.py

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

1375+
def test_sniff_regex_backtracking(self):
1376+
# gh-109638: this artificial sample used to take minutes.
1377+
sniffer = csv.Sniffer()
1378+
sample = '"",' * 100 + '"' * 100 + '0' + '"' * 100 + '0'
1379+
self.assertEqual(sniffer.sniff(sample).delimiter, ',')
1380+
1381+
def test_sniff_doublequote_across_fields(self):
1382+
# A quoted field which contains the delimiter, followed by
1383+
# an empty quoted field, is not a doubled quote.
1384+
sniffer = csv.Sniffer()
1385+
sample = '",","",","\n' * 4
1386+
dialect = sniffer.sniff(sample)
1387+
self.assertEqual(dialect.delimiter, ',')
1388+
self.assertEqual(dialect.quotechar, '"')
1389+
self.assertIs(dialect.doublequote, False)
1390+
self.assertEqual(next(csv.reader(StringIO(sample), dialect)),
1391+
[',', '', ','])
1392+
1393+
def test_sniff_doublequote_record_separators(self):
1394+
# The record separator ends a field as a delimiter does.
1395+
sniffer = csv.Sniffer()
1396+
for sep in '\n', '\r\n', '\r':
1397+
with self.subTest(sep=sep):
1398+
sample = ('x,"a""b"' + sep + 'y,"c"' + sep) * 2
1399+
self.assertIs(sniffer.sniff(sample).doublequote, True)
1400+
sample = ('"",","' + sep) * 4
1401+
self.assertIs(sniffer.sniff(sample).doublequote, False)
1402+
1403+
def test_sniff_single_column(self):
1404+
# This sample used to be quadratic.
1405+
sniffer = csv.Sniffer()
1406+
sample = '"a"\n' + ' ' * 100000
1407+
with self.assertRaisesRegex(csv.Error, "Could not determine delimiter"):
1408+
sniffer.sniff(sample, delimiters=',;')
1409+
1410+
def test_sniff_space_delimiter(self):
1411+
# This sample used to be quadratic.
1412+
sniffer = csv.Sniffer()
1413+
sample = '"a" "b"\n' + ' ' * 100000
1414+
dialect = sniffer.sniff(sample)
1415+
self.assertEqual(dialect.delimiter, ' ')
1416+
self.assertIs(dialect.doublequote, False)
1417+
1418+
13751419
class NUL:
13761420
def write(s, *args):
13771421
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)