22
33import atexit
44import functools
5+ import inspect
56import logging
67import sys
8+ import sysconfig
79import threading
10+ from functools import partial
811from logging import StreamHandler
912from pathlib import Path
13+ from threading import get_ident
1014from typing import TYPE_CHECKING , ClassVar , Final
1115
1216import apprise .cli
@@ -79,6 +83,9 @@ class Appriser:
7983 """A wrapper around Apprise to accumulate logs and send notifications."""
8084
8185 _accumulator_id : ClassVar [int | None ] = None
86+ _exit_via_unhandled_exception : ClassVar [bool ] = False
87+ _original_excepthook : Callable [[type [BaseException ], BaseException , types .TracebackType | None ], None ] = None
88+ _original_threading_excepthook : Callable [[threading .ExceptHookArgs ], None ] = None
8289
8390 def __init__ (
8491 self ,
@@ -104,7 +111,8 @@ def __init__(
104111 # Initialize everything
105112 self ._load_default_config_paths ()
106113 self ._setup_interception_handler ()
107- self ._setup_exception_hook ()
114+ self ._setup_sys_exception_hook ()
115+ self ._setup_threading_exception_hook ()
108116 self ._start_periodic_flush ()
109117 self ._setup_at_exit_cleanup ()
110118 self ._setup_removal_prevention ()
@@ -156,23 +164,110 @@ def new_log_method(self: logging.Logger, *args: object, **kwargs: object) -> Non
156164 new_log_method ._intercepted_by_logprise = True
157165 logging .Logger ._log = new_log_method
158166
159- def _setup_exception_hook (self ) -> None :
167+ def _setup_sys_exception_hook (self ) -> None :
160168 """Set up a hook to capture uncaught exceptions."""
161- original_excepthook = sys .excepthook
162-
163- def uncaught_exception (
164- exc_type : type [BaseException ], exc_value : BaseException , exc_traceback : types .TracebackType | None
165- ) -> None :
166- # Log the exception
167- logger .opt (exception = (exc_type , exc_value , exc_traceback )).error (
168- f"Uncaught exception: { exc_type .__name__ } : { exc_value } "
169- )
170- # Force send the notification immediately for uncaught exceptions
171- self .send_notification ()
172- # Call the original excepthook
169+ self ._original_excepthook = hook = sys .excepthook
170+
171+ # We want the original one, not go through multiple Appriser objects!
172+ while (
173+ isinstance (hook , partial )
174+ and hook .func .__name__ == self ._handle_uncaught_sys_exception .__name__
175+ and isinstance (hook .keywords , dict )
176+ and "original_excepthook" in hook .keywords
177+ ):
178+ hook = hook .keywords ["original_excepthook" ]
179+
180+ sys .excepthook = partial (self ._handle_uncaught_sys_exception , original_excepthook = hook )
181+
182+ def _setup_threading_exception_hook (self ) -> None :
183+ """Set up a hook to capture uncaught exceptions."""
184+ self ._original_threading_excepthook = hook = threading .excepthook
185+
186+ # We want the original one, not go through multiple Appriser objects!
187+ while (
188+ isinstance (hook , partial )
189+ and hook .func .__name__ == self ._handle_uncaught_threading_exception .__name__
190+ and isinstance (hook .keywords , dict )
191+ and "original_excepthook" in hook .keywords
192+ ):
193+ hook = hook .keywords ["original_excepthook" ]
194+
195+ threading .excepthook = partial (self ._handle_uncaught_threading_exception , original_excepthook = hook )
196+
197+ _STDLIB_BACKPORTS : ClassVar [frozenset [str ]] = frozenset (
198+ {
199+ "exceptiongroup" , # stdlib in 3.11+
200+ "importlib_metadata" , # stdlib in 3.8+
201+ "importlib_resources" , # stdlib in 3.9+
202+ "typing_extensions" , # backport of typing features
203+ "tomli" , # tomllib in 3.11+
204+ }
205+ )
206+
207+ @staticmethod
208+ def _is_method_in_stdlib (method : Callable ) -> bool :
209+ module = inspect .getmodule (method )
210+ if not module :
211+ return False
212+
213+ top_level = method .__module__ .split ("." , maxsplit = 1 )[0 ]
214+
215+ if top_level in sys .stdlib_module_names or top_level in Appriser ._STDLIB_BACKPORTS :
216+ return True
217+
218+ if not hasattr (module , "__file__" ) or not module .__file__ :
219+ return True
220+
221+ module_path = Path (module .__file__ ).resolve ().absolute ()
222+
223+ if "site-packages" in module_path .parts or "dist-packages" in module_path .parts :
224+ return False
225+
226+ all_paths = sysconfig .get_paths ()
227+ for check_this in ["stdlib" , "platstdlib" ]:
228+ path = Path (all_paths [check_this ]).resolve ().absolute ()
229+ if module_path .is_relative_to (path ):
230+ return True
231+
232+ return False
233+
234+ def _handle_uncaught_sys_exception (
235+ self ,
236+ exc_type : type [BaseException ],
237+ exc_value : BaseException ,
238+ exc_traceback : types .TracebackType | None ,
239+ original_excepthook : Callable [[type [BaseException ], BaseException , types .TracebackType | None ], None ],
240+ ) -> None :
241+ """Handle uncaught exceptions by logging and sending notifications."""
242+ logger .opt (exception = (exc_type , exc_value , exc_traceback )).error (
243+ f"Uncaught exception: { exc_type .__name__ } : { exc_value } "
244+ )
245+
246+ Appriser ._exit_via_unhandled_exception = True
247+
248+ self .send_notification ()
249+
250+ if not self ._is_method_in_stdlib (original_excepthook ):
173251 original_excepthook (exc_type , exc_value , exc_traceback )
174252
175- sys .excepthook = uncaught_exception
253+ def _handle_uncaught_threading_exception (
254+ self ,
255+ args : threading .ExceptHookArgs ,
256+ / ,
257+ original_excepthook : Callable [[threading .ExceptHookArgs ], None ],
258+ ) -> None :
259+ """Handle uncaught exceptions by logging and sending notifications."""
260+ logger .opt (exception = (args .exc_type , args .exc_value , args .exc_traceback )).error (
261+ f"Uncaught exception in thread { args .thread .name if args .thread else get_ident ()} :"
262+ f" { args .exc_type .__name__ } : { args .exc_value } "
263+ )
264+
265+ Appriser ._exit_via_unhandled_exception = True
266+
267+ self .send_notification ()
268+
269+ if not self ._is_method_in_stdlib (original_excepthook ):
270+ original_excepthook (args )
176271
177272 @property
178273 def flush_interval (self ) -> int | float :
@@ -232,7 +327,12 @@ def stop_periodic_flush(self) -> None:
232327 def cleanup (self ) -> None :
233328 """Clean up resources and send any pending notifications."""
234329 self .stop_periodic_flush ()
235- self .send_notification ()
330+
331+ if not Appriser ._exit_via_unhandled_exception :
332+ self .send_notification ()
333+
334+ sys .excepthook = self ._original_excepthook
335+ threading .excepthook = self ._original_threading_excepthook
236336
237337 @property
238338 def notification_level (self ) -> int :
@@ -263,15 +363,19 @@ def send_notification(
263363 ) -> None :
264364 """Send a single notification with all accumulated logs."""
265365 if not self .buffer :
366+ logger .debug ("No logs to send" )
266367 return
267368
268369 # Format the buffered logs into a single message
269370 message = "" .join (self .buffer ).replace ("\r " , "" )
270371
271- if self .apprise_obj and self .apprise_obj .notify (
272- title = title , notify_type = notify_type , body = message , body_format = body_format
273- ):
274- self .buffer .clear () # Clear the buffer after sending
372+ try :
373+ if message and self .apprise_obj .notify (
374+ title = title , notify_type = notify_type , body = message , body_format = body_format
375+ ):
376+ self .buffer .clear () # Clear the buffer after sending
377+ except BaseException as e :
378+ logger .warning (f"Failed to send notification: { e } " )
275379
276380
277381appriser = Appriser ()
0 commit comments