https://github.com/python/cpython/commit/31577863cb902717b735e5876020732ba52520b4
commit: 31577863cb902717b735e5876020732ba52520b4
branch: main
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-08-13T10:21:03Z
summary:

gh-75008: Detect the lineterminator in csv.Sniffer.sniff() (GH-155061)

It is guessed by a majority vote among the line endings of the sample,
instead of always being '\r\n'.

files:
A Misc/NEWS.d/next/Library/2026-08-01-23-50-47.gh-issue-75008.0Boa3r.rst
M Doc/library/csv.rst
M Doc/whatsnew/3.16.rst
M Lib/csv.py
M Lib/test/test_csv.py

diff --git a/Doc/library/csv.rst b/Doc/library/csv.rst
index 631b82ab9a335a8..53288e810bffcf6 100644
--- a/Doc/library/csv.rst
+++ b/Doc/library/csv.rst
@@ -328,10 +328,15 @@ The :mod:`!csv` module defines the following classes:
       are preferred, in that order,
       no matter how many times each of them occurs.
 
+      The *lineterminator* parameter is deduced separately,
+      by a majority vote among the line endings of the sample.
+      A tie is broken in the order ``'\r\n'``, ``'\n'``, ``'\r'``,
+      so a sample without a complete line gives ``'\r\n'``.
+
       .. versionchanged:: next
          The dialect is now deduced by trial parsing
          and the results may differ from those of earlier Python versions.
-         The *escapechar* parameter can now be detected,
+         The *escapechar* and *lineterminator* parameters can now be detected,
          and the requested *delimiters* are not restricted to ASCII.
 
 
diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst
index b017535b96979d9..fc09afc96b16763 100644
--- a/Doc/whatsnew/3.16.rst
+++ b/Doc/whatsnew/3.16.rst
@@ -135,6 +135,11 @@ csv
   The results may differ from those of earlier Python versions.
   (Contributed by Serhiy Storchaka in :gh:`83273`.)
 
+* :meth:`csv.Sniffer.sniff` now detects the *lineterminator* parameter
+  by a majority vote among the line endings of the sample,
+  instead of always returning ``'\r\n'``.
+  (Contributed by Serhiy Storchaka in :gh:`75008`.)
+
 curses
 ------
 
diff --git a/Lib/csv.py b/Lib/csv.py
index 0c5c40c759240ca..b406d82efffd9f3 100644
--- a/Lib/csv.py
+++ b/Lib/csv.py
@@ -389,9 +389,9 @@ def sniff(self, sample, delimiters=None):
 
         class dialect(Dialect):
             _name = "sniffed"
-            lineterminator = '\r\n'
             quoting = QUOTE_MINIMAL
 
+        dialect.lineterminator = self._detect_lineterminator(lines)
         dialect.delimiter = delimiter
         # _csv.reader won't accept a quotechar of ''
         dialect.quotechar = quotechar or '"'
@@ -614,6 +614,24 @@ def _detect_skipinitialspace(self, lines, delimiter, 
quotechar,
                  for kept_row, skipped_row in zip(*results)]
         return all(first) or not any(first)
 
+    def _detect_lineterminator(self, lines):
+        """
+        Detect the line terminator by majority vote among the line
+        endings.  A line break inside a quoted field is counted too,
+        but it takes more of them than of the real ones to win the
+        vote.  A tie is broken in the order '\\r\\n', '\\n', '\\r',
+        so a sample without a complete line gives '\\r\\n'.
+        """
+        counts = dict.fromkeys(('\r\n', '\n', '\r'), 0)
+        for line in lines:
+            for lineterminator in counts:
+                if line.endswith(lineterminator):
+                    counts[lineterminator] += 1
+                    break
+        # max() returns the first of equal candidates, and dict
+        # preserves the insertion order.
+        return max(counts, key=counts.get)
+
     def has_header(self, sample):
         # Creates a dictionary of types of data in each column. If any
         # column is of a single type (say, integers), *except* for the first
diff --git a/Lib/test/test_csv.py b/Lib/test/test_csv.py
index 7a679d627ff8c08..73e282d1abf7177 100644
--- a/Lib/test/test_csv.py
+++ b/Lib/test/test_csv.py
@@ -1699,6 +1699,39 @@ def test_sniff_crlf_lineterminator(self):
         dialect = sniffer.sniff(sample)
         self.assertEqual(dialect.delimiter, ',')
         self.assertEqual(dialect.quotechar, '"')
+        self.assertEqual(dialect.lineterminator, '\r\n')
+
+    def test_sniff_lineterminator(self):
+        sniffer = csv.Sniffer()
+        for lineterminator in '\r\n', '\n', '\r':
+            with self.subTest(lineterminator=lineterminator):
+                sample = lineterminator.join(['a,b,c', 'd,e,f', 'g,h,i', ''])
+                dialect = sniffer.sniff(sample)
+                self.assertEqual(dialect.lineterminator, lineterminator)
+                self.assertEqual(dialect.delimiter, ',')
+        # The majority wins.
+        sample = 'a,b,c\nd,e,f\r\ng,h,i\n'
+        self.assertEqual(sniffer.sniff(sample).lineterminator, '\n')
+        sample = 'a,b,c\r\nd,e,f\ng,h,i\r\n'
+        self.assertEqual(sniffer.sniff(sample).lineterminator, '\r\n')
+        # A line break inside a quoted field is counted too, but it is
+        # outvoted by the real ones.
+        sample = 'a,"x\ny",c\r\nd,e,f\r\ng,h,i\r\n'
+        self.assertEqual(sniffer.sniff(sample).lineterminator, '\r\n')
+
+    def test_sniff_lineterminator_tie(self):
+        # A tie is broken in the order '\r\n', '\n', '\r'.
+        sniffer = csv.Sniffer()
+        for sample, lineterminator in (
+            ('a,b,c\nd,e,f\r\n', '\r\n'),
+            ('a,b,c\r\nd,e,f\ng,h,i\rj,k,l', '\r\n'),
+            ('a,b,c\nd,e,f\rg,h,i', '\n'),
+            # A sample without a complete line is a tie of zeros.
+            ('a,b,c', '\r\n'),
+        ):
+            with self.subTest(sample=sample):
+                self.assertEqual(sniffer.sniff(sample).lineterminator,
+                                 lineterminator)
 
     def test_sniff_excel_tab_with_quotes(self):
         # gh-62029: tab-delimited data with a quoted field containing
diff --git 
a/Misc/NEWS.d/next/Library/2026-08-01-23-50-47.gh-issue-75008.0Boa3r.rst 
b/Misc/NEWS.d/next/Library/2026-08-01-23-50-47.gh-issue-75008.0Boa3r.rst
new file mode 100644
index 000000000000000..19545f041093bd4
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-08-01-23-50-47.gh-issue-75008.0Boa3r.rst
@@ -0,0 +1,2 @@
+:meth:`csv.Sniffer.sniff` now detects the *lineterminator* parameter by a
+majority vote among the line endings of the sample.

_______________________________________________
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