Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion marimo/_plugins/ui/_impl/dataframes/dataframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,9 +380,9 @@ def _download_as(self, args: DownloadAsArgs) -> DownloadAsResponse:
json=JsonOptions(
ensure_ascii=self._download_json_ensure_ascii
),
locale=args.locale,
),
filename=bound_filename,
locale=args.locale,
)
except InvalidExportLocaleError as error:
return DownloadAsResponse(error=str(error))
Expand Down
3 changes: 2 additions & 1 deletion marimo/_plugins/ui/_impl/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
)
from marimo._plugins.ui._impl.tables.utils import get_table_manager
from marimo._plugins.ui._impl.utils.dataframe import (
DownloadOptions,
ListOrTuple,
TableData,
download_as,
Expand Down Expand Up @@ -1105,7 +1106,7 @@ def _download_as(self, args: DownloadAsArgs) -> DownloadAsResponse:
args.format,
drop_marimo_index=True,
filename=bound_filename,
locale=args.locale,
options=DownloadOptions(locale=args.locale),
)
except InvalidExportLocaleError as error:
return DownloadAsResponse(error=str(error))
Expand Down
79 changes: 72 additions & 7 deletions marimo/_plugins/ui/_impl/tables/pandas_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import functools
import io
import json
import math
from functools import cached_property
from typing import TYPE_CHECKING, Any

Expand All @@ -30,8 +31,11 @@
TableManager,
TableManagerFactory,
)
from marimo._utils.delimited import DelimitedDialect
from marimo._utils.narwhals_utils import dataframe_to_csv
from marimo._utils.delimited import (
DelimitedDialect,
format_delimited_number,
is_delimited_number,
)

if TYPE_CHECKING:
import pandas as pd
Expand Down Expand Up @@ -204,6 +208,59 @@ def package_name() -> str:
def create() -> type[TableManager[Any]]:
import pandas as pd

def prepare_delimited_data(
data: pd.DataFrame, decimal_separator: str
) -> pd.DataFrame:
"""Prepare values pandas does not localize itself."""
localized_data: pd.DataFrame | None = None
for position, dtype in enumerate(data.dtypes):
column = data.iloc[:, position]
if (
pd.api.types.is_float_dtype(dtype)
and not pd.api.types.is_extension_array_dtype(dtype)
and column.isna().any()
):
if localized_data is None:
localized_data = data.copy()
localized_data.isetitem(
position,
column.astype(object)
.where(column.notna(), "nan")
.array,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
)
continue

if (
decimal_separator == "."
or not pd.api.types.is_object_dtype(dtype)
):
continue

changed = False

def localize_number(value: object) -> object:
nonlocal changed
if not is_delimited_number(value):
return value
if isinstance(value, float) and not math.isfinite(value):
changed = True
return str(value)
formatted = format_delimited_number(
value, decimal_separator
)
if formatted == str(value):
return value
changed = True
return formatted

localized_column = column.map(localize_number)
if changed:
if localized_data is None:
localized_data = data.copy()
localized_data.isetitem(position, localized_column.array)

return localized_data if localized_data is not None else data

class PandasTableManager(NarwhalsTableManager[pd.DataFrame, Any]):
type = "pandas"

Expand Down Expand Up @@ -273,17 +330,25 @@ def to_csv_str(
sep=resolved_separator,
)

# Include a non-trivial pandas index, then reuse the Narwhals
# writer so decimal formatting lives in one place.
def to_delimited_str(
self,
dialect: DelimitedDialect,
format_mapping: FormatMapping | None = None,
) -> str:
manager = self.apply_formatting(format_mapping)
if len(self.get_row_headers()) > 0:
manager = manager.with_index_as_columns()
return dataframe_to_csv(manager.as_frame(), dialect=dialect)
# pandas applies `decimal` only to numeric dtypes. Prepare
# Decimal and mixed object columns before using its native
# writer for the full frame.
data = prepare_delimited_data(
manager._original_data, dialect.decimal_separator
)

return data.to_csv(
index=len(self.get_row_headers()) > 0,
sep=dialect.field_separator,
decimal=dialect.decimal_separator,
lineterminator="\n",
)

def to_json_str(
self,
Expand Down
21 changes: 21 additions & 0 deletions marimo/_plugins/ui/_impl/tables/polars_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from __future__ import annotations

import functools
import inspect
import io
import json
from functools import cached_property
Expand Down Expand Up @@ -40,6 +41,11 @@ def package_name() -> str:
def create() -> type[TableManager[Any]]:
import polars as pl

supports_decimal_comma = (
"decimal_comma"
in inspect.signature(pl.DataFrame.write_csv).parameters
)

def serialize_sequence_column(
column: pl.Series, dtype: pl.List | pl.Array
) -> pl.Series:
Expand Down Expand Up @@ -127,6 +133,21 @@ def to_delimited_str(
result = self._cast_object_to_string(result, column)
elif isinstance(dtype, pl.Duration):
result = self._convert_time_to_string(result, column)

has_nan = any(
column.dtype.is_float() and column.is_nan().any()
for column in result.get_columns()
)
if has_nan:
return dataframe_to_csv(result, dialect=dialect)

if dialect.decimal_separator == ".":
return result.write_csv(separator=dialect.field_separator)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if dialect.decimal_separator == "," and supports_decimal_comma:
return result.write_csv(
separator=dialect.field_separator,
decimal_comma=True,
)
return dataframe_to_csv(result, dialect=dialect)

def to_json_str(
Expand Down
8 changes: 1 addition & 7 deletions marimo/_plugins/ui/_impl/utils/dataframe.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Copyright 2026 Marimo. All rights reserved.
from __future__ import annotations

from dataclasses import dataclass, field, replace
from dataclasses import dataclass, field
Comment thread
Light2Dark marked this conversation as resolved.
from typing import TYPE_CHECKING, Any, Literal, TypeVar, Union

from narwhals.typing import IntoDataFrame, IntoLazyFrame
Expand Down Expand Up @@ -177,7 +177,6 @@ def download_as(
drop_marimo_index: bool = False,
options: DownloadOptions | None = None,
filename: str | None = None,
locale: ResolvedExportLocale | None = None,
) -> tuple[str, str]:
"""Download the table data in the specified format.

Expand All @@ -192,9 +191,6 @@ def download_as(
Defaults to each format's defaults.
filename (str | None, optional): The filename to use for the
downloaded file. Defaults to None, which uses a random filename.
locale (ResolvedExportLocale | None, optional): Browser-resolved
locale for CSV and TSV numeric decimals. Ignored for JSON and
Parquet. Defaults to None, which keeps locale-neutral output.

Returns:
tuple: (url, user-facing filename with extension) for the downloaded file.
Expand All @@ -203,8 +199,6 @@ def download_as(
ValueError: If unrecognized format.
"""
options = options or DownloadOptions()
if locale is not None:
options = replace(options, locale=locale)
if drop_marimo_index:
# Remove the selection column if exists
manager = manager.drop_columns([INDEX_COLUMN_NAME])
Expand Down
18 changes: 7 additions & 11 deletions marimo/_utils/narwhals_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@ def assert_can_narwhalify(obj: Any) -> TypeGuard[IntoFrame]:

def dataframe_to_csv(
df: IntoFrame,
separator: str | None = None,
dialect: DelimitedDialect | None = None,
) -> str:
"""
Expand All @@ -108,31 +107,28 @@ def dataframe_to_csv(
assert_can_narwhalify(df)
df = nw.from_native(df, pass_through=False)
df = upgrade_narwhals_df(df)
field_separator = (
dialect.field_separator if dialect is not None else separator or ","
)
decimal_separator = (
dialect.decimal_separator if dialect is not None else "."
)
dialect = dialect or DelimitedDialect(",", ".")

frame = df.collect() if is_narwhals_lazyframe(df) else df
if field_separator == "," and decimal_separator == ".":
if dialect == DelimitedDialect(",", "."):
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
return frame.write_csv()

# Narwhals inputs can map to different backends, and
# write_csv(separator=...) is not consistently reliable across them.
# For non-default dialects, use Python's csv writer for stable behavior.
buffer = io.StringIO()
writer = csv.writer(buffer, delimiter=field_separator, lineterminator="\n")
writer = csv.writer(
buffer, delimiter=dialect.field_separator, lineterminator="\n"
)
writer.writerow(frame.columns)
rows = frame.iter_rows()
if decimal_separator == ".":
if dialect.decimal_separator == ".":
writer.writerows(rows)
return buffer.getvalue()

writer.writerows(
tuple(
format_delimited_number(value, decimal_separator)
format_delimited_number(value, dialect.decimal_separator)
if is_delimited_number(value)
else value
for value in row
Expand Down
53 changes: 53 additions & 0 deletions tests/_plugins/ui/_impl/tables/test_pandas_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,22 @@ def test_to_delimited_str_pt_br(self) -> None:
== 'integer;fraction;text;null\n1234;1234,567890123456;"value.1,2;3";\n'
)

def test_to_delimited_str_uses_native_writer(self) -> None:
df = pd.DataFrame({"value": [1.5]})
manager = PandasTableManagerFactory.create()(df)

with patch.object(df, "to_csv", wraps=df.to_csv) as writer:
assert manager.to_delimited_str(DelimitedDialect(";", ",")) == (
"value\n1,5\n"
)

writer.assert_called_once_with(
index=False,
sep=";",
decimal=",",
lineterminator="\n",
)

def test_to_delimited_str_decimal_and_exponent(self) -> None:
manager = PandasTableManagerFactory.create()(
pd.DataFrame(
Expand All @@ -274,6 +290,22 @@ def test_to_delimited_str_decimal_and_exponent(self) -> None:
result = manager.to_delimited_str(DelimitedDialect(";", ","))
assert result == "decimal;exponent\n1234,567890123456;1,23e-10\n"

def test_to_delimited_str_localizes_mixed_object_numbers(self) -> None:
manager = PandasTableManagerFactory.create()(
pd.DataFrame(
{
"mixed": pd.Series(
[1.25, decimal.Decimal("2.5")], dtype=object
),
"text": ["1.25", "unchanged"],
}
)
)

result = manager.to_delimited_str(DelimitedDialect(";", ","))

assert result == "mixed;text\n1,25;1.25\n2,5;unchanged\n"

def test_to_delimited_str_non_finite(self) -> None:
manager = PandasTableManagerFactory.create()(
pd.DataFrame(
Expand Down Expand Up @@ -302,6 +334,27 @@ def test_to_csv_str_preserves_unnamed_index_header(self) -> None:

assert manager.to_csv_str() == df.to_csv(index=True)

def test_to_delimited_str_preserves_unnamed_index_header(self) -> None:
df = pd.DataFrame({"value": [1.5]}, index=[10])
manager = PandasTableManagerFactory.create()(df)

assert manager.to_delimited_str(DelimitedDialect(";", ",")) == (
";value\n10;1,5\n"
)

def test_to_delimited_str_preserves_unnamed_multi_index_headers(
self,
) -> None:
df = pd.DataFrame(
{"value": [1.5]},
index=pd.MultiIndex.from_tuples([("a", 1)]),
)
manager = PandasTableManagerFactory.create()(df)

assert manager.to_delimited_str(DelimitedDialect(";", ",")) == (
";;value\na;1;1,5\n"
)

def test_to_csv_datetime(self) -> None:
D = pd.to_datetime("2024-12-17", errors="coerce")

Expand Down
25 changes: 25 additions & 0 deletions tests/_plugins/ui/_impl/tables/test_polars_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from enum import Enum
from math import isnan
from typing import Any
from unittest import mock

import narwhals.stable.v2 as nw
import pytest
Expand Down Expand Up @@ -190,6 +191,30 @@ def test_to_delimited_str_pt_br(self) -> None:
== 'integer;fraction;text;null\n1234;1234,567890123456;"value.1,2;3";\n'
)

def test_to_delimited_str_uses_native_decimal_comma_writer(self) -> None:
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
import polars as pl

manager = self.factory.create()(pl.DataFrame({"value": [1.5]}))
with mock.patch(
"marimo._plugins.ui._impl.tables.polars_table.dataframe_to_csv"
) as fallback:
assert manager.to_delimited_str(DelimitedDialect(";", ",")) == (
"value\n1,5\n"
)
fallback.assert_not_called()

def test_to_delimited_str_uses_native_tsv_writer(self) -> None:
import polars as pl

manager = self.factory.create()(pl.DataFrame({"value": [1.5]}))
with mock.patch(
"marimo._plugins.ui._impl.tables.polars_table.dataframe_to_csv"
) as fallback:
assert manager.to_delimited_str(DelimitedDialect("\t", ".")) == (
"value\n1.5\n"
)
fallback.assert_not_called()

def test_to_delimited_str_normalizes_nested_values(self) -> None:
import polars as pl

Expand Down
Loading
Loading