]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-154744: Improve detection of skipinitialspace in csv.Sniffer (GH-154745)
authorSerhiy Storchaka <storchaka@gmail.com>
Mon, 27 Jul 2026 15:03:17 +0000 (18:03 +0300)
committerGitHub <noreply@github.com>
Mon, 27 Jul 2026 15:03:17 +0000 (18:03 +0300)
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.

Lib/csv.py
Lib/test/test_csv.py
Misc/NEWS.d/next/Library/2026-07-26-13-40-00.gh-issue-154744.Sk1Psp.rst [new file with mode: 0644]

index a84234f110dd70c742e5622537502ae95a161a3c..c66717dc1ee59e7dc734b0f027a8b5e8c868a38c 100644 (file)
@@ -563,22 +563,40 @@ class Sniffer:
     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
index ded53e936d19632083c2c2d5f080180f02f6e2b1..91170cc16b3ac95a782a20c071e6ee7b0a716f3d 100644 (file)
@@ -1688,6 +1688,60 @@ ghi\0jkl
         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 (file)
index 0000000..500826c
--- /dev/null
@@ -0,0 +1 @@
+Improve detection of ``skipinitialspace`` in :meth:`csv.Sniffer.sniff`.