Skip to content

Commit 5b23575

Browse files
serhiy-storchakamiss-islington
authored andcommitted
[3.13] gh-109638: Fix exponential time in csv.Sniffer for doubled quotes (GH-154868) (GH-155113)
(cherry picked from commit eaa9bac) Co-authored-by: Serhiy Storchaka <storchaka@gmail.com> (cherry picked from commit d52184b)
1 parent d2b2f5e commit 5b23575

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
@@ -177,6 +177,8 @@ def sniff(self, sample, delimiters=None):
177177
Returns a dialect (or None) corresponding to the sample
178178
"""
179179

180+
sample = sample.replace('\r\n', '\n').replace('\r', '\n')
181+
180182
quotechar, doublequote, delimiter, skipinitialspace = \
181183
self._guess_quote_and_delimiter(sample, delimiters)
182184
if not delimiter:
@@ -261,18 +263,22 @@ def _guess_quote_and_delimiter(self, data, delimiters):
261263
delim = ''
262264
skipinitialspace = 0
263265

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

277283
return (quotechar, doublequote, delim, skipinitialspace)
278284

Lib/test/test_csv.py

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

1317+
def test_sniff_regex_backtracking(self):
1318+
# gh-109638: this artificial sample used to take minutes.
1319+
sniffer = csv.Sniffer()
1320+
sample = '"",' * 100 + '"' * 100 + '0' + '"' * 100 + '0'
1321+
self.assertEqual(sniffer.sniff(sample).delimiter, ',')
1322+
1323+
def test_sniff_doublequote_across_fields(self):
1324+
# A quoted field which contains the delimiter, followed by
1325+
# an empty quoted field, is not a doubled quote.
1326+
sniffer = csv.Sniffer()
1327+
sample = '",","",","\n' * 4
1328+
dialect = sniffer.sniff(sample)
1329+
self.assertEqual(dialect.delimiter, ',')
1330+
self.assertEqual(dialect.quotechar, '"')
1331+
self.assertIs(dialect.doublequote, False)
1332+
self.assertEqual(next(csv.reader(StringIO(sample), dialect)),
1333+
[',', '', ','])
1334+
1335+
def test_sniff_doublequote_record_separators(self):
1336+
# The record separator ends a field as a delimiter does.
1337+
sniffer = csv.Sniffer()
1338+
for sep in '\n', '\r\n', '\r':
1339+
with self.subTest(sep=sep):
1340+
sample = ('x,"a""b"' + sep + 'y,"c"' + sep) * 2
1341+
self.assertIs(sniffer.sniff(sample).doublequote, True)
1342+
sample = ('"",","' + sep) * 4
1343+
self.assertIs(sniffer.sniff(sample).doublequote, False)
1344+
1345+
def test_sniff_single_column(self):
1346+
# This sample used to be quadratic.
1347+
sniffer = csv.Sniffer()
1348+
sample = '"a"\n' + ' ' * 100000
1349+
with self.assertRaisesRegex(csv.Error, "Could not determine delimiter"):
1350+
sniffer.sniff(sample, delimiters=',;')
1351+
1352+
def test_sniff_space_delimiter(self):
1353+
# This sample used to be quadratic.
1354+
sniffer = csv.Sniffer()
1355+
sample = '"a" "b"\n' + ' ' * 100000
1356+
dialect = sniffer.sniff(sample)
1357+
self.assertEqual(dialect.delimiter, ' ')
1358+
self.assertIs(dialect.doublequote, False)
1359+
1360+
13171361
class NUL:
13181362
def write(s, *args):
13191363
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)