fix(downloads): save server-side to ~/Downloads + open-folder link

Switch the download mechanic from "browser <a download> with a data:
URL" to "write the bytes directly to the user's Downloads folder and
show them the exact path". DataTools runs as a local Streamlit app,
so the "server" IS the user's machine — there's no reason to go
through the browser save dialog at all.

Flow:

1. Click "Download <something>" button (rendered as a regular
   ``st.button``, so no widget-collision issues).
2. Bytes are written to ``Path.home() / "Downloads" / file_name``
   (overwriting any same-named file).
3. The page reruns and renders a success caption with the absolute
   path the file landed at.
4. An "📂 Open Downloads folder" button appears. Clicking it pops the
   OS file manager via ``os.startfile`` (Windows), ``open`` (macOS),
   or ``xdg-open`` (Linux).

Why this is better than the previous HTML-data-URL helper:

- Unambiguous about where the file went — user sees the full path,
  not "wherever your browser was configured to save".
- The data: URL approach base64-inflated the page payload by 33% and
  bloated for large outputs; server-side write is byte-for-byte.
- No more browser-side widget collision class of bug.
- The save action is a real Streamlit button, so the existing widget
  semantics (disabled, help tooltip, key isolation) work without
  workarounds.

API surface unchanged. New canonical name ``local_download_button``;
``html_download_button`` is kept as a back-compat alias that points
at the same implementation — every existing call site continues to
work without edits.

Tests are protected from polluting the developer's home dir via a
``DATATOOLS_DOWNLOADS_DIR`` env var override returned by the new
``_downloads_dir()`` helper. Smoke verified end-to-end via AppTest:
click → file appears in tmp dir → success banner shows path →
open-folder button renders.

2220 tests pass, 91 skipped, 35 s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 21:48:28 +00:00
parent 5128d35961
commit b9147f3b66
2 changed files with 111 additions and 46 deletions

View File

@@ -49,6 +49,7 @@ __all__ = [
"back_to_home_link", "back_to_home_link",
"hide_streamlit_chrome", "hide_streamlit_chrome",
"html_download_button", "html_download_button",
"local_download_button",
"shutdown_app", "shutdown_app",
"pickup_or_upload", "pickup_or_upload",
# License gate + activation form # License gate + activation form

View File

@@ -239,68 +239,132 @@ def _farewell_script() -> str:
) )
def html_download_button( def _downloads_dir() -> "Path":
"""Return the user's Downloads folder.
Defaults to ``~/Downloads``. Overrideable via the
``DATATOOLS_DOWNLOADS_DIR`` environment variable so tests can write
to a temp directory instead of polluting the developer's home.
"""
import os
from pathlib import Path
override = os.environ.get("DATATOOLS_DOWNLOADS_DIR")
if override:
return Path(override)
return Path.home() / "Downloads"
def _open_in_file_manager(path: "Path") -> bool:
"""Open *path* in the host OS's native file manager.
Returns ``True`` if the command was dispatched (no guarantee the
file manager actually opened — the user might have removed the
default handler). Returns ``False`` on platforms we don't know
how to launch.
"""
import os
import subprocess
try:
if sys.platform == "win32":
os.startfile(str(path)) # type: ignore[attr-defined]
return True
if sys.platform == "darwin":
subprocess.Popen(["open", str(path)])
return True
# Linux / *BSD / etc.
subprocess.Popen(["xdg-open", str(path)])
return True
except Exception:
return False
def local_download_button(
label: str, label: str,
data: bytes, data: bytes,
*, *,
file_name: str, file_name: str,
mime: str = "application/octet-stream", mime: str = "application/octet-stream", # noqa: ARG001 — kept for API compat
disabled: bool = False, disabled: bool = False,
help: str | None = None, help: str | None = None,
use_container_width: bool = True, use_container_width: bool = True,
) -> None: ) -> None:
"""Render a download trigger as a real ``<a download>`` anchor. """Save bytes directly to the user's Downloads folder.
Replaces ``st.download_button`` for pages that stack multiple DataTools runs as a local Streamlit app, so the "server" IS the
download triggers in one render pass. Streamlit's ``download_button`` user's machine — we can write straight to ``~/Downloads/<file_name>``
has a long-standing failure mode where only the first button in the instead of going through the browser save dialog. On click:
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 1. Bytes are written to ``Path.home() / "Downloads" / file_name``
base64-encoded into a ``data:`` URL on the anchor's ``href``; the (overwriting any existing file with the same name).
browser's native ``download`` attribute pops the standard save 2. The page reruns and renders a success caption naming the exact
dialog. No script reruns happen on click — that's an upside, since absolute path the file landed at.
it avoids resetting any other in-flight UI state. 3. An "Open Downloads folder" button appears that pops the OS file
manager (Explorer / Finder / xdg-open) at the parent directory.
Caveat: data: URLs balloon by 33% (base64). Fine up to a few tens Why not ``st.download_button`` or an HTML data: URL anchor?
of MB. For 1 GB+ datasets a different mechanism would be needed,
but tool output is rarely that large. - ``st.download_button`` has a long-standing failure mode where
only the first button on the page fires when multiple are
stacked together.
- Data: URLs balloon by 33% (base64) and leave the user guessing
where the browser saved it (default Downloads folder or wherever
they last picked — varies per browser).
The save-server-side path is unambiguous, works the same regardless
of browser settings, and gives the user a real link to the file.
The ``mime`` parameter is accepted for backwards compatibility with
the previous helper signature; it is no longer relevant because
nothing on the wire knows the bytes' content type.
""" """
import base64 import hashlib
import html as _html from pathlib import Path
width_css = "width:100%;" if use_container_width else "" # Stable widget keys, namespaced by file_name + content digest so
base_style = ( # repeated renders of the same content keep their saved-state
"display:inline-block;text-align:center;" # banner, but a re-run that produced different bytes gets a fresh
"padding:0.375rem 0.75rem;border-radius:0.5rem;" # button with no stale success message.
"border:1px solid rgba(49,51,63,0.2);" digest = hashlib.sha1(data, usedforsecurity=False).hexdigest()[:8]
"background:rgb(240,242,246);color:rgb(38,39,48);" btn_key = f"_dl_btn_{file_name}_{digest}"
"text-decoration:none;font-weight:400;cursor:pointer;" saved_key = f"_dl_saved_{file_name}_{digest}"
"font-family:inherit;font-size:14px;" open_key = f"_dl_open_{file_name}_{digest}"
"box-sizing:border-box;line-height:1.6;"
f"{width_css}" clicked = st.button(
label,
key=btn_key,
disabled=disabled,
help=help,
type="secondary",
use_container_width=use_container_width,
) )
safe_label = _html.escape(label)
title_attr = f' title="{_html.escape(help)}"' if help else ""
if disabled: if clicked:
disabled_style = base_style + "opacity:0.5;cursor:not-allowed;" target_dir = _downloads_dir()
st.markdown( try:
f'<span{title_attr} style="{disabled_style}">{safe_label}</span>', target_dir.mkdir(parents=True, exist_ok=True)
unsafe_allow_html=True, target = target_dir / file_name
target.write_bytes(data)
st.session_state[saved_key] = str(target)
except Exception as e:
st.error(
f"Could not save **{file_name}** to `{target_dir}`: {e}"
) )
return return
b64 = base64.b64encode(data).decode("ascii") saved_path_str = st.session_state.get(saved_key)
safe_name = _html.escape(file_name, quote=True) if saved_path_str:
st.markdown( st.success(f"✓ Saved to `{saved_path_str}`")
f'<a download="{safe_name}" href="data:{mime};base64,{b64}"' if st.button(
f'{title_attr} style="{base_style}">{safe_label}</a>', "📂 Open Downloads folder",
unsafe_allow_html=True, key=open_key,
) type="secondary",
):
_open_in_file_manager(Path(saved_path_str).parent)
# Back-compat alias: existing call sites use the old name. New code
# should prefer ``local_download_button``.
html_download_button = local_download_button
def back_to_home_link(*, key: str = "_back_to_home_link") -> None: def back_to_home_link(*, key: str = "_back_to_home_link") -> None: