Skip to content

Commit afabd23

Browse files
fix: when appriser fails during an uncaught exception, don't try twice (#73)
* First adjustment * Improving coverage * reformat * Interpret partials * Potential fix for code scanning alert no. 2: Workflow does not contain permissions Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1 parent a5c1fb3 commit afabd23

File tree

8 files changed

+480
-88
lines changed

8 files changed

+480
-88
lines changed

.github/workflows/build.yml

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
name: Build and Test
2+
permissions:
3+
contents: read
24

35
on:
46
push:
@@ -8,12 +10,8 @@ on:
810
workflow_dispatch:
911

1012
jobs:
11-
test:
13+
lint:
1214
runs-on: ubuntu-latest
13-
strategy:
14-
matrix:
15-
python-version: ["3.10", "3.11", "3.12", "3.13"]
16-
fail-fast: false
1715

1816
steps:
1917
- uses: actions/checkout@v5
@@ -24,37 +22,58 @@ jobs:
2422
virtualenvs-create: true
2523
virtualenvs-in-project: true
2624

27-
- name: Set up Python ${{ matrix.python-version }}
25+
- name: Set up Python 3.10
2826
uses: actions/setup-python@v6
2927
with:
30-
python-version: ${{ matrix.python-version }}
28+
python-version: "3.10"
3129
cache: 'poetry'
3230

3331
- name: Install dependencies
3432
run: poetry install --no-interaction
3533

3634
- name: Run ruff format check
37-
if: always()
3835
run: poetry run ruff format --check --diff .
3936

4037
- name: Run ruff lint check
41-
if: always()
4238
run: poetry run ruff check --diff .
4339

4440
- name: Run consistency checks between stub & module
45-
if: always()
4641
env:
4742
PYTHONPATH: "."
4843
run: poetry run stubtest logprise
4944

5045
- name: Running mypy
51-
if: always()
5246
env:
5347
PYTHONPATH: "."
5448
run: poetry run mypy .
5549

50+
test:
51+
needs: lint
52+
runs-on: ubuntu-latest
53+
strategy:
54+
matrix:
55+
python-version: ["3.10", "3.11", "3.12", "3.13"]
56+
fail-fast: false
57+
58+
steps:
59+
- uses: actions/checkout@v5
60+
61+
- name: Install Poetry
62+
uses: snok/install-poetry@v1
63+
with:
64+
virtualenvs-create: true
65+
virtualenvs-in-project: true
66+
67+
- name: Set up Python ${{ matrix.python-version }}
68+
uses: actions/setup-python@v6
69+
with:
70+
python-version: ${{ matrix.python-version }}
71+
cache: 'poetry'
72+
73+
- name: Install dependencies
74+
run: poetry install --no-interaction
75+
5676
- name: Run tests with coverage
57-
if: always()
5877
run: poetry run pytest --cov=logprise --cov-report=xml
5978

6079
- name: Upload coverage report
@@ -64,4 +83,4 @@ jobs:
6483
fail_ci_if_error: true
6584
files: ./coverage.xml
6685
verbose: true
67-
token: ${{ secrets.CODECOV_TOKEN }}
86+
token: ${{ secrets.CODECOV_TOKEN }}

logprise/__init__.py

Lines changed: 124 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22

33
import atexit
44
import functools
5+
import inspect
56
import logging
67
import sys
8+
import sysconfig
79
import threading
10+
from functools import partial
811
from logging import StreamHandler
912
from pathlib import Path
13+
from threading import get_ident
1014
from typing import TYPE_CHECKING, ClassVar, Final
1115

1216
import 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

277381
appriser = Appriser()

tests/conftest.py

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import sys
2+
import threading
3+
from collections.abc import Generator
14
from typing import Any
25

36
import pytest
4-
from apprise import Apprise, NotifyBase, NotifyType
7+
from apprise import NotifyBase, NotifyType
58

6-
import logprise
9+
from logprise import Appriser, logger
710

811

912
class NoOpNotifier(NotifyBase):
@@ -15,37 +18,55 @@ class NoOpNotifier(NotifyBase):
1518
# Define protocol(s) this notification supports
1619
protocol = ("noop", "dummy")
1720

21+
calls: list[dict]
22+
1823
def __init__(self, **kwargs):
1924
super().__init__(secure=False, **kwargs)
25+
self.calls = []
2026

2127
def url(self, privacy: bool = False, *args: Any, **kwargs: Any) -> str:
2228
return "noop://"
2329

2430
def send(self, body: str, title: str = "", notify_type: NotifyType = NotifyType.INFO, **kwargs: Any) -> bool:
2531
# Simply return True to simulate successful notification
32+
self.calls.append({"title": title, "body": body})
2633
return True
2734

2835

29-
@pytest.fixture()
30-
def noop() -> NoOpNotifier:
31-
return NoOpNotifier()
36+
@pytest.fixture
37+
def apprise_noop() -> Generator[tuple[Appriser, NoOpNotifier], Any, None]:
38+
a = Appriser()
39+
noop = NoOpNotifier()
40+
a.add(noop)
41+
try:
42+
yield a, noop
43+
finally:
44+
a.cleanup()
3245

3346

3447
@pytest.fixture(autouse=True)
35-
def notify_mock(monkeypatch):
36-
"""Only mock the notify method of Apprise"""
37-
calls = []
48+
def reset_appriser_object() -> Generator[None, None, None]:
49+
try:
50+
yield
51+
finally:
52+
Appriser._exit_via_unhandled_exception = False
3853

39-
def mock_notify(self, title, body, *_, **__):
40-
calls.append({"title": title, "body": body})
41-
return True
4254

43-
monkeypatch.setattr(Apprise, "notify", mock_notify)
44-
return calls
55+
@pytest.fixture(autouse=True)
56+
def save_restore_excepthooks() -> Generator[None, None, None]:
57+
original_excepthook = sys.excepthook
58+
original_threading_excepthook = threading.excepthook
59+
try:
60+
yield
61+
finally:
62+
sys.excepthook = original_excepthook
63+
threading.excepthook = original_threading_excepthook
4564

4665

4766
@pytest.fixture(autouse=True)
4867
def silence_logger():
49-
logprise.logger.remove() # Silence any output
50-
yield
51-
logprise.logger.remove() # And restore any handlers we added
68+
logger.remove() # Silence any output
69+
try:
70+
yield
71+
finally:
72+
logger.remove() # And restore any handlers we added

0 commit comments

Comments
 (0)