fix(nav): switch_page resolves correctly + bottom-of-page back link
Two issues, same fix surface.
(1) Reported crash on Back-to-Home:
StreamlitAPIException: Could not find page: app.py.
``st.switch_page("app.py")`` doesn't work under ``st.navigation`` —
the entry script is the nav manager itself and is not a registered
page. The fix needs to pass an ``st.Page`` object whose script
identity matches one registered in the nav.
First-pass attempt (``from src.gui.app import _home_page``) hit a
worse failure: importing ``app.py`` from inside a tool-page render
re-executes the nav setup with the WRONG "main script" context, so
every ``st.Page("pages/N_foo.py", ...)`` call in ``_build_navigation``
fails with "file could not be found".
Extract the home renderer into its own module ``src/gui/_home.py``
which has no top-level Streamlit side effects. Both the nav manager
and the back-link helper import ``_home_page`` from there. The Page
object built at click time has the same callable identity as the one
registered, so ``st.switch_page`` resolves it.
(2) Reported UX: the back button scrolled out of view on long pages.
Add a second ``back_to_home_link(key="_back_to_home_link_bottom")``
call near the footer of every tool page (1-9). The unique key avoids
widget-id collision with the top instance. Coming-Soon stubs get it
unconditionally; Ready tools render it only after a result exists
because the page short-circuits with ``st.stop()`` before then —
when no result is on screen the page is short enough that the top
link is sufficient.
2220 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
131
src/gui/_home.py
Normal file
131
src/gui/_home.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""Home-page renderer extracted into its own module.
|
||||||
|
|
||||||
|
This used to live inside ``src/gui/app.py`` as a local function. Pulling
|
||||||
|
it out into a side-effect-free module lets the ``back_to_home_link``
|
||||||
|
helper (in ``components/_legacy.py``) import the home callable to pass
|
||||||
|
into ``st.switch_page`` — without re-running ``app.py``'s navigation
|
||||||
|
setup, which would itself blow up because tool pages have a different
|
||||||
|
"main script" context that breaks the registry's relative ``pages/…``
|
||||||
|
paths.
|
||||||
|
|
||||||
|
Keep this module imports-light: nothing that runs Streamlit commands
|
||||||
|
at module top level, nothing that triggers config loads. Just the
|
||||||
|
``_home_page`` callable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
|
||||||
|
def _home_page() -> None:
|
||||||
|
"""Render the home page — multi-file upload + per-file analysis."""
|
||||||
|
from src.gui.components import hide_streamlit_chrome, render_findings_panel
|
||||||
|
from src.gui.components._legacy import _run_analysis_on_upload
|
||||||
|
from src.i18n import t
|
||||||
|
|
||||||
|
st.set_page_config(
|
||||||
|
page_title=t("home.page_title"),
|
||||||
|
page_icon="🧹",
|
||||||
|
layout="wide",
|
||||||
|
)
|
||||||
|
hide_streamlit_chrome()
|
||||||
|
|
||||||
|
st.title(t("home.title"))
|
||||||
|
st.caption(t("home.caption"))
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
st.markdown(f"### {t('upload.heading')}")
|
||||||
|
st.caption(t("upload.intro_multi"))
|
||||||
|
|
||||||
|
uploaded_files = st.file_uploader(
|
||||||
|
t("upload.uploader_label_multi"),
|
||||||
|
type=["csv", "tsv", "xlsx", "xls"],
|
||||||
|
accept_multiple_files=True,
|
||||||
|
key="home_upload",
|
||||||
|
help=t("upload.uploader_help"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not uploaded_files:
|
||||||
|
st.info(t("upload.empty_state"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Keep tool pages working: they consume a single ``home_uploaded_*``
|
||||||
|
# set via ``pickup_or_upload``. Expose the first uploaded file as
|
||||||
|
# the "active" upload for that contract; the rest live alongside
|
||||||
|
# for per-file analysis on this page.
|
||||||
|
first = uploaded_files[0]
|
||||||
|
if (
|
||||||
|
st.session_state.get("home_uploaded_name") != first.name
|
||||||
|
or st.session_state.get("home_uploaded_size") != first.size
|
||||||
|
):
|
||||||
|
st.session_state["home_uploaded_name"] = first.name
|
||||||
|
st.session_state["home_uploaded_size"] = first.size
|
||||||
|
st.session_state["home_uploaded_bytes"] = first.getvalue()
|
||||||
|
|
||||||
|
# Per-file findings live in a dict so removing a file from the
|
||||||
|
# uploader (Streamlit's "x" button) drops its results too. We only
|
||||||
|
# re-analyze files we haven't already analyzed in this session.
|
||||||
|
findings_by_file: dict = st.session_state.setdefault(
|
||||||
|
"home_findings_by_file", {}
|
||||||
|
)
|
||||||
|
current_names = {f.name for f in uploaded_files}
|
||||||
|
findings_by_file = {
|
||||||
|
name: result for name, result in findings_by_file.items()
|
||||||
|
if name in current_names
|
||||||
|
}
|
||||||
|
st.session_state["home_findings_by_file"] = findings_by_file
|
||||||
|
|
||||||
|
pending = [f for f in uploaded_files if f.name not in findings_by_file]
|
||||||
|
|
||||||
|
col_run, col_clear, _ = st.columns([1, 1, 4])
|
||||||
|
with col_run:
|
||||||
|
run_clicked = st.button(
|
||||||
|
t("upload.run_button"),
|
||||||
|
type="primary",
|
||||||
|
key="home_run_analysis",
|
||||||
|
disabled=not pending,
|
||||||
|
use_container_width=True,
|
||||||
|
)
|
||||||
|
with col_clear:
|
||||||
|
clear_clicked = st.button(
|
||||||
|
t("upload.clear_results"),
|
||||||
|
key="home_clear_results",
|
||||||
|
disabled=not findings_by_file,
|
||||||
|
use_container_width=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if clear_clicked:
|
||||||
|
st.session_state["home_findings_by_file"] = {}
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
if run_clicked:
|
||||||
|
progress = st.progress(0.0, text=t("upload.scanning"))
|
||||||
|
for i, f in enumerate(pending, start=1):
|
||||||
|
findings_by_file[f.name] = _run_analysis_on_upload(f)
|
||||||
|
progress.progress(i / len(pending), text=f"{f.name}")
|
||||||
|
st.session_state["home_findings_by_file"] = findings_by_file
|
||||||
|
progress.empty()
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
if findings_by_file:
|
||||||
|
st.divider()
|
||||||
|
# Preserve uploader order so the user sees results in the same
|
||||||
|
# order they appear in the file list above. Each file's findings
|
||||||
|
# render via ``render_findings_panel`` so the per-tool grouping
|
||||||
|
# (and the "Open <Tool>" jump link under each group) is kept —
|
||||||
|
# that's how the user reaches the cleaner that fixes a specific
|
||||||
|
# finding without hunting through the sidebar.
|
||||||
|
for f in uploaded_files:
|
||||||
|
if f.name not in findings_by_file:
|
||||||
|
continue
|
||||||
|
findings = findings_by_file[f.name]
|
||||||
|
with st.container(border=True):
|
||||||
|
if not findings:
|
||||||
|
st.markdown(f"### 📄 {f.name}")
|
||||||
|
st.success(t("findings.none"))
|
||||||
|
else:
|
||||||
|
render_findings_panel(findings, header=f"📄 {f.name}")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
st.caption(t("chrome.footer"))
|
||||||
119
src/gui/app.py
119
src/gui/app.py
@@ -28,118 +28,15 @@ if str(_project_root) not in sys.path:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Home page (rendered when the user selects the default nav entry)
|
# Home page (rendered when the user selects the default nav entry)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The renderer lives in ``src/gui/_home.py`` so the ``back_to_home_link``
|
||||||
|
# helper on tool pages can ``import _home_page`` and pass it into
|
||||||
|
# ``st.switch_page`` without re-executing this entry script's
|
||||||
|
# navigation setup (which would crash because tool pages have a
|
||||||
|
# different "main script" context and the relative ``pages/…`` paths
|
||||||
|
# below would no longer resolve).
|
||||||
|
|
||||||
def _home_page() -> None:
|
from src.gui._home import _home_page # noqa: E402
|
||||||
"""Render the home page — multi-file upload + per-file analysis."""
|
|
||||||
from src.gui.components import hide_streamlit_chrome, render_findings_panel
|
|
||||||
from src.gui.components._legacy import _run_analysis_on_upload
|
|
||||||
from src.i18n import t
|
|
||||||
|
|
||||||
st.set_page_config(
|
|
||||||
page_title=t("home.page_title"),
|
|
||||||
page_icon="🧹",
|
|
||||||
layout="wide",
|
|
||||||
)
|
|
||||||
hide_streamlit_chrome()
|
|
||||||
|
|
||||||
st.title(t("home.title"))
|
|
||||||
st.caption(t("home.caption"))
|
|
||||||
st.divider()
|
|
||||||
|
|
||||||
st.markdown(f"### {t('upload.heading')}")
|
|
||||||
st.caption(t("upload.intro_multi"))
|
|
||||||
|
|
||||||
uploaded_files = st.file_uploader(
|
|
||||||
t("upload.uploader_label_multi"),
|
|
||||||
type=["csv", "tsv", "xlsx", "xls"],
|
|
||||||
accept_multiple_files=True,
|
|
||||||
key="home_upload",
|
|
||||||
help=t("upload.uploader_help"),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not uploaded_files:
|
|
||||||
st.info(t("upload.empty_state"))
|
|
||||||
return
|
|
||||||
|
|
||||||
# Keep tool pages working: they consume a single ``home_uploaded_*``
|
|
||||||
# set via ``pickup_or_upload``. Expose the first uploaded file as
|
|
||||||
# the "active" upload for that contract; the rest live alongside
|
|
||||||
# for per-file analysis on this page.
|
|
||||||
first = uploaded_files[0]
|
|
||||||
if (
|
|
||||||
st.session_state.get("home_uploaded_name") != first.name
|
|
||||||
or st.session_state.get("home_uploaded_size") != first.size
|
|
||||||
):
|
|
||||||
st.session_state["home_uploaded_name"] = first.name
|
|
||||||
st.session_state["home_uploaded_size"] = first.size
|
|
||||||
st.session_state["home_uploaded_bytes"] = first.getvalue()
|
|
||||||
|
|
||||||
# Per-file findings live in a dict so removing a file from the
|
|
||||||
# uploader (Streamlit's "x" button) drops its results too. We only
|
|
||||||
# re-analyze files we haven't already analyzed in this session.
|
|
||||||
findings_by_file: dict = st.session_state.setdefault(
|
|
||||||
"home_findings_by_file", {}
|
|
||||||
)
|
|
||||||
current_names = {f.name for f in uploaded_files}
|
|
||||||
findings_by_file = {
|
|
||||||
name: result for name, result in findings_by_file.items()
|
|
||||||
if name in current_names
|
|
||||||
}
|
|
||||||
st.session_state["home_findings_by_file"] = findings_by_file
|
|
||||||
|
|
||||||
pending = [f for f in uploaded_files if f.name not in findings_by_file]
|
|
||||||
|
|
||||||
col_run, col_clear, _ = st.columns([1, 1, 4])
|
|
||||||
with col_run:
|
|
||||||
run_clicked = st.button(
|
|
||||||
t("upload.run_button"),
|
|
||||||
type="primary",
|
|
||||||
key="home_run_analysis",
|
|
||||||
disabled=not pending,
|
|
||||||
use_container_width=True,
|
|
||||||
)
|
|
||||||
with col_clear:
|
|
||||||
clear_clicked = st.button(
|
|
||||||
t("upload.clear_results"),
|
|
||||||
key="home_clear_results",
|
|
||||||
disabled=not findings_by_file,
|
|
||||||
use_container_width=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if clear_clicked:
|
|
||||||
st.session_state["home_findings_by_file"] = {}
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
if run_clicked:
|
|
||||||
progress = st.progress(0.0, text=t("upload.scanning"))
|
|
||||||
for i, f in enumerate(pending, start=1):
|
|
||||||
findings_by_file[f.name] = _run_analysis_on_upload(f)
|
|
||||||
progress.progress(i / len(pending), text=f"{f.name}")
|
|
||||||
st.session_state["home_findings_by_file"] = findings_by_file
|
|
||||||
progress.empty()
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
if findings_by_file:
|
|
||||||
st.divider()
|
|
||||||
# Preserve uploader order so the user sees results in the same
|
|
||||||
# order they appear in the file list above. Each file's findings
|
|
||||||
# render via ``render_findings_panel`` so the per-tool grouping
|
|
||||||
# (and the "Open <Tool>" jump link under each group) is kept —
|
|
||||||
# that's how the user reaches the cleaner that fixes a specific
|
|
||||||
# finding without hunting through the sidebar.
|
|
||||||
for f in uploaded_files:
|
|
||||||
if f.name not in findings_by_file:
|
|
||||||
continue
|
|
||||||
findings = findings_by_file[f.name]
|
|
||||||
with st.container(border=True):
|
|
||||||
if not findings:
|
|
||||||
st.markdown(f"### 📄 {f.name}")
|
|
||||||
st.success(t("findings.none"))
|
|
||||||
else:
|
|
||||||
render_findings_panel(findings, header=f"📄 {f.name}")
|
|
||||||
|
|
||||||
st.divider()
|
|
||||||
st.caption(t("chrome.footer"))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -410,22 +410,33 @@ 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:
|
||||||
"""Render a small "← Back to Home" affordance near the top of a tool page.
|
"""Render a "← Back to Home" affordance on a tool page.
|
||||||
|
|
||||||
Tool pages reached from the home findings panel benefit from an
|
Tool pages reached from the home findings panel benefit from an
|
||||||
explicit return-to-home control so a user working through findings
|
explicit return-to-home control so a user working through findings
|
||||||
on multiple uploaded files can hop between files without hunting
|
on multiple uploaded files can hop between files without hunting
|
||||||
through the sidebar.
|
through the sidebar. Call this twice on each tool page — once
|
||||||
|
near the top (default key) and once at the bottom with
|
||||||
|
``key="_back_to_home_link_bottom"`` so the control stays reachable
|
||||||
|
after the user scrolls through long results.
|
||||||
|
|
||||||
Implementation note: ``st.switch_page("app.py")`` routes back to the
|
Implementation: ``st.switch_page`` under ``st.navigation`` requires
|
||||||
entry script which, under ``st.navigation``, lands on the default
|
either a file path to a page in ``pages/`` or a ``StreamlitPage``
|
||||||
page (Home). Streamlit's button is used (rather than ``st.page_link``)
|
object whose script identity matches one registered in the nav.
|
||||||
because the entry script is a navigation manager, not a registered
|
The entry script ``app.py`` is the nav manager itself — it cannot
|
||||||
Page object, and ``page_link`` to ``app.py`` renders inconsistently
|
be switched-to by filename. So we import the home callable from
|
||||||
across Streamlit minor versions.
|
``src.gui.app`` and rebuild the same ``st.Page`` registration here.
|
||||||
|
Streamlit identifies pages by the underlying callable's qualified
|
||||||
|
name, so a freshly-constructed Page resolves to the registered one.
|
||||||
"""
|
"""
|
||||||
if st.button(_t("nav.back_to_home"), key=key, type="secondary"):
|
if st.button(_t("nav.back_to_home"), key=key, type="secondary"):
|
||||||
st.switch_page("app.py")
|
# Import from the renderer module (not from app.py — importing
|
||||||
|
# app.py would re-execute its navigation setup with the wrong
|
||||||
|
# "main script" context and blow up the pages/ path resolution).
|
||||||
|
from src.gui._home import _home_page
|
||||||
|
st.switch_page(
|
||||||
|
st.Page(_home_page, title="Home", icon="🧹", url_path="home"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def shutdown_app() -> None:
|
def shutdown_app() -> None:
|
||||||
|
|||||||
@@ -399,6 +399,8 @@ else:
|
|||||||
# Footer
|
# Footer
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
back_to_home_link(key="_back_to_home_link_bottom")
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
st.caption(
|
st.caption(
|
||||||
"Runs locally. Your data never leaves this computer. "
|
"Runs locally. Your data never leaves this computer. "
|
||||||
|
|||||||
@@ -379,6 +379,8 @@ with dl_c:
|
|||||||
mime="application/json",
|
mime="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
back_to_home_link(key="_back_to_home_link_bottom")
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")
|
st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")
|
||||||
|
|
||||||
|
|||||||
@@ -650,6 +650,8 @@ with dl_c:
|
|||||||
mime="application/json",
|
mime="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
back_to_home_link(key="_back_to_home_link_bottom")
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")
|
st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")
|
||||||
|
|
||||||
|
|||||||
@@ -412,6 +412,8 @@ with dl_c:
|
|||||||
mime="application/json",
|
mime="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
back_to_home_link(key="_back_to_home_link_bottom")
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")
|
st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")
|
||||||
|
|
||||||
|
|||||||
@@ -456,6 +456,8 @@ with dl_c:
|
|||||||
mime="application/json",
|
mime="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
back_to_home_link(key="_back_to_home_link_bottom")
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")
|
st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ st.button("Detect Outliers", type="primary", use_container_width=True, disabled=
|
|||||||
# Footer
|
# Footer
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
back_to_home_link(key="_back_to_home_link_bottom")
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
st.caption(
|
st.caption(
|
||||||
"Runs locally. Your data never leaves this computer. "
|
"Runs locally. Your data never leaves this computer. "
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ st.button("Merge Files", type="primary", use_container_width=True, disabled=True
|
|||||||
# Footer
|
# Footer
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
back_to_home_link(key="_back_to_home_link_bottom")
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
st.caption(
|
st.caption(
|
||||||
"Runs locally. Your data never leaves this computer. "
|
"Runs locally. Your data never leaves this computer. "
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ st.button("Validate & Generate Report", type="primary", use_container_width=True
|
|||||||
# Footer
|
# Footer
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
back_to_home_link(key="_back_to_home_link_bottom")
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
st.caption(
|
st.caption(
|
||||||
"Runs locally. Your data never leaves this computer. "
|
"Runs locally. Your data never leaves this computer. "
|
||||||
|
|||||||
@@ -414,6 +414,8 @@ with dl_c:
|
|||||||
mime="application/json",
|
mime="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
back_to_home_link(key="_back_to_home_link_bottom")
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")
|
st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user