1"""This is a pure-python replacement for notify-python, using python-dbus
2to communicate with the notifications server directly. It's compatible with
3Python 2 and 3, and its callbacks can work with Gtk 3 or Qt 4 applications.
4
5To use it, first call ``notify2.init('app name')``, then create and show notifications::
6
7    n = notify2.Notification("Summary",
8                             "Some body text",
9                             "notification-message-im"   # Icon name
10                            )
11    n.show()
12
13API docs are `available on ReadTheDocs <https://notify2.readthedocs.org/en/latest/>`_,
14or you can refer to docstrings.
15
16Based on the notifications spec at:
17http://developer.gnome.org/notification-spec/
18
19Porting applications from pynotify
20----------------------------------
21
22There are a few differences from pynotify you should be aware of:
23
24- If you need callbacks from notifications, notify2 must know about your event
25  loop. The simplest way is to pass 'glib' or 'qt' as the ``mainloop`` parameter
26  to ``init``.
27- The methods ``attach_to_widget`` and ``attach_to_status_icon`` are not
28  implemented. You can calculate the location you want the notification to
29  appear and call ``Notification``.
30- ``set_property`` and ``get_property`` are not implemented. The summary, body
31  and icon are accessible as attributes of a ``Notification`` instance.
32- Various methods that pynotify Notification instances got from gobject do not
33  exist, or only implement part of the functionality.
34
35Several pynotify functions, especially getters and setters, are only supported
36for compatibility. You are encouraged to use more direct, Pythonic alternatives.
37"""
38
39import dbus
40
41__version__ = '0.3.1'
42
43# Constants
44EXPIRES_DEFAULT = -1
45EXPIRES_NEVER = 0
46
47URGENCY_LOW = 0
48URGENCY_NORMAL = 1
49URGENCY_CRITICAL = 2
50urgency_levels = [URGENCY_LOW, URGENCY_NORMAL, URGENCY_CRITICAL]
51
52# Initialise the module (following pynotify's API) -----------------------------
53
54initted = False
55appname = ""
56_have_mainloop = False
57
58class UninittedError(RuntimeError):
59    """Error raised if you try to communicate with the server before calling
60    :func:`init`.
61    """
62    pass
63
64class UninittedDbusObj(object):
65    def __getattr__(self, name):
66        raise UninittedError("You must call notify2.init() before using the "
67                             "notification features.")
68
69dbus_iface = UninittedDbusObj()
70
71def init(app_name, mainloop=None):
72    """Initialise the D-Bus connection. Must be called before you send any
73    notifications, or retrieve server info or capabilities.
74
75    To get callbacks from notifications, DBus must be integrated with a mainloop.
76    There are three ways to achieve this:
77
78    - Set a default mainloop (dbus.set_default_main_loop) before calling init()
79    - Pass the mainloop parameter as a string 'glib' or 'qt' to integrate with
80      those mainloops. (N.B. passing 'qt' currently makes that the default dbus
81      mainloop, because that's the only way it seems to work.)
82    - Pass the mainloop parameter a DBus compatible mainloop instance, such as
83      dbus.mainloop.glib.DBusGMainLoop().
84
85    If you only want to display notifications, without receiving information
86    back from them, you can safely omit mainloop.
87    """
88    global appname, initted, dbus_iface, _have_mainloop
89
90    if mainloop == 'glib':
91        from dbus.mainloop.glib import DBusGMainLoop
92        mainloop = DBusGMainLoop()
93    elif mainloop == 'qt':
94        from dbus.mainloop.qt import DBusQtMainLoop
95        # For some reason, this only works if we make it the default mainloop
96        # for dbus. That might make life tricky for anyone trying to juggle two
97        # event loops, but I can't see any way round it.
98        mainloop = DBusQtMainLoop(set_as_default=True)
99
100    bus = dbus.SessionBus(mainloop=mainloop)
101
102    dbus_obj = bus.get_object('org.freedesktop.Notifications',
103                              '/org/freedesktop/Notifications')
104    dbus_iface = dbus.Interface(dbus_obj,
105                                dbus_interface='org.freedesktop.Notifications')
106    appname = app_name
107    initted = True
108
109    if mainloop or dbus.get_default_main_loop():
110        _have_mainloop = True
111        dbus_iface.connect_to_signal('ActionInvoked', _action_callback)
112        dbus_iface.connect_to_signal('NotificationClosed', _closed_callback)
113
114    return True
115
116def is_initted():
117    """Has init() been called? Only exists for compatibility with pynotify.
118    """
119    return initted
120
121def get_app_name():
122    """Return appname. Only exists for compatibility with pynotify.
123    """
124    return appname
125
126def uninit():
127    """Undo what init() does."""
128    global initted, dbus_iface, _have_mainloop
129    initted = False
130    _have_mainloop = False
131    dbus_iface = UninittedDbusObj()
132
133# Retrieve basic server information --------------------------------------------
134
135def get_server_caps():
136    """Get a list of server capabilities.
137
138    These are short strings, listed `in the spec <http://people.gnome.org/~mccann/docs/notification-spec/notification-spec-latest.html#commands>`_.
139    Vendors may also list extra capabilities with an 'x-' prefix, e.g. 'x-canonical-append'.
140    """
141    return [str(x) for x in dbus_iface.GetCapabilities()]
142
143def get_server_info():
144    """Get basic information about the server.
145    """
146    res = dbus_iface.GetServerInformation()
147    return {'name': str(res[0]),
148             'vendor': str(res[1]),
149             'version': str(res[2]),
150             'spec-version': str(res[3]),
151            }
152
153# Action callbacks -------------------------------------------------------------
154
155notifications_registry = {}
156
157def _action_callback(nid, action):
158    nid, action = int(nid), str(action)
159    try:
160        n = notifications_registry[nid]
161    except KeyError:
162        #this message was created through some other program.
163        return
164    n._action_callback(action)
165
166def _closed_callback(nid, reason):
167    nid, reason = int(nid), int(reason)
168    try:
169        n = notifications_registry[nid]
170    except KeyError:
171        #this message was created through some other program.
172        return
173    n._closed_callback(n)
174    del notifications_registry[nid]
175
176def no_op(*args):
177    """No-op function for callbacks.
178    """
179    pass
180
181# Controlling notifications ----------------------------------------------------
182
183ActionsDictClass = dict  # fallback for old version of Python
184try:
185    from collections import OrderedDict
186    ActionsDictClass = OrderedDict
187except ImportError:
188    pass
189
190
191class Notification(object):
192    """A notification object.
193
194    summary : str
195      The title text
196    message : str
197      The body text, if the server has the 'body' capability.
198    icon : str
199      Path to an icon image, or the name of a stock icon. Stock icons available
200      in Ubuntu are `listed here <https://wiki.ubuntu.com/NotificationDevelopmentGuidelines#How_do_I_get_these_slick_icons>`_.
201      You can also set an icon from data in your application - see
202      :meth:`set_icon_from_pixbuf`.
203    """
204    id = 0
205    timeout = -1    # -1 = server default settings
206    _closed_callback = no_op
207
208    def __init__(self, summary, message='', icon=''):
209        self.summary = summary
210        self.message = message
211        self.icon = icon
212        self.hints = {}
213        self.actions = ActionsDictClass()
214        self.data = {}     # Any data the user wants to attach
215
216    def show(self):
217        """Ask the server to show the notification.
218
219        Call this after you have finished setting any parameters of the
220        notification that you want.
221        """
222        nid = dbus_iface.Notify(appname,       # app_name       (spec names)
223                              self.id,       # replaces_id
224                              self.icon,     # app_icon
225                              self.summary,  # summary
226                              self.message,  # body
227                              self._make_actions_array(),  # actions
228                              self.hints,    # hints
229                              self.timeout,  # expire_timeout
230                            )
231
232        self.id = int(nid)
233
234        if _have_mainloop:
235            notifications_registry[self.id] = self
236        return True
237
238    def update(self, summary, message="", icon=None):
239        """Replace the summary and body of the notification, and optionally its
240        icon. You should call :meth:`show` again after this to display the
241        updated notification.
242        """
243        self.summary = summary
244        self.message = message
245        if icon is not None:
246            self.icon = icon
247
248    def close(self):
249        """Ask the server to close this notification."""
250        if self.id != 0:
251            dbus_iface.CloseNotification(self.id)
252
253    def set_hint(self, key, value):
254        """n.set_hint(key, value) <--> n.hints[key] = value
255
256        See `hints in the spec <http://people.gnome.org/~mccann/docs/notification-spec/notification-spec-latest.html#hints>`_.
257
258        Only exists for compatibility with pynotify.
259        """
260        self.hints[key] = value
261
262    set_hint_string = set_hint_int32 = set_hint_double = set_hint
263
264    def set_hint_byte(self, key, value):
265        """Set a hint with a dbus byte value. The input value can be an
266        integer or a bytes string of length 1.
267        """
268        self.hints[key] = dbus.Byte(value)
269
270    def set_urgency(self, level):
271        """Set the urgency level to one of URGENCY_LOW, URGENCY_NORMAL or
272        URGENCY_CRITICAL.
273        """
274        if level not in urgency_levels:
275            raise ValueError("Unknown urgency level specified", level)
276        self.set_hint_byte("urgency", level)
277
278    def set_category(self, category):
279        """Set the 'category' hint for this notification.
280
281        See `categories in the spec <http://people.gnome.org/~mccann/docs/notification-spec/notification-spec-latest.html#categories>`_.
282        """
283        self.hints['category'] = category
284
285    def set_timeout(self, timeout):
286        """Set the display duration in milliseconds, or one of the special
287        values EXPIRES_DEFAULT or EXPIRES_NEVER. This is a request, which the
288        server might ignore.
289
290        Only exists for compatibility with pynotify; you can simply set::
291
292          n.timeout = 5000
293        """
294        if not isinstance(timeout, int):
295            raise TypeError("timeout value was not int", timeout)
296        self.timeout = timeout
297
298    def get_timeout(self):
299        """Return the timeout value for this notification.
300
301        Only exists for compatibility with pynotify; you can inspect the
302        timeout attribute directly.
303        """
304        return self.timeout
305
306    def add_action(self, action, label, callback, user_data=None):
307        """Add an action to the notification.
308
309        Check for the 'actions' server capability before using this.
310
311        action : str
312          A brief key.
313        label : str
314          The text displayed on the action button
315        callback : callable
316          A function taking at 2-3 parameters: the Notification object, the
317          action key and (if specified) the user_data.
318        user_data :
319          An extra argument to pass to the callback.
320        """
321        self.actions[action] = (label, callback, user_data)
322
323    def _make_actions_array(self):
324        """Make the actions array to send over DBus.
325        """
326        arr = []
327        for action, (label, callback, user_data) in self.actions.items():
328            arr.append(action)
329            arr.append(label)
330        return arr
331
332    def _action_callback(self, action):
333        """Called when the user selects an action on the notification, to
334        dispatch it to the relevant user-specified callback.
335        """
336        try:
337            label, callback, user_data = self.actions[action]
338        except KeyError:
339            return
340
341        if user_data is None:
342            callback(self, action)
343        else:
344            callback(self, action, user_data)
345
346    def connect(self, event, callback):
347        """Set the callback for the notification closing; the only valid value
348        for event is 'closed' (the parameter is kept for compatibility with pynotify).
349
350        The callback will be called with the :class:`Notification` instance.
351        """
352        if event != 'closed':
353            raise ValueError("'closed' is the only valid value for event", event)
354        self._closed_callback = callback
355
356    def set_data(self, key, value):
357        """n.set_data(key, value) <--> n.data[key] = value
358
359        Only exists for compatibility with pynotify.
360        """
361        self.data[key] = value
362
363    def get_data(self, key):
364        """n.get_data(key) <--> n.data[key]
365
366        Only exists for compatibility with pynotify.
367        """
368        return self.data[key]
369
370    def set_icon_from_pixbuf(self, icon):
371        """Set a custom icon from a GdkPixbuf.
372        """
373        struct = (
374            icon.get_width(),
375            icon.get_height(),
376            icon.get_rowstride(),
377            icon.get_has_alpha(),
378            icon.get_bits_per_sample(),
379            icon.get_n_channels(),
380            dbus.ByteArray(icon.get_pixels())
381            )
382        self.hints['icon_data'] = struct
383
384    def set_location(self, x, y):
385        """Set the notification location as (x, y), if the server supports it.
386        """
387        if (not isinstance(x, int)) or (not isinstance(y, int)):
388            raise TypeError("x and y must both be ints", (x,y))
389        self.hints['x'] = x
390        self.hints['y'] = y
391
392