Files
superset2/tests/unit_tests/utils/excel_streaming_tests.py
Hugh A Miles II 3602619e98 fix(dashboard): address Excel export review feedback
Addresses PR #41133 review comments:
- Blank/stringify non-finite and out-of-range Decimal cells instead of
  crashing xlsxwriter (shared _coerce_float_cell helper).
- Detect formula-injection prefixes behind leading whitespace via
  _quote_if_formula (lstrip before checking).
- Correct the module docstring to scope the constant-memory guarantee to
  the writer side (source rows may be materialized upstream).
- Append a "[Truncated: ...]" notice row when a sheet exceeds Excel's
  per-sheet row cap so dropped rows are visible.
- Note AWS S3's 7-day pre-signed URL cap on EXCEL_EXPORT_LINK_TTL_SECONDS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 12:22:41 -04:00

221 lines
7.1 KiB
Python

# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
import pytest
from superset.utils import excel_streaming
from superset.utils.excel_streaming import (
_sanitize_cell,
sanitize_sheet_name,
StreamingXlsxWriter,
)
# --- sanitize_sheet_name ---
def test_sheet_name_replaces_forbidden_chars() -> None:
assert sanitize_sheet_name("a/b:c*d?e[f]g\\h", set()) == "a_b_c_d_e_f_g_h"
def test_sheet_name_truncated_to_31() -> None:
assert sanitize_sheet_name("x" * 40, set()) == "x" * 31
def test_sheet_name_dedupes_case_insensitively() -> None:
used: set[str] = set()
assert sanitize_sheet_name("Sales", used) == "Sales"
assert sanitize_sheet_name("sales", used) == "sales~2"
assert sanitize_sheet_name("SALES", used) == "SALES~3"
def test_sheet_name_dedupe_marker_respects_length_cap() -> None:
used: set[str] = set()
long_name = "y" * 31
assert sanitize_sheet_name(long_name, used) == long_name
assert sanitize_sheet_name(long_name, used) == "y" * 29 + "~2"
def test_sheet_name_blank_falls_back() -> None:
assert sanitize_sheet_name(" ", set()) == "Sheet"
def test_sheet_name_reserved_history_is_escaped() -> None:
assert sanitize_sheet_name("History", set()) == "History_"
def test_sheet_name_strips_surrounding_apostrophes() -> None:
assert sanitize_sheet_name("'quoted'", set()) == "quoted"
# --- _sanitize_cell ---
@pytest.mark.parametrize(
"value,expected",
[
(None, ""),
("=SUM(A1)", "'=SUM(A1)"),
("+1", "'+1"),
("-1", "'-1"),
("@handle", "'@handle"),
("normal", "normal"),
(True, True),
(5, 5),
(1.5, 1.5),
(Decimal("2.5"), 2.5),
(datetime(2020, 1, 2, 3, 4, 5), "2020-01-02T03:04:05"),
(date(2020, 1, 2), "2020-01-02"),
],
)
def test_sanitize_cell(value: object, expected: object) -> None:
assert _sanitize_cell(value) == expected
def test_sanitize_cell_large_int_becomes_string() -> None:
assert _sanitize_cell(10**16) == str(10**16)
def test_sanitize_cell_non_finite_floats_blanked() -> None:
assert _sanitize_cell(float("nan")) == ""
assert _sanitize_cell(float("inf")) == ""
def test_sanitize_cell_non_finite_decimals_blanked() -> None:
# float(Decimal("NaN")) is nan and float(Decimal("Infinity")) is inf, both
# of which xlsxwriter rejects; they must be blanked rather than crash.
assert _sanitize_cell(Decimal("NaN")) == ""
assert _sanitize_cell(Decimal("Infinity")) == ""
assert _sanitize_cell(Decimal("-Infinity")) == ""
def test_sanitize_cell_out_of_range_decimal_is_blanked() -> None:
# A Decimal too large for a float becomes inf (or, in edge cases, raises
# OverflowError); either way it must be neutralized rather than crash
# xlsxwriter or emit a bogus value.
assert _sanitize_cell(Decimal("1E10000")) == ""
@pytest.mark.parametrize(
"value,expected",
[
(" =SUM(A1)", "' =SUM(A1)"),
("\t=SUM(A1)", "'\t=SUM(A1)"),
(" +1", "' +1"),
("\t@handle", "'\t@handle"),
],
)
def test_sanitize_cell_quotes_formula_behind_whitespace(
value: str, expected: str
) -> None:
# Spreadsheet apps evaluate formulas even when preceded by spaces/tabs, so
# the formula guard must look past leading whitespace.
assert _sanitize_cell(value) == expected
# --- StreamingXlsxWriter (round-trip via openpyxl) ---
def _read_workbook(path: str) -> dict[str, list[list[object]]]:
openpyxl = pytest.importorskip("openpyxl")
workbook = openpyxl.load_workbook(path, read_only=True)
sheets = {
ws.title: [list(row) for row in ws.iter_rows(values_only=True)]
for ws in workbook.worksheets
}
workbook.close()
return sheets
def test_writer_writes_one_sheet_per_chart(tmp_path: Path) -> None:
path = str(tmp_path / "out.xlsx")
writer = StreamingXlsxWriter(path)
assert writer.add_sheet("10 - First", ["a", "b"], [[1, 2], [3, 4]]) == 2
assert writer.add_sheet("20 - Second", ["c"], [["x"]]) == 1
writer.close()
sheets = _read_workbook(path)
assert list(sheets.keys()) == ["10 - First", "20 - Second"]
assert sheets["10 - First"] == [["a", "b"], [1, 2], [3, 4]]
assert sheets["20 - Second"] == [["c"], ["x"]]
def test_writer_quotes_formula_cells(tmp_path: Path) -> None:
path = str(tmp_path / "out.xlsx")
writer = StreamingXlsxWriter(path)
writer.add_sheet("data", ["col"], [["=cmd()"]])
writer.close()
sheets = _read_workbook(path)
assert sheets["data"][1][0] == "'=cmd()"
def test_writer_caps_rows_per_sheet(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(excel_streaming, "MAX_DATA_ROWS_PER_SHEET", 3)
path = str(tmp_path / "out.xlsx")
writer = StreamingXlsxWriter(path)
written = writer.add_sheet("data", ["col"], [[i] for i in range(5)])
writer.close()
# One row is reserved for the truncation notice, so only 2 data rows fit.
assert written == 2
sheets = _read_workbook(path)
# header + 2 data rows + 1 truncation notice
assert len(sheets["data"]) == 4
assert sheets["data"][-1][0] == "[Truncated: only first 2 rows exported]"
def test_writer_no_truncation_notice_when_data_fits(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(excel_streaming, "MAX_DATA_ROWS_PER_SHEET", 3)
path = str(tmp_path / "out.xlsx")
writer = StreamingXlsxWriter(path)
# Exactly fills the reserved capacity (MAX - 1) with no leftover rows.
written = writer.add_sheet("data", ["col"], [[i] for i in range(2)])
writer.close()
assert written == 2
sheets = _read_workbook(path)
# header + 2 data rows, no notice
assert sheets["data"] == [["col"], [0], [1]]
def test_writer_empty_workbook_is_valid(tmp_path: Path) -> None:
path = str(tmp_path / "out.xlsx")
writer = StreamingXlsxWriter(path)
writer.close()
sheets = _read_workbook(path)
assert list(sheets.keys()) == ["Export"]
def test_writer_summary_sheet(tmp_path: Path) -> None:
path = str(tmp_path / "out.xlsx")
writer = StreamingXlsxWriter(path)
writer.add_summary_sheet("Export Summary", ["Skipped charts:", "10 - Broken"])
writer.close()
sheets = _read_workbook(path)
assert sheets["Export Summary"] == [["Skipped charts:"], ["10 - Broken"]]