feat(gui): one-click Close in its own bottom sidebar section
Close is now a direct shutdown trigger: visiting the Close page (the sidebar entry) fires shutdown_app() immediately — no confirm step, no intermediate body. The farewell overlay paints and os._exit(0) lands ~1s later from a daemon thread. Layout: Close moved into its own bottom-of-sidebar section so the destructive action is visually separated from Account/Activate. - New shutdown_app() in components/_legacy.py replaces quit_button. os._exit thread is skipped when "pytest" is in sys.modules so the test suite doesn't suicide on rendering 99_Close. - pages/99_Close.py shrinks to set_page_config + chrome + shutdown_app. - app.py nav grows a new "Close" section header (new nav.section_close key in en/es packs) pinned at the bottom of the navigation dict. Tests updated: - TestQuitButtonRenders → TestClosePageShutsDownImmediately. Assert the shutdown caption renders + no confirm button exists. - test_smoke EXPECTED_SUBSTRINGS["99_Close"] now pins "Shutting down" / "Cerrando" (the visible page body) instead of the removed page title. 2008 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -198,12 +198,17 @@ def _build_navigation() -> dict[str, list]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
account_header = _t("nav.section_account") or "Account"
|
account_header = _t("nav.section_account") or "Account"
|
||||||
|
close_header = _t("nav.section_close") or "Close"
|
||||||
|
# Close lives in its own section pinned at the very bottom of the
|
||||||
|
# sidebar — its own click is the shutdown, so we visually separate
|
||||||
|
# it from the navigable pages above to reduce mis-click risk.
|
||||||
return {
|
return {
|
||||||
"": [home],
|
"": [home],
|
||||||
section_label("cleaners"): by_section["cleaners"],
|
section_label("cleaners"): by_section["cleaners"],
|
||||||
section_label("transformations"): by_section["transformations"],
|
section_label("transformations"): by_section["transformations"],
|
||||||
section_label("automations"): by_section["automations"],
|
section_label("automations"): by_section["automations"],
|
||||||
account_header: [activate, close],
|
account_header: [activate],
|
||||||
|
close_header: [close],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ from .activation import ( # noqa: F401 re-exported
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
# Shared chrome / pickup
|
# Shared chrome / pickup
|
||||||
"hide_streamlit_chrome",
|
"hide_streamlit_chrome",
|
||||||
"quit_button",
|
"shutdown_app",
|
||||||
"pickup_or_upload",
|
"pickup_or_upload",
|
||||||
# License gate + activation form
|
# License gate + activation form
|
||||||
"render_activation_form",
|
"render_activation_form",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -186,37 +187,38 @@ def _farewell_script() -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def quit_button(label: str | None = None, *, key: str = "quit_app_button") -> None:
|
def shutdown_app() -> None:
|
||||||
"""Render a Quit button that terminates the Streamlit server.
|
"""Terminate the Streamlit server immediately, no confirm.
|
||||||
|
|
||||||
|
Designed to be called from a page whose mere act of rendering means
|
||||||
|
the user wants to quit (e.g., the sidebar Close entry). Schedules
|
||||||
|
``os._exit(0)`` on a daemon thread so the process terminates after
|
||||||
|
the farewell overlay has had a chance to paint, then injects the
|
||||||
|
overlay JS and short-circuits the rest of the page via ``st.stop``.
|
||||||
|
|
||||||
Streamlit has no first-class shutdown hook, and signalling the
|
Streamlit has no first-class shutdown hook, and signalling the
|
||||||
process (SIGTERM/SIGINT) does not reliably terminate it — Streamlit
|
process (SIGTERM/SIGINT) does not reliably terminate it — Streamlit
|
||||||
installs its own handlers and the tornado/asyncio loop swallows or
|
installs its own handlers and the tornado/asyncio loop swallows or
|
||||||
defers the signal, so the browser sees the websocket drop while the
|
defers the signal, so the browser sees the websocket drop while the
|
||||||
python process stays alive. We schedule ``os._exit(0)`` on a daemon
|
python process stays alive. ``os._exit`` is the only reliable kill.
|
||||||
thread to hard-kill the process, and inject a small JS shim that
|
|
||||||
either closes the tab (when allowed) or replaces the top frame with
|
The hard-exit thread is skipped under pytest so the test suite does
|
||||||
a self-contained "App closed" page so the user never sees
|
not suicide when a test renders this page. The overlay + caption
|
||||||
Streamlit's red connection-error overlay.
|
still render so test assertions about content work.
|
||||||
"""
|
"""
|
||||||
if label is None:
|
if not st.session_state.get("_app_shutting_down"):
|
||||||
label = _t("quit.button")
|
|
||||||
|
|
||||||
if st.session_state.get("_app_shutting_down"):
|
|
||||||
from streamlit.components.v1 import html as _components_html
|
|
||||||
_components_html(_farewell_script(), height=0)
|
|
||||||
st.success(_t("quit.shutting_down"))
|
|
||||||
st.stop()
|
|
||||||
|
|
||||||
if st.button(label, key=key, type="secondary"):
|
|
||||||
st.session_state["_app_shutting_down"] = True
|
st.session_state["_app_shutting_down"] = True
|
||||||
|
if "pytest" not in sys.modules:
|
||||||
|
def _hard_exit() -> None:
|
||||||
|
time.sleep(1.0)
|
||||||
|
os._exit(0)
|
||||||
|
|
||||||
def _hard_exit() -> None:
|
threading.Thread(target=_hard_exit, daemon=True).start()
|
||||||
time.sleep(1.0)
|
|
||||||
os._exit(0)
|
|
||||||
|
|
||||||
threading.Thread(target=_hard_exit, daemon=True).start()
|
from streamlit.components.v1 import html as _components_html
|
||||||
st.rerun()
|
_components_html(_farewell_script(), height=0)
|
||||||
|
st.success(_t("quit.shutting_down"))
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""Close — shut the DataTools server down cleanly.
|
"""Close — shut the DataTools server down immediately on visit.
|
||||||
|
|
||||||
Lives in the sidebar nav alongside the tool pages so users have a
|
Sidebar nav entry. Clicking it routes here, and the page's render
|
||||||
discoverable way to terminate the local Streamlit process without
|
fires :func:`shutdown_app` directly — there is no confirm step. The
|
||||||
having to Ctrl+C in the shell. An explicit confirm step prevents an
|
browser sees the farewell overlay; the Python process terminates a
|
||||||
accidental sidebar click from killing a session mid-work.
|
moment later via ``os._exit``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -17,7 +17,7 @@ _project_root = Path(__file__).resolve().parent.parent.parent.parent
|
|||||||
if str(_project_root) not in sys.path:
|
if str(_project_root) not in sys.path:
|
||||||
sys.path.insert(0, str(_project_root))
|
sys.path.insert(0, str(_project_root))
|
||||||
|
|
||||||
from src.gui.components import hide_streamlit_chrome, quit_button
|
from src.gui.components import hide_streamlit_chrome, shutdown_app
|
||||||
from src.i18n import t
|
from src.i18n import t
|
||||||
|
|
||||||
st.set_page_config(
|
st.set_page_config(
|
||||||
@@ -25,14 +25,6 @@ st.set_page_config(
|
|||||||
page_icon="🛑",
|
page_icon="🛑",
|
||||||
layout="wide",
|
layout="wide",
|
||||||
)
|
)
|
||||||
|
|
||||||
hide_streamlit_chrome()
|
hide_streamlit_chrome()
|
||||||
|
|
||||||
st.title(t("close_page.title"))
|
shutdown_app()
|
||||||
st.caption(t("close_page.caption"))
|
|
||||||
st.divider()
|
|
||||||
|
|
||||||
st.markdown(t("close_page.body"))
|
|
||||||
|
|
||||||
st.write("")
|
|
||||||
quit_button(label=t("close_page.button"), key="quit_app_button_page")
|
|
||||||
|
|||||||
@@ -148,6 +148,7 @@
|
|||||||
"home_page_title": "Home",
|
"home_page_title": "Home",
|
||||||
"section_account": "Account",
|
"section_account": "Account",
|
||||||
"activate_title": "Activate",
|
"activate_title": "Activate",
|
||||||
"close_title": "Close"
|
"close_title": "Close",
|
||||||
|
"section_close": "Close"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,7 @@
|
|||||||
"home_page_title": "Inicio",
|
"home_page_title": "Inicio",
|
||||||
"section_account": "Cuenta",
|
"section_account": "Cuenta",
|
||||||
"activate_title": "Activar",
|
"activate_title": "Activar",
|
||||||
"close_title": "Cerrar"
|
"close_title": "Cerrar",
|
||||||
|
"section_close": "Cerrar"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,38 +122,33 @@ class TestLocalizedChrome:
|
|||||||
# Quit / Close page
|
# Quit / Close page
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestQuitButtonRenders:
|
class TestClosePageShutsDownImmediately:
|
||||||
"""The Close page must show the localized title, body, and the
|
"""The Close page no longer renders a confirm button — visiting the
|
||||||
Close-the-app button. We don't actually click the button — that
|
page IS the close action. Under pytest the ``os._exit`` thread is
|
||||||
would call ``os._exit(0)`` and kill the test process. We only
|
skipped (so the test runner doesn't suicide), but the farewell
|
||||||
assert the button is present and its label is localized."""
|
success caption still renders and we assert against it."""
|
||||||
|
|
||||||
def test_close_page_english(self, app_factory):
|
def test_english_close_renders_shutdown_message(self, app_factory):
|
||||||
app = app_factory("99_Close")
|
app = app_factory("99_Close")
|
||||||
app.run()
|
app.run()
|
||||||
text = collected_text(app)
|
text = collected_text(app)
|
||||||
assert "Close DataTools" in text
|
assert "Shutting down" in text or "Goodbye" in text, (
|
||||||
|
f"Close page missing shutdown caption; got:\n{text[:300]}"
|
||||||
|
)
|
||||||
|
# No confirm button — the page is the action.
|
||||||
labels = [b.label for b in app.button]
|
labels = [b.label for b in app.button]
|
||||||
assert any("Close the app" in lbl for lbl in labels), (
|
assert not any("Close the app" in lbl for lbl in labels), (
|
||||||
f"Close-the-app button missing; buttons: {labels}"
|
f"Close page should not render a confirm button; got: {labels}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_close_page_spanish(self, app_factory):
|
def test_spanish_close_renders_localized_shutdown_message(self, app_factory):
|
||||||
app = app_factory("99_Close")
|
app = app_factory("99_Close")
|
||||||
with_language(app, "es")
|
with_language(app, "es")
|
||||||
app.run()
|
app.run()
|
||||||
text = collected_text(app)
|
text = collected_text(app)
|
||||||
assert "Cerrar DataTools" in text
|
# ``quit.shutting_down`` in the es pack.
|
||||||
labels = [b.label for b in app.button]
|
assert "Cerrando" in text or "Apagando" in text or "Adiós" in text, (
|
||||||
assert any("Cerrar la app" in lbl for lbl in labels), (
|
f"Spanish Close page missing localized shutdown caption; got:\n{text[:300]}"
|
||||||
f"Spanish Close button missing; buttons: {labels}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_close_body_describes_unsaved_work_warning_es(self, app_factory):
|
|
||||||
app = app_factory("99_Close")
|
|
||||||
with_language(app, "es")
|
|
||||||
app.run()
|
|
||||||
text = collected_text(app)
|
|
||||||
assert "trabajo sin guardar" in text
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ EXPECTED_SUBSTRINGS: dict[str, dict[str, str]] = {
|
|||||||
"7_Multi_File_Merger": {"en": "Combine Files", "es": "Combine Files"},
|
"7_Multi_File_Merger": {"en": "Combine Files", "es": "Combine Files"},
|
||||||
"8_Validator_Reporter": {"en": "Quality Check", "es": "Quality Check"},
|
"8_Validator_Reporter": {"en": "Quality Check", "es": "Quality Check"},
|
||||||
"9_Pipeline_Runner": {"en": "Automated", "es": "Automated"},
|
"9_Pipeline_Runner": {"en": "Automated", "es": "Automated"},
|
||||||
"99_Close": {"en": "Close DataTools", "es": "Cerrar DataTools"},
|
"99_Close": {"en": "Shutting down", "es": "Cerrando"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user