Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 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
20 changes: 20 additions & 0 deletions frontend/plugins.openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3140,6 +3140,16 @@ components:
- json
- parquet
- tsv
locale:
type: object
properties:
tag:
type: string
decimal_separator:
type: string
required:
- tag
- decimal_separator
required:
- format
marimo-dataframe.download_as.output:
Expand Down Expand Up @@ -4577,6 +4587,16 @@ components:
- json
- parquet
- tsv
locale:
type: object
properties:
tag:
type: string
decimal_separator:
type: string
required:
- tag
- decimal_separator
required:
- format
marimo-table.download_as.output:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/* Copyright 2026 Marimo. All rights reserved. */

import { describe, expect, it } from "vitest";
import { buildDownloadAsRequest, sourceFormatForCopy } from "../export-actions";

const PT_BR_LOCALE = { tag: "pt-BR", decimal_separator: "," };

describe("buildDownloadAsRequest", () => {
it("includes locale for CSV and TSV", () => {
expect(buildDownloadAsRequest("csv", "pt-BR")).toEqual({
format: "csv",
locale: PT_BR_LOCALE,
});
expect(buildDownloadAsRequest("tsv", "pt-BR")).toEqual({
format: "tsv",
locale: PT_BR_LOCALE,
});
});

it("omits locale for JSON and Parquet", () => {
expect(buildDownloadAsRequest("json", "pt-BR")).toEqual({ format: "json" });
expect(buildDownloadAsRequest("parquet", "pt-BR")).toEqual({
format: "parquet",
});
});
});

describe("file and clipboard request parity", () => {
it("sends the same locale for CSV download and clipboard copy", () => {
const download = buildDownloadAsRequest("csv", "pt-BR");
const clipboard = buildDownloadAsRequest(
sourceFormatForCopy("csv"),
"pt-BR",
);
expect(download).toEqual({ format: "csv", locale: PT_BR_LOCALE });
expect(clipboard).toEqual(download);
});

it("sends the same locale for TSV download and clipboard copy", () => {
const download = buildDownloadAsRequest("tsv", "pt-BR");
const clipboard = buildDownloadAsRequest(
sourceFormatForCopy("tsv"),
"pt-BR",
);
expect(download).toEqual({ format: "tsv", locale: PT_BR_LOCALE });
expect(clipboard).toEqual(download);
});

it("omits locale for JSON, Parquet, and Markdown source requests", () => {
expect(buildDownloadAsRequest("json", "pt-BR")).toEqual({ format: "json" });
expect(buildDownloadAsRequest("parquet", "pt-BR")).toEqual({
format: "parquet",
});
expect(
buildDownloadAsRequest(sourceFormatForCopy("json"), "pt-BR"),
).toEqual({ format: "json" });
expect(
buildDownloadAsRequest(sourceFormatForCopy("markdown"), "pt-BR"),
).toEqual({ format: "json" });
});
});
46 changes: 46 additions & 0 deletions frontend/src/components/data-table/__tests__/export-locale.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/* Copyright 2026 Marimo. All rights reserved. */

import { describe, expect, it } from "vitest";
import { resolveExportLocale } from "../export-locale";
import { DownloadAsSchema } from "../schemas";

describe("resolveExportLocale", () => {
it("resolves a decimal point for en-US", () => {
expect(resolveExportLocale("en-US")).toEqual({
tag: "en-US",
decimal_separator: ".",
});
});

it("resolves a decimal comma for pt-BR", () => {
expect(resolveExportLocale("pt-BR").decimal_separator).toBe(",");
});

it("preserves a non-comma Unicode decimal separator", () => {
expect(resolveExportLocale("ar-EG").decimal_separator).toBe("٫");
});

it("rejects an invalid locale", () => {
expect(() => resolveExportLocale("not_a_locale")).toThrow();
});

it("rejects an empty locale tag", () => {
expect(() => resolveExportLocale("")).toThrow();
});
});

describe("DownloadAsSchema", () => {
it("accepts a request with only format", () => {
expect(DownloadAsSchema.input.parse({ format: "csv" })).toEqual({
format: "csv",
});
});

it("accepts an optional locale value", () => {
const locale = { tag: "pt-BR", decimal_separator: "," };
expect(DownloadAsSchema.input.parse({ format: "csv", locale })).toEqual({
format: "csv",
locale,
});
});
});
30 changes: 22 additions & 8 deletions frontend/src/components/data-table/export-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
TableIcon,
} from "lucide-react";
import React from "react";
import { useLocale } from "react-aria";
import { downloadSizeLimitAtom } from "./download-policy/atoms";
import { logNever } from "@/utils/assertNever";
import { cn } from "@/utils/cn";
Expand All @@ -32,6 +33,8 @@ import {
} from "../ui/dropdown-menu";
import { Tooltip } from "../ui/tooltip";
import { toast } from "../ui/use-toast";
import { resolveExportLocale } from "./export-locale";
import type { DownloadAsArgs } from "./schemas";

const FILE_TYPES = {
CSV: {
Expand Down Expand Up @@ -91,13 +94,22 @@ const COPY_SOURCE_FORMAT: Record<CopyFormat, DownloadFormat> = {
markdown: "json",
};

export function sourceFormatForCopy(format: CopyFormat): DownloadFormat {
return COPY_SOURCE_FORMAT[format];
}

export function buildDownloadAsRequest(
format: DownloadFormat,
localeTag: string,
): Parameters<DownloadAsArgs>[0] {
if (format === "csv" || format === "tsv") {
return { format, locale: resolveExportLocale(localeTag) };
}
return { format };
}

export interface ExportActionProps {
downloadAs: (req: { format: DownloadFormat }) => Promise<{
url: string;
filename: string;
error?: string | null;
missing_packages?: string[] | null;
}>;
downloadAs: DownloadAsArgs;
// JSON-serialized size of the currently-rendered data. Used together with
// downloadSizeLimitAtom to disable the Export button when a host (e.g.,
// marimo-lsp inside VS Code) declares a download size cap. Null/undefined
Expand All @@ -113,6 +125,7 @@ const labelForCopyFormat = (format: CopyFormat): string =>

export const ExportMenu: React.FC<ExportActionProps> = (props) => {
const [downloadMenuOpen, setDownloadMenuOpen] = React.useState(false);
const { locale } = useLocale();
const policy = useAtomValue(downloadSizeLimitAtom);
const overLimit = !!(
policy &&
Expand Down Expand Up @@ -151,7 +164,8 @@ export const ExportMenu: React.FC<ExportActionProps> = (props) => {
} | null> => {
let response: Awaited<ReturnType<typeof props.downloadAs>>;
try {
response = await props.downloadAs({ format });
const request = buildDownloadAsRequest(format, locale);
response = await props.downloadAs(request);
} catch (error) {
toast({
title: "Failed to download",
Expand Down Expand Up @@ -224,7 +238,7 @@ export const ExportMenu: React.FC<ExportActionProps> = (props) => {
await withLoadingToast(
`Preparing ${labelForCopyFormat(format)} for clipboard...`,
async () => {
const sourceFormat = COPY_SOURCE_FORMAT[format];
const sourceFormat = sourceFormatForCopy(format);
const result = await resolveDownloadUrl(sourceFormat, () => {
void handleClipboardCopy(format);
});
Expand Down
33 changes: 33 additions & 0 deletions frontend/src/components/data-table/export-locale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/* Copyright 2026 Marimo. All rights reserved. */

export interface ResolvedExportLocale {
tag: string;
decimal_separator: string;
}

const FORBIDDEN_DECIMAL_SEPARATORS = new Set(["\r", "\n", "\0"]);

export function resolveExportLocale(locale: string): ResolvedExportLocale {
if (locale === "") {
throw new Error("Locale tag must be a non-empty string.");
}

const decimalSeparator = new Intl.NumberFormat(locale)
.formatToParts(1.1)
.find((part) => part.type === "decimal")?.value;

if (
decimalSeparator === undefined ||
[...decimalSeparator].length !== 1 ||
FORBIDDEN_DECIMAL_SEPARATORS.has(decimalSeparator)
) {
throw new Error(
`Locale "${locale}" did not resolve a valid decimal separator.`,
);
}

return {
tag: locale,
decimal_separator: decimalSeparator,
};
}
8 changes: 8 additions & 0 deletions frontend/src/components/data-table/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

import z from "zod";
import { rpc } from "@/plugins/core/rpc";
import type { ResolvedExportLocale } from "./export-locale";

export type DownloadAsArgs = (req: {
format: "csv" | "json" | "parquet" | "tsv";
locale?: ResolvedExportLocale;
}) => Promise<{
url: string;
filename: string;
Expand All @@ -16,6 +18,12 @@ export const DownloadAsSchema = rpc
.input(
z.object({
format: z.enum(["csv", "json", "parquet", "tsv"]),
locale: z
.object({
tag: z.string(),
decimal_separator: z.string(),
})
.optional(),
}),
)
.output(
Expand Down
31 changes: 18 additions & 13 deletions marimo/_plugins/ui/_impl/dataframes/dataframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
SortArgs,
TableSearchError,
)
from marimo._plugins.ui._impl.tables.delimited import InvalidExportLocaleError
from marimo._plugins.ui._impl.tables.format import FormatMapping
from marimo._plugins.ui._impl.tables.table_manager import (
FieldTypes,
Expand Down Expand Up @@ -367,20 +368,24 @@ def _download_as(self, args: DownloadAsArgs) -> DownloadAsResponse:

bound_filename = get_bound_name(self._id)

url, filename = download_as(
manager,
args.format,
options=DownloadOptions(
delimited=DelimitedOptions(
encoding=self._download_csv_encoding,
separator=self._download_csv_separator,
),
json=JsonOptions(
ensure_ascii=self._download_json_ensure_ascii
try:
url, filename = download_as(
manager,
args.format,
options=DownloadOptions(
delimited=DelimitedOptions(
encoding=self._download_csv_encoding,
separator=self._download_csv_separator,
),
json=JsonOptions(
ensure_ascii=self._download_json_ensure_ascii
),
),
),
filename=bound_filename,
)
filename=bound_filename,
locale=args.locale,
)
except InvalidExportLocaleError as error:
return DownloadAsResponse(error=str(error))
Comment thread
kirangadhave marked this conversation as resolved.
return DownloadAsResponse(url=url, filename=filename)

def _apply_filters_query_sort(
Expand Down
21 changes: 15 additions & 6 deletions marimo/_plugins/ui/_impl/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@
TransformType,
validate_operator_for_dtype,
)
from marimo._plugins.ui._impl.tables.delimited import (
InvalidExportLocaleError,
ResolvedExportLocale,
)
from marimo._plugins.ui._impl.tables.selection import (
INDEX_COLUMN_NAME,
add_selection_column,
Expand Down Expand Up @@ -94,6 +98,7 @@ def __init__(self, error: str):
@dataclass
class DownloadAsArgs:
format: Literal["csv", "tsv", "json", "parquet"]
locale: ResolvedExportLocale | None = None


@dataclass
Expand Down Expand Up @@ -1094,12 +1099,16 @@ def _download_as(self, args: DownloadAsArgs) -> DownloadAsResponse:
if isinstance(manager_candidate, TableManager):
bound_filename = get_bound_name(self._id)

url, filename = download_as(
manager_candidate,
args.format,
drop_marimo_index=True,
filename=bound_filename,
)
try:
url, filename = download_as(
manager_candidate,
args.format,
drop_marimo_index=True,
filename=bound_filename,
locale=args.locale,
)
except InvalidExportLocaleError as error:
return DownloadAsResponse(error=str(error))
return DownloadAsResponse(url=url, filename=filename)
else:
raise NotImplementedError(
Expand Down
Loading
Loading