Skip to content

Commit 3157786

Browse files
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'.
1 parent 1ed6b78 commit 3157786

5 files changed

Lines changed: 65 additions & 2 deletions

File tree

Doc/library/csv.rst

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,10 +328,15 @@ The :mod:`!csv` module defines the following classes:
328328
are preferred, in that order,
329329
no matter how many times each of them occurs.
330330

331+
The *lineterminator* parameter is deduced separately,
332+
by a majority vote among the line endings of the sample.
333+
A tie is broken in the order ``'\r\n'``, ``'\n'``, ``'\r'``,
334+
so a sample without a complete line gives ``'\r\n'``.
335+
331336
.. versionchanged:: next
332337
The dialect is now deduced by trial parsing
333338
and the results may differ from those of earlier Python versions.
334-
The *escapechar* parameter can now be detected,
339+
The *escapechar* and *lineterminator* parameters can now be detected,
335340
and the requested *delimiters* are not restricted to ASCII.
336341

337342

Doc/whatsnew/3.16.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,11 @@ csv
135135
The results may differ from those of earlier Python versions.
136136
(Contributed by Serhiy Storchaka in :gh:`83273`.)
137137

138+
* :meth:`csv.Sniffer.sniff` now detects the *lineterminator* parameter
139+
by a majority vote among the line endings of the sample,
140+
instead of always returning ``'\r\n'``.
141+
(Contributed by Serhiy Storchaka in :gh:`75008`.)
142+
138143
curses
139144
------
140145

Lib/csv.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,9 +389,9 @@ def sniff(self, sample, delimiters=None):
389389

390390
class dialect(Dialect):
391391
_name = "sniffed"
392-
lineterminator = '\r\n'
393392
quoting = QUOTE_MINIMAL
394393

394+
dialect.lineterminator = self._detect_lineterminator(lines)
395395
dialect.delimiter = delimiter
396396
# _csv.reader won't accept a quotechar of ''
397397
dialect.quotechar = quotechar or '"'
@@ -614,6 +614,24 @@ def _detect_skipinitialspace(self, lines, delimiter, quotechar,
614614
for kept_row, skipped_row in zip(*results)]
615615
return all(first) or not any(first)
616616

617+
def _detect_lineterminator(self, lines):
618+
"""
619+
Detect the line terminator by majority vote among the line
620+
endings. A line break inside a quoted field is counted too,
621+
but it takes more of them than of the real ones to win the
622+
vote. A tie is broken in the order '\\r\\n', '\\n', '\\r',
623+
so a sample without a complete line gives '\\r\\n'.
624+
"""
625+
counts = dict.fromkeys(('\r\n', '\n', '\r'), 0)
626+
for line in lines:
627+
for lineterminator in counts:
628+
if line.endswith(lineterminator):
629+
counts[lineterminator] += 1
630+
break
631+
# max() returns the first of equal candidates, and dict
632+
# preserves the insertion order.
633+
return max(counts, key=counts.get)
634+
617635
def has_header(self, sample):
618636
# Creates a dictionary of types of data in each column. If any
619637
# column is of a single type (say, integers), *except* for the first

Lib/test/test_csv.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1699,6 +1699,39 @@ def test_sniff_crlf_lineterminator(self):
16991699
dialect = sniffer.sniff(sample)
17001700
self.assertEqual(dialect.delimiter, ',')
17011701
self.assertEqual(dialect.quotechar, '"')
1702+
self.assertEqual(dialect.lineterminator, '\r\n')
1703+
1704+
def test_sniff_lineterminator(self):
1705+
sniffer = csv.Sniffer()
1706+
for lineterminator in '\r\n', '\n', '\r':
1707+
with self.subTest(lineterminator=lineterminator):
1708+
sample = lineterminator.join(['a,b,c', 'd,e,f', 'g,h,i', ''])
1709+
dialect = sniffer.sniff(sample)
1710+
self.assertEqual(dialect.lineterminator, lineterminator)
1711+
self.assertEqual(dialect.delimiter, ',')
1712+
# The majority wins.
1713+
sample = 'a,b,c\nd,e,f\r\ng,h,i\n'
1714+
self.assertEqual(sniffer.sniff(sample).lineterminator, '\n')
1715+
sample = 'a,b,c\r\nd,e,f\ng,h,i\r\n'
1716+
self.assertEqual(sniffer.sniff(sample).lineterminator, '\r\n')
1717+
# A line break inside a quoted field is counted too, but it is
1718+
# outvoted by the real ones.
1719+
sample = 'a,"x\ny",c\r\nd,e,f\r\ng,h,i\r\n'
1720+
self.assertEqual(sniffer.sniff(sample).lineterminator, '\r\n')
1721+
1722+
def test_sniff_lineterminator_tie(self):
1723+
# A tie is broken in the order '\r\n', '\n', '\r'.
1724+
sniffer = csv.Sniffer()
1725+
for sample, lineterminator in (
1726+
('a,b,c\nd,e,f\r\n', '\r\n'),
1727+
('a,b,c\r\nd,e,f\ng,h,i\rj,k,l', '\r\n'),
1728+
('a,b,c\nd,e,f\rg,h,i', '\n'),
1729+
# A sample without a complete line is a tie of zeros.
1730+
('a,b,c', '\r\n'),
1731+
):
1732+
with self.subTest(sample=sample):
1733+
self.assertEqual(sniffer.sniff(sample).lineterminator,
1734+
lineterminator)
17021735

17031736
def test_sniff_excel_tab_with_quotes(self):
17041737
# gh-62029: tab-delimited data with a quoted field containing
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
:meth:`csv.Sniffer.sniff` now detects the *lineterminator* parameter by a
2+
majority vote among the line endings of the sample.

0 commit comments

Comments
 (0)