diff --git a/marimo/_plugins/ui/_impl/dataframes/dataframe.py b/marimo/_plugins/ui/_impl/dataframes/dataframe.py index cad414709d8..a4d50fe8fd4 100644 --- a/marimo/_plugins/ui/_impl/dataframes/dataframe.py +++ b/marimo/_plugins/ui/_impl/dataframes/dataframe.py @@ -120,6 +120,8 @@ class dataframe(UIElement[dict[str, Any], DataFrameType]): download_csv_encoding (str | None, optional): Encoding used when downloading CSV. Defaults to the runtime config value (or "utf-8" if not configured). Set to "utf-8-sig" to include BOM for Excel. + download_csv_separator (str | None, optional): Separator used in CSV downloads. + Defaults to ",". download_json_ensure_ascii (bool, optional): Whether to escape non-ASCII characters in JSON downloads. Defaults to True. on_change (Optional[Callable[[DataFrameType], None]], optional): Optional callback @@ -140,6 +142,7 @@ def __init__( *, format_mapping: Optional[FormatMapping] = None, download_csv_encoding: str | None = None, + download_csv_separator: str | None = None, download_json_ensure_ascii: bool = True, lazy: Optional[bool] = None, ) -> None: @@ -156,6 +159,7 @@ def __init__( self._dataframe_name = dataframe_name self._data = df self._download_csv_encoding = download_csv_encoding + self._download_csv_separator = download_csv_separator self._download_json_ensure_ascii = download_json_ensure_ascii self._handler = handler self._manager = self._get_cached_table_manager(df, self._limit) @@ -190,6 +194,7 @@ def __init__( "page-size": page_size, "show-download": show_download, "download-csv-encoding": download_csv_encoding, + "download-csv-separator": download_csv_separator, "download-json-ensure-ascii": download_json_ensure_ascii, "lazy": self._lazy, }, @@ -349,6 +354,7 @@ def _download_as(self, args: DownloadAsArgs) -> str: manager, args.format, csv_encoding=self._download_csv_encoding, + csv_separator=self._download_csv_separator, json_ensure_ascii=self._download_json_ensure_ascii, ) diff --git a/marimo/_plugins/ui/_impl/tables/default_table.py b/marimo/_plugins/ui/_impl/tables/default_table.py index 0131ef09f6c..4035e6b52fd 100644 --- a/marimo/_plugins/ui/_impl/tables/default_table.py +++ b/marimo/_plugins/ui/_impl/tables/default_table.py @@ -91,14 +91,18 @@ def supports_filters(self) -> bool: return False def to_csv_str( - self, format_mapping: Optional[FormatMapping] = None + self, + format_mapping: Optional[FormatMapping] = None, + separator: str | None = None, ) -> str: if isinstance(self.data, dict) and not self.is_column_oriented: return DefaultTableManager( self._normalize_data(self.data) - ).to_csv_str(format_mapping) + ).to_csv_str(format_mapping, separator=separator) - return self._as_table_manager().to_csv_str(format_mapping) + return self._as_table_manager().to_csv_str( + format_mapping, separator=separator + ) def to_json_str( self, diff --git a/marimo/_plugins/ui/_impl/tables/narwhals_table.py b/marimo/_plugins/ui/_impl/tables/narwhals_table.py index d2c7ff8952e..70bbe30f385 100644 --- a/marimo/_plugins/ui/_impl/tables/narwhals_table.py +++ b/marimo/_plugins/ui/_impl/tables/narwhals_table.py @@ -88,9 +88,10 @@ def with_new_data( def to_csv_str( self, format_mapping: Optional[FormatMapping] = None, + separator: str | None = None, ) -> str: _data = self.apply_formatting(format_mapping).as_frame() - return dataframe_to_csv(_data) + return dataframe_to_csv(_data, separator=separator) def to_json_str( self, diff --git a/marimo/_plugins/ui/_impl/tables/pandas_table.py b/marimo/_plugins/ui/_impl/tables/pandas_table.py index 0513b0ad2f5..07a4fda8279 100644 --- a/marimo/_plugins/ui/_impl/tables/pandas_table.py +++ b/marimo/_plugins/ui/_impl/tables/pandas_table.py @@ -109,12 +109,19 @@ def schema(self) -> pd.Series[Any]: # We override narwhals's to_csv_str to handle pandas # headers def to_csv_str( - self, format_mapping: Optional[FormatMapping] = None + self, + format_mapping: Optional[FormatMapping] = None, + separator: str | None = None, ) -> str: has_headers = len(self.get_row_headers()) > 0 + resolved_separator = ( + separator if separator is not None else "," + ) return self.apply_formatting( format_mapping - )._original_data.to_csv(index=has_headers) + )._original_data.to_csv( + index=has_headers, sep=resolved_separator + ) def to_json_str( self, diff --git a/marimo/_plugins/ui/_impl/tables/polars_table.py b/marimo/_plugins/ui/_impl/tables/polars_table.py index 6249a106221..56eeb579380 100644 --- a/marimo/_plugins/ui/_impl/tables/polars_table.py +++ b/marimo/_plugins/ui/_impl/tables/polars_table.py @@ -73,10 +73,14 @@ def to_arrow_ipc(self) -> bytes: def to_csv_str( self, format_mapping: Optional[FormatMapping] = None, + separator: str | None = None, ) -> str: + resolved_separator = ( + separator if separator is not None else "," + ) _data = self.apply_formatting(format_mapping).collect() try: - return _data.write_csv() + return _data.write_csv(separator=resolved_separator) except pl.exceptions.ComputeError: # Likely CSV format does not support nested data or objects # Try to convert columns to json or strings @@ -105,7 +109,7 @@ def to_csv_str( result = self._convert_time_to_string( result, column ) - return result.write_csv() + return result.write_csv(separator=resolved_separator) def to_json_str( self, diff --git a/marimo/_plugins/ui/_impl/tables/table_manager.py b/marimo/_plugins/ui/_impl/tables/table_manager.py index 805fbbc1cb1..a5c7af9a138 100644 --- a/marimo/_plugins/ui/_impl/tables/table_manager.py +++ b/marimo/_plugins/ui/_impl/tables/table_manager.py @@ -97,6 +97,7 @@ def sort_values(self, by: list[SortArgs]) -> TableManager[Any]: def to_csv_str( self, format_mapping: Optional[FormatMapping] = None, + separator: str | None = None, ) -> str: pass @@ -104,9 +105,12 @@ def to_csv( self, format_mapping: Optional[FormatMapping] = None, encoding: str | None = "utf-8", + separator: str | None = None, ) -> bytes: resolved_encoding = encoding or "utf-8" - return self.to_csv_str(format_mapping).encode(resolved_encoding) + return self.to_csv_str(format_mapping, separator=separator).encode( + resolved_encoding + ) def to_arrow_ipc(self) -> bytes: raise NotImplementedError("Arrow format not supported") diff --git a/marimo/_plugins/ui/_impl/utils/dataframe.py b/marimo/_plugins/ui/_impl/utils/dataframe.py index 0ebbbf65591..0d0fbad970e 100644 --- a/marimo/_plugins/ui/_impl/utils/dataframe.py +++ b/marimo/_plugins/ui/_impl/utils/dataframe.py @@ -56,6 +56,7 @@ def download_as( ext: str, drop_marimo_index: bool = False, csv_encoding: str | None = None, + csv_separator: str | None = None, json_ensure_ascii: bool = True, ) -> str: """Download the table data in the specified format. @@ -68,6 +69,8 @@ def download_as( csv_encoding (str | None, optional): Encoding used when generating CSV bytes. Defaults to the runtime config value (or "utf-8" if not configured). Ignored for non-CSV formats. + csv_separator (str | None, optional): Separator used in CSV downloads. + Defaults to "," when not configured. json_ensure_ascii (bool, optional): Whether to escape non-ASCII characters in JSON output. Defaults to True. @@ -88,7 +91,9 @@ def download_as( if csv_encoding is not None else get_default_csv_encoding() ) - return mo_data.csv(manager.to_csv(encoding=encoding)).url + return mo_data.csv( + manager.to_csv(encoding=encoding, separator=csv_separator) + ).url elif ext == "json": # Use strict JSON to ensure compliance with JSON spec return mo_data.json( diff --git a/marimo/_utils/narwhals_utils.py b/marimo/_utils/narwhals_utils.py index e5632c9433c..1085b8f7d21 100644 --- a/marimo/_utils/narwhals_utils.py +++ b/marimo/_utils/narwhals_utils.py @@ -1,6 +1,8 @@ # Copyright 2026 Marimo. All rights reserved. from __future__ import annotations +import csv +import io import sys from typing import TYPE_CHECKING, Any, Callable, Union, overload @@ -94,17 +96,29 @@ def assert_can_narwhalify(obj: Any) -> TypeGuard[IntoFrame]: return True -def dataframe_to_csv(df: IntoFrame) -> str: +def dataframe_to_csv(df: IntoFrame, separator: str | None = None) -> str: """ Convert a dataframe to a CSV string. """ assert_can_narwhalify(df) df = nw.from_native(df, pass_through=False) df = upgrade_narwhals_df(df) - if is_narwhals_lazyframe(df): - return df.collect().write_csv() - else: - return df.write_csv() + resolved_separator = separator if separator is not None else "," + + frame = df.collect() if is_narwhals_lazyframe(df) else df + if resolved_separator == ",": + return frame.write_csv() + + # Narwhals inputs can map to different backends, and + # write_csv(separator=...) is not consistently reliable across them. + # For non-comma separators, use Python's csv writer for stable behavior. + buffer = io.StringIO() + writer = csv.writer( + buffer, delimiter=resolved_separator, lineterminator="\n" + ) + writer.writerow(frame.columns) + writer.writerows(frame.iter_rows()) + return buffer.getvalue() def is_narwhals_integer_type( diff --git a/tests/_plugins/ui/_impl/dataframes/test_dataframe.py b/tests/_plugins/ui/_impl/dataframes/test_dataframe.py index 8e5f8bbb7b4..e5ccd65a27a 100644 --- a/tests/_plugins/ui/_impl/dataframes/test_dataframe.py +++ b/tests/_plugins/ui/_impl/dataframes/test_dataframe.py @@ -356,6 +356,22 @@ def test_dataframe_download_encoding_utf8_sig() -> None: assert csv_bytes.startswith(b"\xef\xbb\xbf") assert "こんにちは" in csv_bytes.decode("utf-8-sig") + @staticmethod + @pytest.mark.skipif( + not HAS_DEPS, reason="optional dependencies not installed" + ) + def test_dataframe_download_csv_separator() -> None: + df = pd.DataFrame({"A": [1, 2], "B": ["x", "y"]}) + subject = ui.dataframe( + df, + download_csv_separator=";", + ) + + csv_url = subject._download_as(DownloadAsArgs(format="csv")) + csv_text = from_data_uri(csv_url)[1].decode("utf-8") + assert "A;B" in csv_text + assert "1;x" in csv_text + @staticmethod @pytest.mark.skipif( not HAS_DEPS, reason="optional dependencies not installed" diff --git a/tests/_utils/test_narwhals_utils.py b/tests/_utils/test_narwhals_utils.py index 9ad842b3ae1..a4c728abab4 100644 --- a/tests/_utils/test_narwhals_utils.py +++ b/tests/_utils/test_narwhals_utils.py @@ -112,6 +112,18 @@ def test_dataframe_to_csv(df: IntoDataFrame) -> None: assert '2,"y"' in csv or "2,y" in csv +@pytest.mark.parametrize( + "df", create_dataframes({"a": [1, 2], "b": ["x", "y"]}) +) +@pytest.mark.skipif(not HAS_DEPS, reason="optional dependencies not installed") +def test_dataframe_to_csv_with_separator(df: IntoDataFrame) -> None: + df_wrapped = nw.from_native(df) + csv = dataframe_to_csv(df_wrapped, separator=";") + assert "a;b" in csv + assert "1;x" in csv + assert "2;y" in csv + + @pytest.mark.skipif(not HAS_DEPS, reason="optional dependencies not installed") def test_narwhals_type_checks(): assert is_narwhals_integer_type(nw.Int64)