https://github.com/python/cpython/commit/b8862aeac3d7cc4ed661c232130cd51c8f691a95
commit: b8862aeac3d7cc4ed661c232130cd51c8f691a95
branch: main
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-07-27T18:03:17+03:00
summary:

gh-154744: Improve detection of skipinitialspace in csv.Sniffer (GH-154745)

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.

files:
A Misc/NEWS.d/next/Library/2026-07-26-13-40-00.gh-issue-154744.Sk1Psp.rst
M Lib/csv.py
M Lib/test/test_csv.py

diff --git a/Lib/csv.py b/Lib/csv.py
index a84234f110dd70..c66717dc1ee59e 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 ded53e936d1963..91170cc16b3ac9 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 00000000000000..500826c195346f
--- /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`.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to