From 7c93f776dd1bf2b1cbb4b615e0442d2dc9bd8e7d Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Fri, 18 Sep 2026 13:41:08 +0300 Subject: [PATCH] gh-157749: Add tkinter.systray.NotificationHandler A logging handler which shows log records as desktop notifications using the "tk sysnotify" command. Co-Authored-By: Claude Opus 5 (1M context) --- Doc/library/tkinter.systray.rst | 36 +++++- Doc/whatsnew/3.16.rst | 8 +- Lib/test/test_tkinter/test_systray.py | 106 +++++++++++++++++- Lib/tkinter/systray.py | 33 +++++- ...-09-16-12-00-00.gh-issue-157749.NtfHdl.rst | 2 + 5 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-16-12-00-00.gh-issue-157749.NtfHdl.rst diff --git a/Doc/library/tkinter.systray.rst b/Doc/library/tkinter.systray.rst index 22ecbf5b71d6aa..821225e821ebe4 100644 --- a/Doc/library/tkinter.systray.rst +++ b/Doc/library/tkinter.systray.rst @@ -12,8 +12,10 @@ The :mod:`!tkinter.systray` module provides the :class:`SysTrayIcon` class as an interface to the system tray (or taskbar) icon, -and the :func:`notify` function which sends a desktop notification. -They require Tk 8.7/9.0 or newer. +the :func:`notify` function which sends a desktop notification, +and the :class:`NotificationHandler` class which shows log records +as desktop notifications. +They require Tk 9.0 or newer. Only one system tray icon is supported per Tcl interpreter. @@ -70,3 +72,33 @@ Only one system tray icon is supported per Tcl interpreter. On Windows, sending a notification requires an existing system tray icon, which is also displayed in the notification; use the :meth:`SysTrayIcon.notify` method instead. + + +.. class:: NotificationHandler(title=None, *, master=None) + + A :mod:`logging` handler which shows log records as desktop notifications. + + The title of the notification is *title* if it is not ``None``, + otherwise the level name of the record (for example ``'WARNING'``). + The message of the notification is the record formatted + by the handler's :class:`~logging.Formatter`. + + The notifications are sent with *master* as the Tk window, + or with the default root window if *master* is ``None``. + The default root window is looked up when a record is emitted, + so the handler can be created before the root window. + On Windows, sending a notification requires an existing system tray icon. + + Errors in sending a notification are reported + by the :meth:`~logging.Handler.handleError` method. + + For example, to show warnings and errors as desktop notifications:: + + import logging + import tkinter + from tkinter.systray import NotificationHandler + + root = tkinter.Tk() + handler = NotificationHandler('My application') + handler.setLevel(logging.WARNING) + logging.getLogger().addHandler(handler) diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 53983637f520c8..743140a76adccd 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -668,9 +668,11 @@ tkinter * Added the :mod:`tkinter.systray` module which provides the :class:`~tkinter.systray.SysTrayIcon` class as an interface to the system - tray icon and the :func:`~tkinter.systray.notify` function which sends a - desktop notification. They require Tk 8.7/9.0 or newer. - (Contributed by Serhiy Storchaka in :gh:`153259`.) + tray icon, the :func:`~tkinter.systray.notify` function which sends a + desktop notification, and the :class:`~tkinter.systray.NotificationHandler` + logging handler which shows log records as desktop notifications. + They require Tk 9.0 or newer. + (Contributed by Serhiy Storchaka in :gh:`153259` and :gh:`157749`.) * :class:`tkinter.scrolledtext.ScrolledText` gained a *use_ttk* parameter to use the themed :mod:`tkinter.ttk` frame and scroll bar instead of the classic diff --git a/Lib/test/test_tkinter/test_systray.py b/Lib/test/test_tkinter/test_systray.py index 809034747a3ab8..70985b70e41570 100644 --- a/Lib/test/test_tkinter/test_systray.py +++ b/Lib/test/test_tkinter/test_systray.py @@ -1,6 +1,8 @@ +import logging +import sys import unittest import tkinter -from tkinter.systray import SysTrayIcon, notify +from tkinter.systray import SysTrayIcon, notify, NotificationHandler from test.support import requires from test.test_tkinter.support import (AbstractTkTest, AbstractDefaultRootTest, @@ -110,6 +112,95 @@ def test_notify(self): # Sends a real desktop notification. icon.notify('Python test', 'tkinter.systray test notification') + @requires_tk(8, 7) + def test_notification_handler(self): + # All real notifications are sent from the same Tcl interpreter. + # Sending a notification after the interpreter which sent + # the previous one was deleted crashes Tk on X11 with libnotify. + if self.root._windowingsystem != 'x11': + self.skipTest('cannot safely send a native notification') + self.create() + handler = NotificationHandler('Python test', master=self.root) + record = logging.LogRecord('test', logging.INFO, __file__, 0, + 'tkinter.systray test notification', + None, None) + # Sends a real desktop notification. + handler.emit(record) + + +class FakeTk: + # Records the calls of "tk sysnotify" instead of sending + # real notifications. + + def __init__(self): + self.calls = [] + + def call(self, *args): + if args[:2] != ('tk', 'sysnotify'): + raise tkinter.TclError(f'unexpected call: {args}') + self.calls.append(args[2:]) + + +class FakeMaster: + def __init__(self): + self.tk = FakeTk() + + +class NotificationHandlerTest(unittest.TestCase): + + def setUp(self): + self.master = FakeMaster() + self.calls = self.master.tk.calls + self.logger = logging.getLogger('test.tkinter.systray') + self.logger.propagate = False + self.logger.setLevel(logging.DEBUG) + self.addCleanup(self.logger.setLevel, logging.NOTSET) + self.addCleanup(setattr, self.logger, 'propagate', True) + + def add_handler(self, *args, **kwargs): + handler = NotificationHandler(*args, master=self.master, **kwargs) + self.logger.addHandler(handler) + self.addCleanup(self.logger.removeHandler, handler) + return handler + + def test_emit(self): + self.add_handler() + self.logger.warning('spam %s', 'eggs') + self.logger.error('ham') + self.assertEqual(self.calls, [('WARNING', 'spam eggs'), + ('ERROR', 'ham')]) + + def test_title(self): + self.add_handler('Python') + self.logger.warning('spam') + self.assertEqual(self.calls, [('Python', 'spam')]) + + def test_formatter(self): + handler = self.add_handler() + handler.setFormatter(logging.Formatter('%(name)s: %(message)s')) + self.logger.info('spam') + self.assertEqual(self.calls, + [('INFO', 'test.tkinter.systray: spam')]) + + def test_level(self): + handler = self.add_handler() + handler.setLevel(logging.WARNING) + self.logger.info('spam') + self.logger.warning('eggs') + self.assertEqual(self.calls, [('WARNING', 'eggs')]) + + def test_error(self): + # Errors in sending a notification are handled by handleError(). + handler = self.add_handler() + errors = [] + handler.handleError = errors.append + def call(*args): + raise tkinter.TclError('no notifications') + self.master.tk.call = call + self.logger.warning('spam') + self.assertEqual(len(errors), 1) + self.assertEqual(errors[0].getMessage(), 'spam') + class DefaultRootTest(AbstractDefaultRootTest, unittest.TestCase): @@ -129,6 +220,19 @@ def test_systray(self): self.assertRaises(RuntimeError, SysTrayIcon, image='none') self.assertRaises(RuntimeError, notify, 'title', 'message') + def test_notification_handler(self): + # The default root window is looked up when a record is emitted. + handler = NotificationHandler() + self.assertIsNone(handler.master) + errors = [] + handler.handleError = lambda record: errors.append(sys.exception()) + record = logging.LogRecord('test', logging.INFO, __file__, 0, + 'message', None, None) + tkinter.NoDefaultRoot() + handler.emit(record) + self.assertEqual(len(errors), 1) + self.assertIsInstance(errors[0], RuntimeError) + if __name__ == "__main__": unittest.main() diff --git a/Lib/tkinter/systray.py b/Lib/tkinter/systray.py index 680879c4f61aa8..37307438a1c9aa 100644 --- a/Lib/tkinter/systray.py +++ b/Lib/tkinter/systray.py @@ -2,6 +2,8 @@ The SysTrayIcon class gives access to the "tk systray" command and the notify() function gives access to the "tk sysnotify" command. +The NotificationHandler class is a logging handler which shows +log records as desktop notifications. They require Tk 8.7/9.0 or newer. Only one system tray icon is supported per Tcl interpreter. @@ -11,9 +13,10 @@ notification. """ +import logging import tkinter -__all__ = ["SysTrayIcon", "notify"] +__all__ = ["SysTrayIcon", "notify", "NotificationHandler"] class SysTrayIcon: @@ -128,3 +131,31 @@ def notify(title, message, *, master=None): if master is None: master = tkinter._get_default_root('send a notification') master.tk.call('tk', 'sysnotify', title, message) + + +class NotificationHandler(logging.Handler): + """A logging handler which shows log records as desktop notifications. + + The title of the notification is the given title if it is not None, + otherwise the level name of the record. The message of the + notification is the formatted record. + + The default root window is used if master is None; it is looked up + when a record is emitted, so the handler can be created before the + root window. + """ + + def __init__(self, title=None, *, master=None): + super().__init__() + self.master = master + self.title = title + + def emit(self, record): + try: + msg = self.format(record) + title = self.title + if title is None: + title = record.levelname + notify(title, msg, master=self.master) + except Exception: + self.handleError(record) diff --git a/Misc/NEWS.d/next/Library/2026-09-16-12-00-00.gh-issue-157749.NtfHdl.rst b/Misc/NEWS.d/next/Library/2026-09-16-12-00-00.gh-issue-157749.NtfHdl.rst new file mode 100644 index 00000000000000..66db3743e2acb1 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-16-12-00-00.gh-issue-157749.NtfHdl.rst @@ -0,0 +1,2 @@ +Add :class:`tkinter.systray.NotificationHandler` -- a :mod:`logging` handler +which shows log records as desktop notifications.