Skip to content

Commit 83ac69c

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 cf23b91 commit 83ac69c

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
@@ -1189,6 +1189,50 @@ def test_doublequote(self):
11891189
dialect = sniffer.sniff(self.sample9)
11901190
self.assertTrue(dialect.doublequote)
11911191

1192+
def test_sniff_regex_backtracking(self):
1193+
# gh-109638: this artificial sample used to take minutes.
1194+
sniffer = csv.Sniffer()
1195+
sample = '"",' * 100 + '"' * 100 + '0' + '"' * 100 + '0'
1196+
self.assertEqual(sniffer.sniff(sample).delimiter, ',')
1197+
1198+
def test_sniff_doublequote_across_fields(self):
1199+
# A quoted field which contains the delimiter, followed by
1200+
# an empty quoted field, is not a doubled quote.
1201+
sniffer = csv.Sniffer()
1202+
sample = '",","",","\n' * 4
1203+
dialect = sniffer.sniff(sample)
1204+
self.assertEqual(dialect.delimiter, ',')
1205+
self.assertEqual(dialect.quotechar, '"')
1206+
self.assertIs(dialect.doublequote, False)
1207+
self.assertEqual(next(csv.reader(StringIO(sample), dialect)),
1208+
[',', '', ','])
1209+
1210+
def test_sniff_doublequote_record_separators(self):
1211+
# The record separator ends a field as a delimiter does.
1212+
sniffer = csv.Sniffer()
1213+
for sep in '\n', '\r\n', '\r':
1214+
with self.subTest(sep=sep):
1215+
sample = ('x,"a""b"' + sep + 'y,"c"' + sep) * 2
1216+
self.assertIs(sniffer.sniff(sample).doublequote, True)
1217+
sample = ('"",","' + sep) * 4
1218+
self.assertIs(sniffer.sniff(sample).doublequote, False)
1219+
1220+
def test_sniff_single_column(self):
1221+
# This sample used to be quadratic.
1222+
sniffer = csv.Sniffer()
1223+
sample = '"a"\n' + ' ' * 100000
1224+
with self.assertRaisesRegex(csv.Error, "Could not determine delimiter"):
1225+
sniffer.sniff(sample, delimiters=',;')
1226+
1227+
def test_sniff_space_delimiter(self):
1228+
# This sample used to be quadratic.
1229+
sniffer = csv.Sniffer()
1230+
sample = '"a" "b"\n' + ' ' * 100000
1231+
dialect = sniffer.sniff(sample)
1232+
self.assertEqual(dialect.delimiter, ' ')
1233+
self.assertIs(dialect.doublequote, False)
1234+
1235+
11921236
class NUL:
11931237
def write(s, *args):
11941238
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)