Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions marimo/_plugins/ui/_impl/dataframes/dataframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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,
)

Expand Down
10 changes: 7 additions & 3 deletions marimo/_plugins/ui/_impl/tables/default_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion marimo/_plugins/ui/_impl/tables/narwhals_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 9 additions & 2 deletions marimo/_plugins/ui/_impl/tables/pandas_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions marimo/_plugins/ui/_impl/tables/polars_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion marimo/_plugins/ui/_impl/tables/table_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,16 +97,20 @@ 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

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")
Expand Down
7 changes: 6 additions & 1 deletion marimo/_plugins/ui/_impl/utils/dataframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand All @@ -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(
Expand Down
24 changes: 19 additions & 5 deletions marimo/_utils/narwhals_utils.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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(
Expand Down
16 changes: 16 additions & 0 deletions tests/_plugins/ui/_impl/dataframes/test_dataframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 12 additions & 0 deletions tests/_utils/test_narwhals_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading