diff --git a/Lib/csv.py b/Lib/csv.py index a84234f110dd70c..c66717dc1ee59e7 100644 --- a/Lib/csv.py +++ b/Lib/csv.py @@ -563,22 +563,40 @@ def _detect_doublequote(self, lines, delimiter, quotechar, escapechar): def _detect_skipinitialspace(self, lines, delimiter, quotechar, escapechar, doublequote): """ - True only if every field following a delimiter starts with - a space. + Detect whether the spaces following a delimiter are a part of + the format or of the data. """ - skipinitialspace = False - try: - for row in self._make_reader(lines, delimiter, quotechar, - escapechar, - doublequote=doublequote, - skipinitialspace=False): - for field in row[1:]: - if not field.startswith(' '): - return False - skipinitialspace = True - except Error: - pass - return skipinitialspace + results = [] + for skipinitialspace in False, True: + rows = [] + try: + rows.extend(self._make_reader( + lines, delimiter, quotechar, escapechar, + doublequote=doublequote, + skipinitialspace=skipinitialspace)) + except Error: + # Keep the rows parsed before the error. + pass + results.append([row for row in rows if row]) + if results[0] == results[1]: + return False # No evidence. + counts = [[len(row) for row in rows] for rows in results] + if counts[0] != counts[1]: + # Prefer the more consistent row widths. + return len(set(counts[1])) <= len(set(counts[0])) + # Only some spaces are stripped. A field differs only if + # a space was skipped at its start, which tells the padding + # apart from the spaces inside quoted or escaped fields. + if not all(kept_field != skipped_field + for kept_row, skipped_row in zip(*results) + for kept_field, skipped_field in zip(kept_row[1:], + skipped_row[1:])): + return False + # The first field of a row is commonly not padded ('a, b, c'), + # so the first fields need only agree with each other. + first = [kept_row[0] != skipped_row[0] + for kept_row, skipped_row in zip(*results)] + return all(first) or not any(first) def has_header(self, sample): # Creates a dictionary of types of data in each column. If any diff --git a/Lib/test/test_csv.py b/Lib/test/test_csv.py index ded53e936d19632..91170cc16b3ac95 100644 --- a/Lib/test/test_csv.py +++ b/Lib/test/test_csv.py @@ -1688,6 +1688,60 @@ def test_sniff_skipinitialspace_quoted(self): self.assertEqual(dialect.quotechar, "'") self.assertIs(dialect.skipinitialspace, True) + def test_sniff_skipinitialspace_quoted_fields(self): + # A quote is only a quote at the very start of a field, so not + # skipping the space splits the quoted field. + sniffer = csv.Sniffer() + sample = 'a, "b,c"\nd,e\nf,g\n' + dialect = sniffer.sniff(sample) + self.assertEqual(dialect.delimiter, ',') + self.assertEqual(dialect.quotechar, '"') + self.assertIs(dialect.skipinitialspace, True) + self.assertEqual(next(csv.reader(StringIO(sample), dialect)), + ['a', 'b,c']) + + # But without a delimiter inside the quotes nothing is split, + # so only the padding counts. + sample = 'a, "b"\nd,e\nf,g\n' + dialect = sniffer.sniff(sample) + self.assertEqual(dialect.delimiter, ',') + self.assertEqual(dialect.quotechar, '"') + self.assertIs(dialect.skipinitialspace, False) + self.assertEqual(next(csv.reader(StringIO(sample), dialect)), + ['a', ' "b"']) + + def test_sniff_skipinitialspace_data(self): + # The spaces are a part of the data if only some fields + # are padded. + sniffer = csv.Sniffer() + sample = 'a, b\nc,d\ne, f\n' + dialect = sniffer.sniff(sample) + self.assertEqual(dialect.delimiter, ',') + self.assertIs(dialect.skipinitialspace, False) + self.assertEqual(next(csv.reader(StringIO(sample), dialect)), + ['a', ' b']) + + def test_sniff_skipinitialspace_not_skipped(self): + # A quoted or escaped space is not skipped, so it is not + # an evidence of the padding. + sniffer = csv.Sniffer() + sample = 'a," b"\nc, d\n' + dialect = sniffer.sniff(sample) + self.assertEqual(dialect.delimiter, ',') + self.assertEqual(dialect.quotechar, '"') + self.assertIs(dialect.skipinitialspace, False) + self.assertEqual(list(csv.reader(StringIO(sample), dialect)), + [['a', ' b'], ['c', ' d']]) + + # The escaped delimiter forces the escapechar detection. + sample = 'a,\\ b\\,c\nd, e\n' + dialect = sniffer.sniff(sample) + self.assertEqual(dialect.delimiter, ',') + self.assertEqual(dialect.escapechar, '\\') + self.assertIs(dialect.skipinitialspace, False) + self.assertEqual(list(csv.reader(StringIO(sample), dialect)), + [['a', ' b,c'], ['d', ' e']]) + def test_sniff_regex_backtracking(self): # gh-109638: this artificial sample used to take minutes. sniffer = csv.Sniffer() diff --git a/Misc/NEWS.d/next/Library/2026-07-26-13-40-00.gh-issue-154744.Sk1Psp.rst b/Misc/NEWS.d/next/Library/2026-07-26-13-40-00.gh-issue-154744.Sk1Psp.rst new file mode 100644 index 000000000000000..500826c195346f7 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-26-13-40-00.gh-issue-154744.Sk1Psp.rst @@ -0,0 +1 @@ +Improve detection of ``skipinitialspace`` in :meth:`csv.Sniffer.sniff`.