Coverage for scanpath_studio/crash_report.py: 100%
32 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-07 21:10 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-07 21:10 +0000
1"""What the user sees when the app itself breaks: an error that asks them to tell us.
3Streamlit's own handler draws a bare traceback, which reads as the user's
4problem to solve. :func:`run_app` runs the whole script — the import of
5``app.py`` included, so an error at import time is caught too — inside
6:func:`guarded`, which draws a short note saying this is a bug in the app and
7where to report it, followed by the same traceback (with its *Copy* button) for
8the report.
10A fragment or dialog that reruns on its own is called by Streamlit directly,
11outside that run, so every ``@st.dialog`` / ``@st.fragment`` in the package also
12carries ``@guarded()`` (pinned by ``tests/test_crash_report.py``). Widget
13callbacks are **not** covered: Streamlit runs them before the script starts, and
14an error there still shows Streamlit's bare traceback.
16Streamlit's control flow (``st.rerun``, ``st.stop``) raises ``BaseException``
17subclasses, so it passes through untouched, and so does
18``FragmentHandledException`` — a fragment whose error Streamlit has already
19drawn. Streamlit 1.65's ``on_script_error`` hook covers callbacks too, but only
20when the app is served as an ``st.App`` (a different, Starlette-based server),
21which none of our surfaces is.
23This module imports only the standard library, Streamlit and ``constants`` —
24itself stdlib-only — so it still loads when the module that failed is one of
25ours.
26"""
28from __future__ import annotations
30import logging
31from collections.abc import Iterator
32from contextlib import contextmanager
33from urllib.parse import urlencode
35import streamlit as st
36from streamlit.errors import FragmentHandledException
38from scanpath_studio.constants import CITATION, ICONS
40_LOGGER = logging.getLogger(__name__)
43def report_url(error: BaseException) -> str:
44 """The bug-report form, its title pre-filled with the error type and version.
46 Only the exception's *type* goes into the URL — its message and traceback
47 can carry file paths or values from the user's data, so those are left for
48 the user to paste from the details below.
49 """
50 from scanpath_studio import __version__
52 title = f"Crash: {type(error).__name__} (v{__version__})"
53 return f"{CITATION['bug_report_url']}&{urlencode({'title': title})}"
56def crash_message(error: BaseException) -> str:
57 """The markdown shown above the traceback."""
58 from scanpath_studio import __version__
60 return (
61 "**Scanpath Studio ran into an unexpected error.** This is most likely "
62 "a bug in the app, not something you did — please let us know so we "
63 "can fix it.\n\n"
64 f"{ICONS['bug']} [Report this bug]({report_url(error)}) ↗ and paste the "
65 "error details below (use their **Copy** button), with what you were "
66 "doing when it happened and your operating system. "
67 f"{ICONS['question']} Questions are welcome on "
68 f"[Discussions → Q&A]({CITATION['questions_url']}) ↗.\n\n"
69 f"Version {__version__}. Reloading the page usually gets you going again."
70 )
73def show_crash(error: Exception) -> None:
74 """Log ``error`` to the server terminal and draw the report-it note + traceback."""
75 _LOGGER.exception("Uncaught error in the app script", exc_info=error)
76 st.error(crash_message(error), icon=ICONS["error"])
77 st.exception(error)
80@contextmanager
81def guarded() -> Iterator[None]:
82 """Turn an uncaught app error into :func:`show_crash` instead of a bare traceback.
84 A context manager, and — like any ``contextmanager`` — a decorator too:
85 ``@guarded()`` under ``@st.dialog`` / ``@st.fragment``.
86 """
87 try:
88 yield
89 except FragmentHandledException:
90 raise
91 except Exception as error:
92 show_crash(error)
95def run_app() -> None:
96 """One script run of the app, crash note included — every entry script calls this."""
97 with guarded():
98 from scanpath_studio.app import main
100 main()