You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ju...@apache.org on 2026/08/26 20:37:53 UTC
(superset) branch 6.2 updated: fix(excel): handle duplicate column labels in xlsx export (#43560)
This is an automated email from the ASF dual-hosted git repository.
justinpark pushed a commit to branch 6.2
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/6.2 by this push:
new 8c8dd8f01f0 fix(excel): handle duplicate column labels in xlsx export (#43560)
8c8dd8f01f0 is described below
commit 8c8dd8f01f035b988ba0309bff3bacb00f9455b5
Author: Luiz Otavio <45...@users.noreply.github.com>
AuthorDate: Wed Aug 26 17:37:44 2026 -0300
fix(excel): handle duplicate column labels in xlsx export (#43560)
Co-authored-by: Claude Opus 5 <no...@anthropic.com>
---
superset/utils/excel.py | 61 +++++++++++++++++++++++---------
tests/unit_tests/utils/excel_tests.py | 65 ++++++++++++++++++++++++++++++++++-
2 files changed, 109 insertions(+), 17 deletions(-)
diff --git a/superset/utils/excel.py b/superset/utils/excel.py
index 83061b5d276..05a3ead8f7f 100644
--- a/superset/utils/excel.py
+++ b/superset/utils/excel.py
@@ -21,21 +21,39 @@ import pandas as pd
from superset.utils.core import GenericDataType
+# Leading characters that turn a cell into a formula in spreadsheet apps.
+FORMULA_PREFIXES = {"=", "+", "-", "@"}
+
+
+def _quote_formula(value: Any) -> Any:
+ """Prefix a string with a quote when it would parse as a formula."""
+ return (
+ f"'{value}"
+ if isinstance(value, str) and len(value) and value[0] in FORMULA_PREFIXES
+ else value
+ )
+
def quote_formulas(df: pd.DataFrame) -> pd.DataFrame:
"""
Make sure to quote any formulas for security reasons.
"""
- formula_prefixes = {"=", "+", "-", "@"}
-
- for col in df.select_dtypes(include="object").columns:
- df[col] = df[col].apply(
- lambda x: (
- f"'{x}"
- if isinstance(x, str) and len(x) and x[0] in formula_prefixes
- else x
- )
- )
+ # Columns are addressed by position rather than by label: a dataframe can
+ # carry duplicate column labels (the verbose_map rename in
+ # QueryContextProcessor.get_data can collapse two columns onto the same
+ # name), and ``df[label]`` then yields a DataFrame instead of a Series.
+ # ``DataFrame.apply`` would hand whole columns to the mapper rather than
+ # individual cells, silently leaving formulas unquoted.
+ for idx in range(len(df.columns)):
+ series = df.iloc[:, idx]
+ # ``is_string_dtype`` rather than an ``object`` comparison: pandas 3
+ # gives string columns a dedicated ``str`` dtype, which an object-only
+ # check (as the ``select_dtypes(include="object")`` this replaced) would
+ # skip, silently leaving formulas unquoted.
+ if pd.api.types.is_object_dtype(series.dtype) or pd.api.types.is_string_dtype(
+ series.dtype
+ ):
+ df.isetitem(idx, series.map(_quote_formula))
return df
@@ -63,20 +81,31 @@ def apply_column_types(
:param column_types: The types of the columns
:return: The dataframe with the column types applied
"""
- for column, column_type in zip(df.columns, column_types, strict=False):
+ # Columns are addressed by position for the same reason as in
+ # ``quote_formulas``: duplicate column labels make ``df[label]`` return a
+ # DataFrame, and ``DataFrame`` has no ``dtype``. Slicing column_types keeps
+ # the lenient pairing the previous ``zip(..., strict=False)`` provided.
+ for idx, column_type in enumerate(column_types[: len(df.columns)]):
+ series = df.iloc[:, idx]
if column_type == GenericDataType.NUMERIC:
try:
- df[column] = pd.to_numeric(df[column])
+ series = pd.to_numeric(series)
# if the number is too large, convert it to a string
# Excel does not support numbers larger than 10^15
- df[column] = df[column].apply(
+ series = series.apply(
lambda x: str(x)
if isinstance(x, (int, float)) and abs(x) > 10**15
else x
)
except ValueError:
- df[column] = df[column].astype(str)
- elif isinstance(df[column].dtype, pd.DatetimeTZDtype):
+ series = series.astype(str)
+ elif isinstance(series.dtype, pd.DatetimeTZDtype):
# timezones are not supported
- df[column] = df[column].astype(str)
+ series = series.astype(str)
+ else:
+ continue
+ # ``isetitem`` replaces the column at that position, which is both
+ # unambiguous under duplicate labels and free of the in-place dtype
+ # casting that ``iloc`` assignment attempts.
+ df.isetitem(idx, series)
return df
diff --git a/tests/unit_tests/utils/excel_tests.py b/tests/unit_tests/utils/excel_tests.py
index b07fe7a4f0d..2c6edd5404c 100644
--- a/tests/unit_tests/utils/excel_tests.py
+++ b/tests/unit_tests/utils/excel_tests.py
@@ -21,7 +21,7 @@ import pandas as pd
from pandas.api.types import is_numeric_dtype
from superset.utils.core import GenericDataType
-from superset.utils.excel import apply_column_types, df_to_excel
+from superset.utils.excel import apply_column_types, df_to_excel, quote_formulas
def test_timezone_conversion() -> None:
@@ -107,6 +107,69 @@ def test_column_data_types_with_failing_conversion():
assert not is_numeric_dtype(df["col3"])
+def test_apply_column_types_with_duplicate_column_labels() -> None:
+ """
+ Test that duplicate column labels do not break the export.
+
+ The verbose_map rename in QueryContextProcessor.get_data can collapse two
+ columns onto the same label, which used to raise
+ "'DataFrame' object has no attribute 'dtype'".
+ """
+ df = pd.DataFrame(
+ [
+ ["1", datetime(2023, 1, 1, 0, 0, tzinfo=timezone.utc), "2"],
+ ["3", datetime(2023, 1, 2, 0, 0, tzinfo=timezone.utc), "4"],
+ ],
+ columns=["dupe", "dupe", "other"],
+ )
+ coltypes: list[GenericDataType] = [
+ GenericDataType.STRING,
+ GenericDataType.TEMPORAL,
+ GenericDataType.NUMERIC,
+ ]
+
+ apply_column_types(df, coltypes)
+
+ # each position is typed independently, despite sharing a label
+ assert not is_numeric_dtype(df.iloc[:, 0])
+ assert df.iloc[:, 1].tolist() == [
+ "2023-01-01 00:00:00+00:00",
+ "2023-01-02 00:00:00+00:00",
+ ]
+ assert is_numeric_dtype(df.iloc[:, 2])
+
+ contents = df_to_excel(df, index=False)
+ assert pd.read_excel(contents).shape == (2, 3)
+
+
+def test_quote_formulas_with_duplicate_column_labels() -> None:
+ """
+ Test that formulas are quoted even when column labels are duplicated.
+ """
+ df = pd.DataFrame(
+ [["=SUM(A1:A2)", "@SUM(A1:A2)", "normal"]],
+ columns=["dupe", "dupe", "other"],
+ )
+
+ quote_formulas(df)
+
+ assert df.iloc[0].tolist() == ["'=SUM(A1:A2)", "'@SUM(A1:A2)", "normal"]
+
+
+def test_quote_formulas_with_dedicated_string_dtype() -> None:
+ """
+ Test that formulas are quoted in columns using the dedicated string dtype.
+
+ pandas 3 gives string columns a ``str`` dtype rather than ``object``, so an
+ object-only dtype check would skip them and leave formulas unquoted.
+ """
+ df = pd.DataFrame({"formula": pd.array(["=SUM(A1:A2)", "normal"], dtype="string")})
+
+ result = quote_formulas(df)
+
+ assert result["formula"].tolist() == ["'=SUM(A1:A2)", "normal"]
+
+
def test_column_data_types_with_large_numeric_values():
df = pd.DataFrame(
{