fix(downloads): swap st.download_button for an HTML <a download> helper

Reported symptom: only the FIRST download button in a multi-button
row pops the browser save dialog. The second and third do nothing on
click. Affects every tool page that exposes (cleaned + audit + config)
downloads.

Root cause is ``st.download_button`` itself — when several render in
the same script pass, the click-to-bytes wiring on the browser side
mis-routes and only one button's data is actually exposed. Explicit
``key`` arguments don't fix it; ``use_container_width=True`` doesn't
help either; we confirmed this in the Text Cleaner reverts.

Replace the widget with a real ``<a download="file" href="data:...">``
anchor rendered via ``st.markdown(..., unsafe_allow_html=True)``.
Bypasses Streamlit's widget machinery entirely; behaves identically to
a native browser download. Side benefit: clicking it does NOT trigger
a script rerun, so other in-flight UI state survives.

New helper ``html_download_button`` lives in
``src/gui/components/_legacy.py`` (exported from ``components``). API:

    html_download_button(
        label, data,
        *, file_name, mime="application/octet-stream",
        disabled=False, help=None, use_container_width=True,
    )

Translation pattern applied across every tool page (and shared
``results_summary`` / ``config_panel`` widgets in ``_legacy.py``):

- ``st.download_button(`` -> ``html_download_button(``
- ``data=foo_bytes`` kwarg -> positional second arg
- ``key="..."`` -> dropped (helper has no widget identity)
- ``use_container_width=True`` -> dropped (default)
- ``disabled=`` and ``help=`` pass through unchanged
- Pre-computed byte buffers kept where they were

Total: 17 sites replaced (3 in Text Cleaner, 3 in Format
Standardizer, 3 in Fix Missing Values, 3 in Map Columns, 3 in
Automated Workflows, 2 in Find Duplicates page + 4 in shared
_legacy.py widgets used by Find Duplicates).

Caveat: data: URLs balloon by 33% (base64). Fine for tool output
sizes we ship; if a future result topped a few hundred MB we'd want a
Blob-URL fallback.

The marketing demo at src/gui/app_demo.py keeps its single
st.download_button — single button, no collision, no need to switch.

2008 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 21:13:41 +00:00
parent 6415be8bf4
commit aeead05e4c
8 changed files with 135 additions and 97 deletions

View File

@@ -221,6 +221,70 @@ def _farewell_script() -> str:
)
def html_download_button(
label: str,
data: bytes,
*,
file_name: str,
mime: str = "application/octet-stream",
disabled: bool = False,
help: str | None = None,
use_container_width: bool = True,
) -> None:
"""Render a download trigger as a real ``<a download>`` anchor.
Replaces ``st.download_button`` for pages that stack multiple
download triggers in one render pass. Streamlit's ``download_button``
has a long-standing failure mode where only the first button in the
page actually fires when several are rendered together: explicit
``key`` arguments are not sufficient, since the browser-side
bytes-to-Blob translation appears to share state across widgets in
some browsers (Edge/Chrome on Windows in particular).
Sidestepping the widget system entirely fixes it. The bytes are
base64-encoded into a ``data:`` URL on the anchor's ``href``; the
browser's native ``download`` attribute pops the standard save
dialog. No script reruns happen on click — that's an upside, since
it avoids resetting any other in-flight UI state.
Caveat: data: URLs balloon by 33% (base64). Fine up to a few tens
of MB. For 1 GB+ datasets a different mechanism would be needed,
but tool output is rarely that large.
"""
import base64
import html as _html
width_css = "width:100%;" if use_container_width else ""
base_style = (
"display:inline-block;text-align:center;"
"padding:0.375rem 0.75rem;border-radius:0.5rem;"
"border:1px solid rgba(49,51,63,0.2);"
"background:rgb(240,242,246);color:rgb(38,39,48);"
"text-decoration:none;font-weight:400;cursor:pointer;"
"font-family:inherit;font-size:14px;"
"box-sizing:border-box;line-height:1.6;"
f"{width_css}"
)
safe_label = _html.escape(label)
title_attr = f' title="{_html.escape(help)}"' if help else ""
if disabled:
disabled_style = base_style + "opacity:0.5;cursor:not-allowed;"
st.markdown(
f'<span{title_attr} style="{disabled_style}">{safe_label}</span>',
unsafe_allow_html=True,
)
return
b64 = base64.b64encode(data).decode("ascii")
safe_name = _html.escape(file_name, quote=True)
st.markdown(
f'<a download="{safe_name}" href="data:{mime};base64,{b64}"'
f'{title_attr} style="{base_style}">{safe_label}</a>',
unsafe_allow_html=True,
)
def back_to_home_link(*, key: str = "_back_to_home_link") -> None:
"""Render a small "← Back to Home" affordance near the top of a tool page.
@@ -398,9 +462,9 @@ def config_panel(df: pd.DataFrame) -> dict:
)
cfg_json = cfg.to_dict()
import json
st.download_button(
html_download_button(
"Download config JSON",
data=json.dumps(cfg_json, indent=2),
json.dumps(cfg_json, indent=2).encode("utf-8"),
file_name="dedup_config.json",
mime="application/json",
)
@@ -777,9 +841,9 @@ def results_summary(
with dl_left:
csv_bytes = result.deduplicated_df.to_csv(index=False).encode("utf-8-sig")
st.download_button(
html_download_button(
"Download Deduplicated CSV",
data=csv_bytes,
csv_bytes,
file_name="deduplicated.csv",
mime="text/csv",
)
@@ -787,9 +851,9 @@ def results_summary(
with dl_mid:
if not result.removed_df.empty:
removed_bytes = result.removed_df.to_csv(index=False).encode("utf-8-sig")
st.download_button(
html_download_button(
"Download Removed Rows",
data=removed_bytes,
removed_bytes,
file_name="removed_rows.csv",
mime="text/csv",
)
@@ -797,9 +861,9 @@ def results_summary(
with dl_right:
if result.match_groups:
groups_data = _build_match_groups_csv(result, original_df)
st.download_button(
html_download_button(
"Download Match Groups Report",
data=groups_data,
groups_data,
file_name="match_groups.csv",
mime="text/csv",
)