Skip to content

Commit cac5542

Browse files
gh-154744: Improve detection of skipinitialspace in csv.Sniffer
Detect the padding by parsing the sample both with and without skipinitialspace and comparing the two readings, instead of testing whether every field following a delimiter starts with a space.
1 parent 5062427 commit cac5542

3 files changed

Lines changed: 88 additions & 15 deletions

File tree

Lib/csv.py

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -563,22 +563,40 @@ def _detect_doublequote(self, lines, delimiter, quotechar, escapechar):
563563
def _detect_skipinitialspace(self, lines, delimiter, quotechar,
564564
escapechar, doublequote):
565565
"""
566-
True only if every field following a delimiter starts with
567-
a space.
566+
Detect whether the spaces following a delimiter are a part of
567+
the format or of the data.
568568
"""
569-
skipinitialspace = False
570-
try:
571-
for row in self._make_reader(lines, delimiter, quotechar,
572-
escapechar,
573-
doublequote=doublequote,
574-
skipinitialspace=False):
575-
for field in row[1:]:
576-
if not field.startswith(' '):
577-
return False
578-
skipinitialspace = True
579-
except Error:
580-
pass
581-
return skipinitialspace
569+
results = []
570+
for skipinitialspace in False, True:
571+
rows = []
572+
try:
573+
rows.extend(self._make_reader(
574+
lines, delimiter, quotechar, escapechar,
575+
doublequote=doublequote,
576+
skipinitialspace=skipinitialspace))
577+
except Error:
578+
# Keep the rows parsed before the error.
579+
pass
580+
results.append([row for row in rows if row])
581+
if results[0] == results[1]:
582+
return False # No evidence.
583+
counts = [[len(row) for row in rows] for rows in results]
584+
if counts[0] != counts[1]:
585+
# Prefer the more consistent row widths.
586+
return len(set(counts[1])) <= len(set(counts[0]))
587+
# Only some spaces are stripped. A field differs only if
588+
# a space was skipped at its start, which tells the padding
589+
# apart from the spaces inside quoted or escaped fields.
590+
if not all(kept_field != skipped_field
591+
for kept_row, skipped_row in zip(*results)
592+
for kept_field, skipped_field in zip(kept_row[1:],
593+
skipped_row[1:])):
594+
return False
595+
# The first field of a row is commonly not padded ('a, b, c'),
596+
# so the first fields need only agree with each other.
597+
first = [kept_row[0] != skipped_row[0]
598+
for kept_row, skipped_row in zip(*results)]
599+
return all(first) or not any(first)
582600

583601
def has_header(self, sample):
584602
# Creates a dictionary of types of data in each column. If any

Lib/test/test_csv.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1688,6 +1688,60 @@ def test_sniff_skipinitialspace_quoted(self):
16881688
self.assertEqual(dialect.quotechar, "'")
16891689
self.assertIs(dialect.skipinitialspace, True)
16901690

1691+
def test_sniff_skipinitialspace_quoted_fields(self):
1692+
# A quote is only a quote at the very start of a field, so not
1693+
# skipping the space splits the quoted field.
1694+
sniffer = csv.Sniffer()
1695+
sample = 'a, "b,c"\nd,e\nf,g\n'
1696+
dialect = sniffer.sniff(sample)
1697+
self.assertEqual(dialect.delimiter, ',')
1698+
self.assertEqual(dialect.quotechar, '"')
1699+
self.assertIs(dialect.skipinitialspace, True)
1700+
self.assertEqual(next(csv.reader(StringIO(sample), dialect)),
1701+
['a', 'b,c'])
1702+
1703+
# But without a delimiter inside the quotes nothing is split,
1704+
# so only the padding counts.
1705+
sample = 'a, "b"\nd,e\nf,g\n'
1706+
dialect = sniffer.sniff(sample)
1707+
self.assertEqual(dialect.delimiter, ',')
1708+
self.assertEqual(dialect.quotechar, '"')
1709+
self.assertIs(dialect.skipinitialspace, False)
1710+
self.assertEqual(next(csv.reader(StringIO(sample), dialect)),
1711+
['a', ' "b"'])
1712+
1713+
def test_sniff_skipinitialspace_data(self):
1714+
# The spaces are a part of the data if only some fields
1715+
# are padded.
1716+
sniffer = csv.Sniffer()
1717+
sample = 'a, b\nc,d\ne, f\n'
1718+
dialect = sniffer.sniff(sample)
1719+
self.assertEqual(dialect.delimiter, ',')
1720+
self.assertIs(dialect.skipinitialspace, False)
1721+
self.assertEqual(next(csv.reader(StringIO(sample), dialect)),
1722+
['a', ' b'])
1723+
1724+
def test_sniff_skipinitialspace_not_skipped(self):
1725+
# A quoted or escaped space is not skipped, so it is not
1726+
# an evidence of the padding.
1727+
sniffer = csv.Sniffer()
1728+
sample = 'a," b"\nc, d\n'
1729+
dialect = sniffer.sniff(sample)
1730+
self.assertEqual(dialect.delimiter, ',')
1731+
self.assertEqual(dialect.quotechar, '"')
1732+
self.assertIs(dialect.skipinitialspace, False)
1733+
self.assertEqual(list(csv.reader(StringIO(sample), dialect)),
1734+
[['a', ' b'], ['c', ' d']])
1735+
1736+
# The escaped delimiter forces the escapechar detection.
1737+
sample = 'a,\\ b\\,c\nd, e\n'
1738+
dialect = sniffer.sniff(sample)
1739+
self.assertEqual(dialect.delimiter, ',')
1740+
self.assertEqual(dialect.escapechar, '\\')
1741+
self.assertIs(dialect.skipinitialspace, False)
1742+
self.assertEqual(list(csv.reader(StringIO(sample), dialect)),
1743+
[['a', ' b,c'], ['d', ' e']])
1744+
16911745
def test_sniff_regex_backtracking(self):
16921746
# gh-109638: this artificial sample used to take minutes.
16931747
sniffer = csv.Sniffer()
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Improve detection of ``skipinitialspace`` in :meth:`csv.Sniffer.sniff`.

0 commit comments

Comments
 (0)