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

1"""What the user sees when the app itself breaks: an error that asks them to tell us. 

2 

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. 

9 

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. 

15 

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. 

22 

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""" 

27 

28from __future__ import annotations 

29 

30import logging 

31from collections.abc import Iterator 

32from contextlib import contextmanager 

33from urllib.parse import urlencode 

34 

35import streamlit as st 

36from streamlit.errors import FragmentHandledException 

37 

38from scanpath_studio.constants import CITATION, ICONS 

39 

40_LOGGER = logging.getLogger(__name__) 

41 

42 

43def report_url(error: BaseException) -> str: 

44 """The bug-report form, its title pre-filled with the error type and version. 

45 

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__ 

51 

52 title = f"Crash: {type(error).__name__} (v{__version__})" 

53 return f"{CITATION['bug_report_url']}&{urlencode({'title': title})}" 

54 

55 

56def crash_message(error: BaseException) -> str: 

57 """The markdown shown above the traceback.""" 

58 from scanpath_studio import __version__ 

59 

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 ) 

71 

72 

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) 

78 

79 

80@contextmanager 

81def guarded() -> Iterator[None]: 

82 """Turn an uncaught app error into :func:`show_crash` instead of a bare traceback. 

83 

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) 

93 

94 

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 

99 

100 main()