Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions Doc/library/tkinter.systray.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)
8 changes: 5 additions & 3 deletions Doc/whatsnew/3.16.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
106 changes: 105 additions & 1 deletion Lib/test/test_tkinter/test_systray.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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):

Expand All @@ -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()
33 changes: 32 additions & 1 deletion Lib/tkinter/systray.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -11,9 +13,10 @@
notification.
"""

import logging
import tkinter

__all__ = ["SysTrayIcon", "notify"]
__all__ = ["SysTrayIcon", "notify", "NotificationHandler"]


class SysTrayIcon:
Expand Down Expand Up @@ -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)
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add :class:`tkinter.systray.NotificationHandler` -- a :mod:`logging` handler
which shows log records as desktop notifications.
Loading