SysNotify is a simple, pure python notification module for use with PyQt5 desktop applications. It uses python-dbus to communicate directly with the systems notification server, and supports callback from notification actions via the pyqt5 dbus main loop. It provides basically the same features as the gtk-2.0 pynotify library, but does not require any GTK libraries, so avoids the dependency hell which can be a problem with Gtk. It is similar to and based on notify2, but is simplified and updated to support PyQt5.

If python-dbus.mainloop.pyqt5 is not installed any notification action callbacks will not work, but the notifications will still be shown.

Depends on:

  • python-dbus
  • python-dbus.mainloop.pyqt5 (optional)

Tested on:

  • Debian 9
  • Ubuntu 18.04

Example Notification

Code

sys_notify.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
#!/usr/bin/env python

import dbus
from collections import OrderedDict

DBusQtMainLoop = None
try:
    from dbus.mainloop.pyqt5 import DBusQtMainLoop
except:
    print "Could not import DBusQtMainLoop, is package 'python-dbus.mainloop.pyqt5' installed?"

APP_NAME = ''
DBUS_IFACE = None
NOTIFICATIONS = {}

class Urgency:
    """freedesktop.org notification urgency levels"""
    LOW, NORMAL, CRITICAL = range(3)

class UninitializedError(RuntimeError):
    """Error raised if you try to show an error before initializing"""
    pass

def init(app_name):
    """Initializes the DBus connection"""
    global APP_NAME, DBUS_IFACE
    APP_NAME = app_name

    name = "org.freedesktop.Notifications"
    path = "/org/freedesktop/Notifications"
    interface = "org.freedesktop.Notifications"

    mainloop = None
    if DBusQtMainLoop is not None:
        mainloop = DBusQtMainLoop(set_as_default=True)

    bus = dbus.SessionBus(mainloop)
    proxy = bus.get_object(name, path)
    DBUS_IFACE = dbus.Interface(proxy, interface)

    if mainloop is not None:
        # We have a mainloop, so connect callbacks
        DBUS_IFACE.connect_to_signal('ActionInvoked', _onActionInvoked)
        DBUS_IFACE.connect_to_signal('NotificationClosed', _onNotificationClosed)

def _onActionInvoked(nid, action):
    """Called when a notification action is clicked"""
    nid, action = int(nid), str(action)
    try:
        notification = NOTIFICATIONS[nid]
    except KeyError:
        # must have been created by some other program
        return
    notification._onActionInvoked(action)

def _onNotificationClosed(nid, reason):
    """Called when the notification is closed"""
    nid, reason = int(nid), int(reason)
    try:
        notification = NOTIFICATIONS[nid]
    except KeyError:
        # must have been created by some other program
        return
    notification._onNotificationClosed(notification)
    del NOTIFICATIONS[nid]

class Notification(object):
    """Notification object"""

    id = 0
    timeout = -1
    _onNotificationClosed = lambda *args: None

    def __init__(self, title, body='', icon='', timeout=-1):
        """Initializes a new notification object.
        Args:
            title (str):              The title of the notification
            body (str, optional):     The body text of the notification
            icon (str, optional):     The icon to display with the notification
            timeout (TYPE, optional): The time in ms before the notification hides, -1 for default, 0 for never
        """

        self.title = title              # title of the notification
        self.body = body                # the body text of the notification
        self.icon = icon                # the path to the icon to use
        self.timeout = timeout          # time in ms before the notification disappears
        self.hints = {}                 # dict of various display hints
        self.actions = OrderedDict()    # actions names and their callbacks
        self.data = {}                  # arbitrary user data

    def show(self):
        if DBUS_IFACE is None:
            raise UninitializedError("You must call 'notify.init()' before 'notify.show()'")

        """Asks the notification server to show the notification"""
        nid = DBUS_IFACE.Notify(APP_NAME,
                           self.id,
                           self.icon,
                           self.title,
                           self.body,
                           self._makeActionsList(),
                           self.hints,
                           self.timeout,
                        )

        self.id = int(nid)

        NOTIFICATIONS[self.id] = self
        return True

    def close(self):
        """Ask the notification server to close the notification"""
        if self.id != 0:
            DBUS_IFACE.CloseNotification(self.id)

    def onClosed(self, callback):
        """Set the callback called when the notification is closed"""
        self._onNotificationClosed = callback

    def setUrgency(self, value):
        """Set the freedesktop.org notification urgency level"""
        if value not in range(3):
            raise ValueError("Unknown urgency level '%s' specified" % level)
        self.hints['urgency'] = dbus.Byte(value)

    def setSoundFile(self, sound_file):
        """Sets a sound file to play when the notification shows"""
        self.hints['sound-file'] = sound_file

    def setSoundName(self, sound_name):
        """Set a freedesktop.org sound name to play when notification shows"""
        self.hints['sound-name'] = sound_name

    def setIconPath(self, icon_path):
        """Set the URI of the icon to display in the notification"""
        self.hints['image-path'] = 'file://' + icon_path

    def setQIcon(self, q_icon):
        # FixMe this would be convenient, but may not be possible
        raise NotImplemented

    def setLocation(self, x_pos, y_pos):
        """Sets the location to display the notification"""
        self.hints['x'] = int(x_pos)
        self.hints['y'] = int(y_pos)

    def setCategory(self, category):
        """Sets the the freedesktop.org notification category"""
        self.hints['category'] = category

    def setTimeout(self, timeout):
        """Set the display duration in milliseconds, -1 for default"""
        if not isinstance(timeout, int):
            raise TypeError("Timeout value '%s' was not int" % timeout)
        self.timeout = timeout

    def setHint(self, key, value):
        """Set one of the other hints"""
        self.hints[key] = value

    def addAction(self, action, label, callback, user_data=None):
        """Add an action to the notification.
        Args:
            action (str):               A sort key identifying the action
            label (str):                The text to display on the action button
            callback (bound method):    The method to call when the action is activated
            user_data (any, optional):  Any user data to be passed to the action callback
        """
        self.actions[action] = (label, callback, user_data)

    def _makeActionsList(self):
        """Make the actions array to send over DBus"""
        arr = []
        for action, (label, callback, user_data) in self.actions.items():
            arr.append(action)
            arr.append(label)
        return arr

    def _onActionInvoked(self, action):
        """Called when the user activates a notification action"""
        try:
            label, callback, user_data = self.actions[action]
        except KeyError:
            return

        if user_data is None:
            callback(self, action)
        else:
            callback(self, action, user_data)


# ----------------------- E X A M P L E -----------------------

def onHelp(n, action):
    assert(action == "help"), "Action was not help!"
    print "You clicked Help action"
    n.close()

def onIgnore(n, action, data):
    assert(action == "ignore"), "Action was not ignore!"
    print "You clicked Ignore action"
    print "Passed user data was: ", data
    n.close()

def onClose(n):
    print "Notification closed"
    app.quit()

if __name__ == "__main__":
    import sys
    from PyQt5.QtCore import QCoreApplication
    app = QCoreApplication(sys.argv)

    # Initialize the DBus connection to the notification server
    init("demo")

    # Initialize a new notification object
    n = Notification("Demo Notification",
                     "This notification is very important as it " +
                     "notifies you that notifications are working.",
                     timeout=3000
                    )
    n.setUrgency(Urgency.NORMAL)
    n.setCategory("device")
    n.setIconPath("/usr/share/icons/Tango/scalable/status/dialog-error.svg")
    # no user data
    n.addAction("help", "Help", onHelp)
    # passing arbitrary user data to the callback
    n.addAction("ignore", "Ignore", onIgnore, 12345)
    n.onClosed(onClose)

    n.show()
app.exec_()

SysNotify can be downloaded from the GitHub Gist located here.