Skip to content

Commit 6f15cef

Browse files
committed
gh-159635: Use quote frequency to break csv.Sniffer ties
1 parent e5ef47e commit 6f15cef

3 files changed

Lines changed: 144 additions & 0 deletions

File tree

‎Lib/csv.py‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ def sniff(self, sample, delimiters=None):
361361

362362
best = None
363363
best_score = None
364+
best_field_start_count = None
364365
for combo, rows in sorted(parsed):
365366
if rows is None:
366367
continue
@@ -379,6 +380,18 @@ def sniff(self, sample, delimiters=None):
379380
if best_score is None or score > best_score:
380381
best_score = score
381382
best = combo[1:]
383+
best_field_start_count = None
384+
elif (score == best_score and delimiter == best[0]
385+
and escapechar == best[2] and quotechar != best[1]):
386+
# Do not let quote frequency affect delimiter or escape choice.
387+
if best_field_start_count is None:
388+
best_field_start_count = self._field_start_count(
389+
lines, best[1], best[0], best[2], cut)
390+
field_start_count = self._field_start_count(
391+
lines, quotechar, delimiter, escapechar, cut)
392+
if field_start_count > best_field_start_count:
393+
best = combo[1:]
394+
best_field_start_count = field_start_count
382395

383396
if best is None:
384397
raise Error("Could not determine delimiter")
@@ -401,6 +414,109 @@ class dialect(Dialect):
401414

402415
return dialect
403416

417+
def _field_start_count(self, lines, quotechar, delimiter, escapechar,
418+
cut):
419+
"""
420+
The number of fields in the sample starting with *quotechar*:
421+
at the start of a field. Used for breaking ties between quote
422+
characters.
423+
"""
424+
if not quotechar:
425+
return 0
426+
427+
class LineIterator:
428+
def __init__(self, lines):
429+
self.lines = iter(lines)
430+
self.position = 0
431+
432+
def __iter__(self):
433+
return self
434+
435+
def __next__(self):
436+
line = next(self.lines)
437+
self.position += 1
438+
return line
439+
440+
source = LineIterator(lines)
441+
record_counts = []
442+
start = 0
443+
# Keep the scan boundaries identical to the candidate reader's.
444+
reader = self._make_reader(source, delimiter, quotechar, escapechar)
445+
parse_error = False
446+
try:
447+
while True:
448+
next(reader)
449+
end = source.position
450+
record = ''.join(lines[start:end])
451+
record_counts.append(
452+
self._record_field_start_count(
453+
record, quotechar, delimiter, escapechar))
454+
start = end
455+
except StopIteration:
456+
pass
457+
except Error:
458+
# Match _try_dialect(): a failing row is not counted.
459+
parse_error = True
460+
if not parse_error and cut and len(record_counts) > 1:
461+
record_counts.pop()
462+
return sum(record_counts)
463+
464+
def _record_field_start_count(self, record, quotechar, delimiter,
465+
escapechar):
466+
if not record:
467+
return 0
468+
skipinitialspace = delimiter != ' '
469+
count = 0
470+
field_start = True
471+
skip_spaces = skipinitialspace
472+
active_quote = None
473+
pos = 0
474+
# The candidate reader fixes record boundaries; recognize either
475+
# quote mark within a record so embedded delimiters do not vote.
476+
while pos < len(record):
477+
char = record[pos]
478+
if active_quote is not None:
479+
if escapechar and char == escapechar:
480+
pos += 2
481+
continue
482+
if char == active_quote:
483+
if pos + 1 < len(record) and record[pos + 1] == char:
484+
pos += 2
485+
continue
486+
active_quote = None
487+
pos += 1
488+
continue
489+
490+
if escapechar and char == escapechar:
491+
field_start = False
492+
pos += 2
493+
continue
494+
if field_start:
495+
if skip_spaces and char == ' ':
496+
pos += 1
497+
continue
498+
if char in "\"'":
499+
if char == quotechar:
500+
count += 1
501+
active_quote = char
502+
field_start = False
503+
pos += 1
504+
continue
505+
if char in '\r\n':
506+
break
507+
if char == delimiter:
508+
skip_spaces = skipinitialspace
509+
pos += 1
510+
continue
511+
field_start = False
512+
elif char == delimiter:
513+
field_start = True
514+
skip_spaces = skipinitialspace
515+
elif char in '\r\n':
516+
break
517+
pos += 1
518+
return count
519+
404520
def _parses_as_single_column(self, sample):
405521
"""
406522
True if the whole sample parses as a single column of quoted

‎Lib/test/test_csv.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1855,6 +1855,29 @@ def test_sniff_regex_backtracking(self):
18551855
sample = '"",' * 100 + '"' * 100 + '0' + '"' * 100 + '0'
18561856
self.assertEqual(sniffer.sniff(sample).delimiter, ',')
18571857

1858+
def test_sniff_quotechar_tie_break(self):
1859+
# When both quote characters fit the sample equally well, the
1860+
# one starting more fields wins, not the one tried first.
1861+
# A single row of double-quoted fields used to make the sniffer
1862+
# report '"' even for a file quoted with "'".
1863+
sniffer = csv.Sniffer()
1864+
sample = "\n".join(["'a','b'"] * 99 + ['"k","l"']) + "\n"
1865+
dialect = sniffer.sniff(sample)
1866+
self.assertEqual(dialect.quotechar, "'")
1867+
self.assertEqual(list(csv.reader(StringIO(sample), dialect))[0],
1868+
['a', 'b'])
1869+
# The mirror sample is not ambiguous: '"' starts far more fields.
1870+
sample = "\n".join(['"a","b"'] * 99 + ["'k','l'"]) + "\n"
1871+
dialect = sniffer.sniff(sample)
1872+
self.assertEqual(dialect.quotechar, '"')
1873+
# A single unquoted field starting with a double quote does not
1874+
# outweigh the single-quoted fields.
1875+
sample = ("id,note\n"
1876+
+ "'1','a'\n" * 20
1877+
+ "5,\"N/A\"\n")
1878+
dialect = sniffer.sniff(sample)
1879+
self.assertEqual(dialect.quotechar, "'")
1880+
18581881
def test_doublequote(self):
18591882
sniffer = csv.Sniffer()
18601883
dialect = sniffer.sniff(self.header1)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
In ``csv.Sniffer.sniff()``, break ties between quote characters which fit
2+
the sample equally well by the number of fields starting with each quote
3+
character, instead of always preferring ``'"'``. This prevents misdetection
4+
of the quote character for files quoted with ``"'"`` that contain a row of
5+
double-quoted fields.

0 commit comments

Comments
 (0)