Skip to content

Commit 3602619

Browse files
hughhhhclaude
andcommitted
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>
1 parent 216cf65 commit 3602619

3 files changed

Lines changed: 115 additions & 21 deletions

File tree

superset/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1378,6 +1378,8 @@ def sync_theme_logo_href(
13781378
# Key prefix for export objects: {prefix}{dashboard_id}/{job_id}.xlsx
13791379
EXCEL_EXPORT_S3_KEY_PREFIX = "dashboard-exports/"
13801380
# Lifetime (seconds) of the pre-signed download URL emailed to the user (24h).
1381+
# Note: AWS S3 caps pre-signed URL lifetime at 7 days (604800 seconds); larger
1382+
# values are rejected by S3, so keep this at or below that when using AWS.
13811383
EXCEL_EXPORT_LINK_TTL_SECONDS = 86400
13821384
# Extra kwargs passed to boto3.client("s3", ...) — e.g. region_name, or an
13831385
# endpoint_url for S3-compatible stores (MinIO/LocalStack). Credentials

superset/utils/excel_streaming.py

Lines changed: 60 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@
1515
# specific language governing permissions and limitations
1616
# under the License.
1717
"""
18-
Streaming, constant-memory XLSX writer for multi-sheet dashboard exports.
19-
20-
Unlike :mod:`superset.utils.excel`, which materializes a whole DataFrame in
21-
memory, this writer streams rows one at a time into an ``xlsxwriter`` workbook
22-
opened in ``constant_memory`` mode, so a dashboard with many large charts never
23-
holds more than one row per sheet in memory at once.
18+
Streaming XLSX writer for multi-sheet dashboard exports.
19+
20+
Unlike :mod:`superset.utils.excel`, which builds an in-memory DataFrame per
21+
sheet and hands the whole thing to ``xlsxwriter`` at once, this writer opens the
22+
workbook in ``constant_memory`` mode and writes rows one at a time, so
23+
``xlsxwriter`` keeps at most one row per sheet buffered on the writer side. The
24+
source records may still be materialized upstream (e.g. by the chart query
25+
response); this bounds only the writer's own footprint, not the caller's.
2426
"""
2527

2628
from __future__ import annotations
@@ -54,6 +56,37 @@
5456
_MAX_EXCEL_INT = 10**15
5557

5658

59+
def _quote_if_formula(text: str) -> str:
60+
"""
61+
Prefix formula-like text with an apostrophe so spreadsheet apps treat it as
62+
literal text (defense against formula injection).
63+
64+
Leading whitespace is ignored when detecting a formula, because spreadsheet
65+
apps still evaluate a cell whose formula prefix is preceded by spaces or
66+
tabs (e.g. ``" =cmd"`` or ``"\\t=cmd"``).
67+
"""
68+
stripped = text.lstrip()
69+
return f"'{text}" if stripped and stripped[0] in _FORMULA_PREFIXES else text
70+
71+
72+
def _coerce_float_cell(value: Any) -> Any:
73+
"""
74+
Convert a ``Decimal``/real value to something ``xlsxwriter`` accepts.
75+
76+
``float()`` on a non-finite ``Decimal`` ("NaN"/"Infinity") yields a value
77+
xlsxwriter rejects, and an over-large value can raise ``OverflowError``;
78+
blank the former and stringify the latter, and stringify magnitudes Excel
79+
cannot represent precisely.
80+
"""
81+
try:
82+
number = float(value)
83+
except (OverflowError, ValueError):
84+
return str(value)
85+
if not math.isfinite(number):
86+
return ""
87+
return str(number) if abs(number) > _MAX_EXCEL_INT else number
88+
89+
5790
def sanitize_sheet_name(raw: str, used: set[str]) -> str:
5891
"""
5992
Produce a valid, unique Excel sheet name from ``raw``.
@@ -104,24 +137,19 @@ def _sanitize_cell(value: Any) -> Any:
104137
if isinstance(value, bool):
105138
return value
106139
if isinstance(value, str):
107-
return f"'{value}" if value and value[0] in _FORMULA_PREFIXES else value
140+
return _quote_if_formula(value)
108141
if isinstance(value, (datetime, date)):
109142
return value.isoformat()
110143
if isinstance(value, Decimal):
111-
number = float(value)
112-
return str(number) if abs(number) > _MAX_EXCEL_INT else number
144+
return _coerce_float_cell(value)
113145
if isinstance(value, numbers.Integral):
114146
number = int(value)
115147
return str(number) if abs(number) > _MAX_EXCEL_INT else number
116148
if isinstance(value, numbers.Real):
117-
number = float(value)
118-
if not math.isfinite(number):
119-
return ""
120-
return str(number) if abs(number) > _MAX_EXCEL_INT else number
149+
return _coerce_float_cell(value)
121150
# Anything else (lists, dicts, custom objects) is stringified, still guarding
122151
# against formula injection on the resulting text.
123-
text = str(value)
124-
return f"'{text}" if text and text[0] in _FORMULA_PREFIXES else text
152+
return _quote_if_formula(str(value))
125153

126154

127155
class StreamingXlsxWriter:
@@ -153,20 +181,34 @@ def add_sheet(
153181
:param name: Desired sheet name (sanitized/de-duplicated automatically)
154182
:param columns: Column headers
155183
:param rows: Iterable of row sequences, streamed one at a time
156-
:returns: The number of data rows actually written (capped at Excel's
157-
per-sheet limit)
184+
:returns: The number of data rows actually written (capped just below
185+
Excel's per-sheet limit; when the data is larger a final notice row
186+
is appended and the dropped rows are not counted)
158187
"""
159188
sheet_name = sanitize_sheet_name(name, self._used_sheet_names)
160189
worksheet = self._workbook.add_worksheet(sheet_name)
161190
worksheet.write_row(0, 0, [_sanitize_cell(col) for col in columns])
162191

192+
# Reserve the final row for a truncation notice, so when the data
193+
# exceeds the sheet's capacity the user can see rows were dropped
194+
# instead of silently losing them.
195+
row_cap = MAX_DATA_ROWS_PER_SHEET - 1
163196
written = 0
197+
truncated = False
164198
for row in rows:
165-
if written >= MAX_DATA_ROWS_PER_SHEET:
199+
if written >= row_cap:
200+
truncated = True
166201
break
167202
worksheet.write_row(written + 1, 0, [_sanitize_cell(cell) for cell in row])
168203
written += 1
169204

205+
if truncated:
206+
worksheet.write_string(
207+
written + 1,
208+
0,
209+
f"[Truncated: only first {written:,} rows exported]",
210+
)
211+
170212
self.sheet_count += 1
171213
return written
172214

tests/unit_tests/utils/excel_streaming_tests.py

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,38 @@ def test_sanitize_cell_non_finite_floats_blanked() -> None:
9999
assert _sanitize_cell(float("inf")) == ""
100100

101101

102+
def test_sanitize_cell_non_finite_decimals_blanked() -> None:
103+
# float(Decimal("NaN")) is nan and float(Decimal("Infinity")) is inf, both
104+
# of which xlsxwriter rejects; they must be blanked rather than crash.
105+
assert _sanitize_cell(Decimal("NaN")) == ""
106+
assert _sanitize_cell(Decimal("Infinity")) == ""
107+
assert _sanitize_cell(Decimal("-Infinity")) == ""
108+
109+
110+
def test_sanitize_cell_out_of_range_decimal_is_blanked() -> None:
111+
# A Decimal too large for a float becomes inf (or, in edge cases, raises
112+
# OverflowError); either way it must be neutralized rather than crash
113+
# xlsxwriter or emit a bogus value.
114+
assert _sanitize_cell(Decimal("1E10000")) == ""
115+
116+
117+
@pytest.mark.parametrize(
118+
"value,expected",
119+
[
120+
(" =SUM(A1)", "' =SUM(A1)"),
121+
("\t=SUM(A1)", "'\t=SUM(A1)"),
122+
(" +1", "' +1"),
123+
("\t@handle", "'\t@handle"),
124+
],
125+
)
126+
def test_sanitize_cell_quotes_formula_behind_whitespace(
127+
value: str, expected: str
128+
) -> None:
129+
# Spreadsheet apps evaluate formulas even when preceded by spaces/tabs, so
130+
# the formula guard must look past leading whitespace.
131+
assert _sanitize_cell(value) == expected
132+
133+
102134
# --- StreamingXlsxWriter (round-trip via openpyxl) ---
103135

104136

@@ -139,16 +171,34 @@ def test_writer_quotes_formula_cells(tmp_path: Path) -> None:
139171
def test_writer_caps_rows_per_sheet(
140172
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
141173
) -> None:
142-
monkeypatch.setattr(excel_streaming, "MAX_DATA_ROWS_PER_SHEET", 2)
174+
monkeypatch.setattr(excel_streaming, "MAX_DATA_ROWS_PER_SHEET", 3)
143175
path = str(tmp_path / "out.xlsx")
144176
writer = StreamingXlsxWriter(path)
145177
written = writer.add_sheet("data", ["col"], [[i] for i in range(5)])
146178
writer.close()
147179

180+
# One row is reserved for the truncation notice, so only 2 data rows fit.
181+
assert written == 2
182+
sheets = _read_workbook(path)
183+
# header + 2 data rows + 1 truncation notice
184+
assert len(sheets["data"]) == 4
185+
assert sheets["data"][-1][0] == "[Truncated: only first 2 rows exported]"
186+
187+
188+
def test_writer_no_truncation_notice_when_data_fits(
189+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
190+
) -> None:
191+
monkeypatch.setattr(excel_streaming, "MAX_DATA_ROWS_PER_SHEET", 3)
192+
path = str(tmp_path / "out.xlsx")
193+
writer = StreamingXlsxWriter(path)
194+
# Exactly fills the reserved capacity (MAX - 1) with no leftover rows.
195+
written = writer.add_sheet("data", ["col"], [[i] for i in range(2)])
196+
writer.close()
197+
148198
assert written == 2
149199
sheets = _read_workbook(path)
150-
# header + 2 data rows
151-
assert len(sheets["data"]) == 3
200+
# header + 2 data rows, no notice
201+
assert sheets["data"] == [["col"], [0], [1]]
152202

153203

154204
def test_writer_empty_workbook_is_valid(tmp_path: Path) -> None:

0 commit comments

Comments
 (0)