Skip to content

Commit 7c93f77

Browse files
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) <noreply@anthropic.com>
1 parent ba91215 commit 7c93f77

5 files changed

Lines changed: 178 additions & 7 deletions

File tree

‎Doc/library/tkinter.systray.rst‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212

1313
The :mod:`!tkinter.systray` module provides the :class:`SysTrayIcon` class
1414
as an interface to the system tray (or taskbar) icon,
15-
and the :func:`notify` function which sends a desktop notification.
16-
They require Tk 8.7/9.0 or newer.
15+
the :func:`notify` function which sends a desktop notification,
16+
and the :class:`NotificationHandler` class which shows log records
17+
as desktop notifications.
18+
They require Tk 9.0 or newer.
1719

1820
Only one system tray icon is supported per Tcl interpreter.
1921

@@ -70,3 +72,33 @@ Only one system tray icon is supported per Tcl interpreter.
7072
On Windows, sending a notification requires an existing system
7173
tray icon, which is also displayed in the notification;
7274
use the :meth:`SysTrayIcon.notify` method instead.
75+
76+
77+
.. class:: NotificationHandler(title=None, *, master=None)
78+
79+
A :mod:`logging` handler which shows log records as desktop notifications.
80+
81+
The title of the notification is *title* if it is not ``None``,
82+
otherwise the level name of the record (for example ``'WARNING'``).
83+
The message of the notification is the record formatted
84+
by the handler's :class:`~logging.Formatter`.
85+
86+
The notifications are sent with *master* as the Tk window,
87+
or with the default root window if *master* is ``None``.
88+
The default root window is looked up when a record is emitted,
89+
so the handler can be created before the root window.
90+
On Windows, sending a notification requires an existing system tray icon.
91+
92+
Errors in sending a notification are reported
93+
by the :meth:`~logging.Handler.handleError` method.
94+
95+
For example, to show warnings and errors as desktop notifications::
96+
97+
import logging
98+
import tkinter
99+
from tkinter.systray import NotificationHandler
100+
101+
root = tkinter.Tk()
102+
handler = NotificationHandler('My application')
103+
handler.setLevel(logging.WARNING)
104+
logging.getLogger().addHandler(handler)

‎Doc/whatsnew/3.16.rst‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -668,9 +668,11 @@ tkinter
668668

669669
* Added the :mod:`tkinter.systray` module which provides the
670670
:class:`~tkinter.systray.SysTrayIcon` class as an interface to the system
671-
tray icon and the :func:`~tkinter.systray.notify` function which sends a
672-
desktop notification. They require Tk 8.7/9.0 or newer.
673-
(Contributed by Serhiy Storchaka in :gh:`153259`.)
671+
tray icon, the :func:`~tkinter.systray.notify` function which sends a
672+
desktop notification, and the :class:`~tkinter.systray.NotificationHandler`
673+
logging handler which shows log records as desktop notifications.
674+
They require Tk 9.0 or newer.
675+
(Contributed by Serhiy Storchaka in :gh:`153259` and :gh:`157749`.)
674676

675677
* :class:`tkinter.scrolledtext.ScrolledText` gained a *use_ttk* parameter to use
676678
the themed :mod:`tkinter.ttk` frame and scroll bar instead of the classic

‎Lib/test/test_tkinter/test_systray.py‎

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import logging
2+
import sys
13
import unittest
24
import tkinter
3-
from tkinter.systray import SysTrayIcon, notify
5+
from tkinter.systray import SysTrayIcon, notify, NotificationHandler
46
from test.support import requires
57
from test.test_tkinter.support import (AbstractTkTest,
68
AbstractDefaultRootTest,
@@ -110,6 +112,95 @@ def test_notify(self):
110112
# Sends a real desktop notification.
111113
icon.notify('Python test', 'tkinter.systray test notification')
112114

115+
@requires_tk(8, 7)
116+
def test_notification_handler(self):
117+
# All real notifications are sent from the same Tcl interpreter.
118+
# Sending a notification after the interpreter which sent
119+
# the previous one was deleted crashes Tk on X11 with libnotify.
120+
if self.root._windowingsystem != 'x11':
121+
self.skipTest('cannot safely send a native notification')
122+
self.create()
123+
handler = NotificationHandler('Python test', master=self.root)
124+
record = logging.LogRecord('test', logging.INFO, __file__, 0,
125+
'tkinter.systray test notification',
126+
None, None)
127+
# Sends a real desktop notification.
128+
handler.emit(record)
129+
130+
131+
class FakeTk:
132+
# Records the calls of "tk sysnotify" instead of sending
133+
# real notifications.
134+
135+
def __init__(self):
136+
self.calls = []
137+
138+
def call(self, *args):
139+
if args[:2] != ('tk', 'sysnotify'):
140+
raise tkinter.TclError(f'unexpected call: {args}')
141+
self.calls.append(args[2:])
142+
143+
144+
class FakeMaster:
145+
def __init__(self):
146+
self.tk = FakeTk()
147+
148+
149+
class NotificationHandlerTest(unittest.TestCase):
150+
151+
def setUp(self):
152+
self.master = FakeMaster()
153+
self.calls = self.master.tk.calls
154+
self.logger = logging.getLogger('test.tkinter.systray')
155+
self.logger.propagate = False
156+
self.logger.setLevel(logging.DEBUG)
157+
self.addCleanup(self.logger.setLevel, logging.NOTSET)
158+
self.addCleanup(setattr, self.logger, 'propagate', True)
159+
160+
def add_handler(self, *args, **kwargs):
161+
handler = NotificationHandler(*args, master=self.master, **kwargs)
162+
self.logger.addHandler(handler)
163+
self.addCleanup(self.logger.removeHandler, handler)
164+
return handler
165+
166+
def test_emit(self):
167+
self.add_handler()
168+
self.logger.warning('spam %s', 'eggs')
169+
self.logger.error('ham')
170+
self.assertEqual(self.calls, [('WARNING', 'spam eggs'),
171+
('ERROR', 'ham')])
172+
173+
def test_title(self):
174+
self.add_handler('Python')
175+
self.logger.warning('spam')
176+
self.assertEqual(self.calls, [('Python', 'spam')])
177+
178+
def test_formatter(self):
179+
handler = self.add_handler()
180+
handler.setFormatter(logging.Formatter('%(name)s: %(message)s'))
181+
self.logger.info('spam')
182+
self.assertEqual(self.calls,
183+
[('INFO', 'test.tkinter.systray: spam')])
184+
185+
def test_level(self):
186+
handler = self.add_handler()
187+
handler.setLevel(logging.WARNING)
188+
self.logger.info('spam')
189+
self.logger.warning('eggs')
190+
self.assertEqual(self.calls, [('WARNING', 'eggs')])
191+
192+
def test_error(self):
193+
# Errors in sending a notification are handled by handleError().
194+
handler = self.add_handler()
195+
errors = []
196+
handler.handleError = errors.append
197+
def call(*args):
198+
raise tkinter.TclError('no notifications')
199+
self.master.tk.call = call
200+
self.logger.warning('spam')
201+
self.assertEqual(len(errors), 1)
202+
self.assertEqual(errors[0].getMessage(), 'spam')
203+
113204

114205
class DefaultRootTest(AbstractDefaultRootTest, unittest.TestCase):
115206

@@ -129,6 +220,19 @@ def test_systray(self):
129220
self.assertRaises(RuntimeError, SysTrayIcon, image='none')
130221
self.assertRaises(RuntimeError, notify, 'title', 'message')
131222

223+
def test_notification_handler(self):
224+
# The default root window is looked up when a record is emitted.
225+
handler = NotificationHandler()
226+
self.assertIsNone(handler.master)
227+
errors = []
228+
handler.handleError = lambda record: errors.append(sys.exception())
229+
record = logging.LogRecord('test', logging.INFO, __file__, 0,
230+
'message', None, None)
231+
tkinter.NoDefaultRoot()
232+
handler.emit(record)
233+
self.assertEqual(len(errors), 1)
234+
self.assertIsInstance(errors[0], RuntimeError)
235+
132236

133237
if __name__ == "__main__":
134238
unittest.main()

‎Lib/tkinter/systray.py‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
33
The SysTrayIcon class gives access to the "tk systray" command and
44
the notify() function gives access to the "tk sysnotify" command.
5+
The NotificationHandler class is a logging handler which shows
6+
log records as desktop notifications.
57
They require Tk 8.7/9.0 or newer.
68
79
Only one system tray icon is supported per Tcl interpreter.
@@ -11,9 +13,10 @@
1113
notification.
1214
"""
1315

16+
import logging
1417
import tkinter
1518

16-
__all__ = ["SysTrayIcon", "notify"]
19+
__all__ = ["SysTrayIcon", "notify", "NotificationHandler"]
1720

1821

1922
class SysTrayIcon:
@@ -128,3 +131,31 @@ def notify(title, message, *, master=None):
128131
if master is None:
129132
master = tkinter._get_default_root('send a notification')
130133
master.tk.call('tk', 'sysnotify', title, message)
134+
135+
136+
class NotificationHandler(logging.Handler):
137+
"""A logging handler which shows log records as desktop notifications.
138+
139+
The title of the notification is the given title if it is not None,
140+
otherwise the level name of the record. The message of the
141+
notification is the formatted record.
142+
143+
The default root window is used if master is None; it is looked up
144+
when a record is emitted, so the handler can be created before the
145+
root window.
146+
"""
147+
148+
def __init__(self, title=None, *, master=None):
149+
super().__init__()
150+
self.master = master
151+
self.title = title
152+
153+
def emit(self, record):
154+
try:
155+
msg = self.format(record)
156+
title = self.title
157+
if title is None:
158+
title = record.levelname
159+
notify(title, msg, master=self.master)
160+
except Exception:
161+
self.handleError(record)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add :class:`tkinter.systray.NotificationHandler` -- a :mod:`logging` handler
2+
which shows log records as desktop notifications.

0 commit comments

Comments
 (0)