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(
         {