From d10adbff22f839dd4db5eccb587ea76637829034 Mon Sep 17 00:00:00 2001 From: Emmanuel Kwaku Otoo <313792027+emmanuelkwakuotoo247-a11y@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:39:58 +1200 Subject: [PATCH] Detect CSV delimiter when one is not supplied --- src/tablib/formats/_csv.py | 43 +++++++++++++++++++++++- tests/test_tablib.py | 69 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 109 insertions(+), 3 deletions(-) diff --git a/src/tablib/formats/_csv.py b/src/tablib/formats/_csv.py index e57e1d9ed..f5634aa79 100644 --- a/src/tablib/formats/_csv.py +++ b/src/tablib/formats/_csv.py @@ -34,12 +34,53 @@ def export_set(cls, dataset, **kwargs): stream = cls.export_stream_set(dataset, **kwargs) return stream.getvalue() + #: Delimiters considered when detecting the dialect of a CSV stream. + CANDIDATE_DELIMITERS = (',', ';', ' ', '|', ':') + + @classmethod + def sniff_delimiter(cls, in_stream): + """Detect the delimiter used by a CSV stream. + + Only a small set of common delimiters is considered, and the + default comma is used whenever the dialect cannot be determined + or only one column is present. + """ + try: + position = in_stream.tell() + except (AttributeError, OSError): + position = None + + try: + sample = in_stream.read(2048) + except Exception: + return cls.DEFAULT_DELIMITER + + if position is not None: + in_stream.seek(position) + + for candidate in cls.CANDIDATE_DELIMITERS: + if candidate not in sample: + continue + try: + dialect = csv.Sniffer().sniff(sample, delimiters=candidate) + except Exception: + continue + if dialect.delimiter == candidate: + return candidate + + return cls.DEFAULT_DELIMITER + @classmethod - def import_set(cls, dset, in_stream, headers=True, skip_lines=0, **kwargs): + def import_set( + cls, dset, in_stream, headers=True, skip_lines=0, **kwargs + ): """Returns dataset from CSV stream.""" dset.wipe() + if 'delimiter' not in kwargs and not skip_lines: + kwargs['delimiter'] = cls.sniff_delimiter(in_stream) + kwargs.setdefault('delimiter', cls.DEFAULT_DELIMITER) rows = csv.reader(in_stream, **kwargs) diff --git a/tests/test_tablib.py b/tests/test_tablib.py index da2699ac8..6b61e26d7 100755 --- a/tests/test_tablib.py +++ b/tests/test_tablib.py @@ -1165,14 +1165,79 @@ def test_csv_formatter_support_kwargs(self): _csv = data.export('csv', **kwargs) self.assertEqual(expected, _csv) - # the import works but consider default delimiter=',' + # the delimiter is now detected, so no explicit delimiter is needed d1 = tablib.import_set(_csv, format="csv") - self.assertEqual(1, len(d1.headers)) + self.assertEqual(3, len(d1.headers)) d2 = tablib.import_set(_csv, format="csv", **kwargs) self.assertEqual(3, len(d2.headers)) + def test_csv_import_set_sniffs_colon_delimiter(self): + """Colon-separated CSV is detected without an explicit delimiter.""" + csv_text = ( + 'test:test2\n' + 'value1:value2\n' + ) + data.csv = csv_text + self.assertEqual(('test', 'test2'), tuple(data.headers)) + self.assertEqual(('value1', 'value2'), tuple(data[0])) + + def test_csv_import_set_sniffs_semicolon_delimiter(self): + """Semicolon-separated CSV is detected without an explicit + delimiter.""" + csv_text = ( + 'a;b;c\n' + '1;2;3\n' + '4;5;6\n' + ) + data.csv = csv_text + self.assertEqual(('a', 'b', 'c'), tuple(data.headers)) + self.assertEqual(('1', '2', '3'), tuple(data[0])) + self.assertEqual(('4', '5', '6'), tuple(data[1])) + + def test_csv_import_set_sniffs_pipe_delimiter(self): + """Pipe-separated CSV is detected without an explicit delimiter.""" + csv_text = ( + 'a|b|c\n' + '1|2|3\n' + ) + data.csv = csv_text + self.assertEqual(('a', 'b', 'c'), tuple(data.headers)) + self.assertEqual(('1', '2', '3'), tuple(data[0])) + + def test_csv_import_set_sniffs_tab_delimiter(self): + """Tab-separated CSV is detected without an explicit delimiter.""" + csv_text = ( + 'a\tb\tc\n' + '1\t2\t3\n' + ) + data.csv = csv_text + self.assertEqual(('a', 'b', 'c'), tuple(data.headers)) + self.assertEqual(('1', '2', '3'), tuple(data[0])) + + def test_csv_import_set_single_column_falls_back_to_comma(self): + """A single-column CSV has no delimiter to detect, so it falls back + to the default without raising.""" + csv_text = ( + 'name\n' + 'Alice\n' + 'Bob\n' + ) + data.csv = csv_text + self.assertEqual(('name',), tuple(data.headers)) + self.assertEqual(('Alice',), tuple(data[0])) + + def test_csv_import_set_explicit_delimiter_still_wins(self): + """An explicitly supplied delimiter takes precedence over + detection.""" + csv_text = ( + 'a;b;c\n' + '1;2;3\n' + ) + data.set_csv(csv_text, delimiter='|') + self.assertEqual(('a;b;c',), tuple(data.headers)) + class TSVTests(BaseTestCase): def test_tsv_import_set(self): """Generate and import TSV set serialization."""