diff --git a/src/gui/_home.py b/src/gui/_home.py new file mode 100644 index 0000000..34e6548 --- /dev/null +++ b/src/gui/_home.py @@ -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 " 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")) diff --git a/src/gui/app.py b/src/gui/app.py index f4f04f9..f7dfe18 100644 --- a/src/gui/app.py +++ b/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) # --------------------------------------------------------------------------- +# +# 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: - """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 " 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")) +from src.gui._home import _home_page # noqa: E402 # --------------------------------------------------------------------------- diff --git a/src/gui/components/_legacy.py b/src/gui/components/_legacy.py index 1cdb2d1..5acac17 100644 --- a/src/gui/components/_legacy.py +++ b/src/gui/components/_legacy.py @@ -410,22 +410,33 @@ html_download_button = local_download_button 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 explicit return-to-home control so a user working through findings 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 - entry script which, under ``st.navigation``, lands on the default - page (Home). Streamlit's button is used (rather than ``st.page_link``) - because the entry script is a navigation manager, not a registered - Page object, and ``page_link`` to ``app.py`` renders inconsistently - across Streamlit minor versions. + Implementation: ``st.switch_page`` under ``st.navigation`` requires + either a file path to a page in ``pages/`` or a ``StreamlitPage`` + object whose script identity matches one registered in the nav. + The entry script ``app.py`` is the nav manager itself โ€” it cannot + be switched-to by filename. So we import the home callable from + ``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"): - 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: diff --git a/src/gui/pages/1_Deduplicator.py b/src/gui/pages/1_Deduplicator.py index 00f2eb8..aa0ae47 100644 --- a/src/gui/pages/1_Deduplicator.py +++ b/src/gui/pages/1_Deduplicator.py @@ -399,6 +399,8 @@ else: # Footer # --------------------------------------------------------------------------- +back_to_home_link(key="_back_to_home_link_bottom") + st.divider() st.caption( "Runs locally. Your data never leaves this computer. " diff --git a/src/gui/pages/2_Text_Cleaner.py b/src/gui/pages/2_Text_Cleaner.py index 779db2c..972daad 100644 --- a/src/gui/pages/2_Text_Cleaner.py +++ b/src/gui/pages/2_Text_Cleaner.py @@ -379,6 +379,8 @@ with dl_c: mime="application/json", ) +back_to_home_link(key="_back_to_home_link_bottom") + st.divider() st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0") diff --git a/src/gui/pages/3_Format_Standardizer.py b/src/gui/pages/3_Format_Standardizer.py index dda5741..7386167 100644 --- a/src/gui/pages/3_Format_Standardizer.py +++ b/src/gui/pages/3_Format_Standardizer.py @@ -650,6 +650,8 @@ with dl_c: mime="application/json", ) +back_to_home_link(key="_back_to_home_link_bottom") + st.divider() st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0") diff --git a/src/gui/pages/4_Missing_Values.py b/src/gui/pages/4_Missing_Values.py index f499f59..44470af 100644 --- a/src/gui/pages/4_Missing_Values.py +++ b/src/gui/pages/4_Missing_Values.py @@ -412,6 +412,8 @@ with dl_c: mime="application/json", ) +back_to_home_link(key="_back_to_home_link_bottom") + st.divider() st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0") diff --git a/src/gui/pages/5_Column_Mapper.py b/src/gui/pages/5_Column_Mapper.py index 47f578b..58eee55 100644 --- a/src/gui/pages/5_Column_Mapper.py +++ b/src/gui/pages/5_Column_Mapper.py @@ -456,6 +456,8 @@ with dl_c: mime="application/json", ) +back_to_home_link(key="_back_to_home_link_bottom") + st.divider() st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0") diff --git a/src/gui/pages/6_Outlier_Detector.py b/src/gui/pages/6_Outlier_Detector.py index 06897e4..18a1391 100644 --- a/src/gui/pages/6_Outlier_Detector.py +++ b/src/gui/pages/6_Outlier_Detector.py @@ -96,6 +96,8 @@ st.button("Detect Outliers", type="primary", use_container_width=True, disabled= # Footer # --------------------------------------------------------------------------- +back_to_home_link(key="_back_to_home_link_bottom") + st.divider() st.caption( "Runs locally. Your data never leaves this computer. " diff --git a/src/gui/pages/7_Multi_File_Merger.py b/src/gui/pages/7_Multi_File_Merger.py index f957fac..d84075f 100644 --- a/src/gui/pages/7_Multi_File_Merger.py +++ b/src/gui/pages/7_Multi_File_Merger.py @@ -94,6 +94,8 @@ st.button("Merge Files", type="primary", use_container_width=True, disabled=True # Footer # --------------------------------------------------------------------------- +back_to_home_link(key="_back_to_home_link_bottom") + st.divider() st.caption( "Runs locally. Your data never leaves this computer. " diff --git a/src/gui/pages/8_Validator_Reporter.py b/src/gui/pages/8_Validator_Reporter.py index 929866a..43d891c 100644 --- a/src/gui/pages/8_Validator_Reporter.py +++ b/src/gui/pages/8_Validator_Reporter.py @@ -101,6 +101,8 @@ st.button("Validate & Generate Report", type="primary", use_container_width=True # Footer # --------------------------------------------------------------------------- +back_to_home_link(key="_back_to_home_link_bottom") + st.divider() st.caption( "Runs locally. Your data never leaves this computer. " diff --git a/src/gui/pages/9_Pipeline_Runner.py b/src/gui/pages/9_Pipeline_Runner.py index 17ea272..8d2bce8 100644 --- a/src/gui/pages/9_Pipeline_Runner.py +++ b/src/gui/pages/9_Pipeline_Runner.py @@ -414,6 +414,8 @@ with dl_c: mime="application/json", ) +back_to_home_link(key="_back_to_home_link_bottom") + st.divider() st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")