diff --git a/CHANGES.rst b/CHANGES.rst index d19d8f8..5af0d3a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,7 @@ Changes 0.3.0.dev --------- +- Notifications: fixes, cleanup, and tests #131 - Fix slider for some older macOS versions (10.11 and before?) - Keyboard interrupts now stop a running application diff --git a/rumps/__init__.py b/rumps/__init__.py index 7f151ef..187042e 100644 --- a/rumps/__init__.py +++ b/rumps/__init__.py @@ -22,5 +22,9 @@ __license__ = 'Modified BSD' __copyright__ = 'Copyright 2020 Jared Suttles' -from .rumps import (separator, debug_mode, alert, notification, application_support, timers, quit_application, timer, - clicked, notifications, MenuItem, SliderMenuItem, Timer, Window, App, slider) +from . import notifications as _notifications +from .rumps import (separator, debug_mode, alert, application_support, timers, quit_application, timer, + clicked, MenuItem, SliderMenuItem, Timer, Window, App, slider) + +notifications = _notifications.on_notification +notification = _notifications.notify diff --git a/rumps/_internal.py b/rumps/_internal.py new file mode 100644 index 0000000..a04ae7c --- /dev/null +++ b/rumps/_internal.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- + +from __future__ import print_function + +import inspect +import traceback + +import Foundation + +from . import compat +from . import exceptions + + +def require_string(*objs): + for obj in objs: + if not isinstance(obj, compat.string_types): + raise TypeError( + 'a string is required but given {0}, a {1}'.format(obj, type(obj).__name__) + ) + + +def require_string_or_none(*objs): + for obj in objs: + if not(obj is None or isinstance(obj, compat.string_types)): + raise TypeError( + 'a string or None is required but given {0}, a {1}'.format(obj, type(obj).__name__) + ) + + +def call_as_function_or_method(func, event): + # The idea here is that when using decorators in a class, the functions passed are not bound so we have to + # determine later if the functions we have (those saved as callbacks) for particular events need to be passed + # 'self'. + # + # This works for an App subclass method or a standalone decorated function. Will attempt to find function as + # a bound method of the App instance. If it is found, use it, otherwise simply call function. + from . import rumps + try: + app = getattr(rumps.App, '*app_instance') + except AttributeError: + pass + else: + for name, method in inspect.getmembers(app, predicate=inspect.ismethod): + if method.__func__ is func: + return method(event) + return func(event) + + +def guard_unexpected_errors(func): + """Decorator to be used in PyObjC callbacks where an error bubbling up + would cause a crash. Instead of crashing, print the error to stderr and + prevent passing to PyObjC layer. + + For Python 3, print the exception using chaining. Accomplished by setting + the cause of :exc:`rumps.exceptions.InternalRumpsError` to the exception. + + For Python 2, emulate exception chaining by printing the original exception + followed by :exc:`rumps.exceptions.InternalRumpsError`. + """ + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + + except Exception as e: + internal_error = exceptions.InternalRumpsError( + 'an unexpected error occurred within an internal callback' + ) + if compat.PY2: + import sys + traceback.print_exc() + print('\nThe above exception was the direct cause of the following exception:\n', file=sys.stderr) + traceback.print_exception(exceptions.InternalRumpsError, internal_error, None) + else: + internal_error.__cause__ = e + traceback.print_exception(exceptions.InternalRumpsError, internal_error, None) + + return wrapper + + +def string_to_objc(x): + if isinstance(x, compat.binary_type): + return Foundation.NSData.alloc().initWithData_(x) + elif isinstance(x, compat.string_types): + return Foundation.NSString.alloc().initWithString_(x) + else: + raise TypeError( + "expected a string or a bytes-like object but provided %s, " + "having type '%s'" % ( + x, + type(x).__name__ + ) + ) diff --git a/rumps/compat.py b/rumps/compat.py index ac3a7ec..e17d45a 100644 --- a/rumps/compat.py +++ b/rumps/compat.py @@ -15,13 +15,19 @@ PY2 = sys.version_info[0] == 2 if not PY2: + binary_type = bytes text_type = str string_types = (str,) iteritems = lambda d: iter(d.items()) + import collections.abc as collections_abc + else: + binary_type = () text_type = unicode string_types = (str, unicode) iteritems = lambda d: d.iteritems() + + import collections as collections_abc diff --git a/rumps/exceptions.py b/rumps/exceptions.py new file mode 100644 index 0000000..05c6d46 --- /dev/null +++ b/rumps/exceptions.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- + + +class RumpsError(Exception): + """A generic rumps error occurred.""" + + +class InternalRumpsError(RumpsError): + """Internal mechanism powering functionality of rumps failed.""" diff --git a/rumps/notifications.py b/rumps/notifications.py new file mode 100644 index 0000000..08d048b --- /dev/null +++ b/rumps/notifications.py @@ -0,0 +1,267 @@ +# -*- coding: utf-8 -*- + +_ENABLED = True +try: + from Foundation import NSUserNotification, NSUserNotificationCenter +except ImportError: + _ENABLED = False + +import datetime +import os +import sys +import traceback + +import Foundation + +from . import _internal +from . import compat + + +def on_notification(f): + """Decorator for registering a function to serve as a "notification center" + for the application. This function will receive the data associated with an + incoming macOS notification sent using :func:`rumps.notification`. This + occurs whenever the user clicks on a notification for this application in + the macOS Notification Center. + + .. code-block:: python + + @rumps.notifications + def notification_center(info): + if 'unix' in info: + print 'i know this' + + """ + on_notification.__dict__['*'] = f + return f + + +def _gather_info_issue_9(): # pragma: no cover + missing_plist = False + missing_bundle_ident = False + info_plist_path = os.path.join(os.path.dirname(sys.executable), 'Info.plist') + try: + with open(info_plist_path) as f: + import plistlib + try: + load_plist = plistlib.load + except AttributeError: + load_plist = plistlib.readPlist + try: + load_plist(f)['CFBundleIdentifier'] + except Exception: + missing_bundle_ident = True + + except IOError as e: + import errno + if e.errno == errno.ENOENT: # No such file or directory + missing_plist = True + + info = '\n\n' + if missing_plist: + info += 'In this case there is no file at "%(info_plist_path)s"' + info += '\n\n' + confidence = 'should' + elif missing_bundle_ident: + info += 'In this case the file at "%(info_plist_path)s" does not contain a value for "CFBundleIdentifier"' + info += '\n\n' + confidence = 'should' + else: + confidence = 'may' + info += 'Running the following command %(confidence)s fix the issue:\n' + info += '/usr/libexec/PlistBuddy -c \'Add :CFBundleIdentifier string "rumps"\' %(info_plist_path)s\n' + return info % {'info_plist_path': info_plist_path, 'confidence': confidence} + + +def _default_user_notification_center(): + notification_center = NSUserNotificationCenter.defaultUserNotificationCenter() + if notification_center is None: # pragma: no cover + info = ( + 'Failed to setup the notification center. This issue occurs when the "Info.plist" file ' + 'cannot be found or is missing "CFBundleIdentifier".' + ) + try: + info += _gather_info_issue_9() + except Exception: + pass + raise RuntimeError(info) + else: + return notification_center + + +def _init_nsapp(nsapp): + if _ENABLED: + try: + notification_center = _default_user_notification_center() + except RuntimeError: + pass + else: + notification_center.setDelegate_(nsapp) + + +@_internal.guard_unexpected_errors +def _clicked(ns_user_notification_center, ns_user_notification): + from . import rumps + + ns_user_notification_center.removeDeliveredNotification_(ns_user_notification) + ns_dict = ns_user_notification.userInfo() + if ns_dict is None: + data = None + else: + dumped = ns_dict['value'] + app = getattr(rumps.App, '*app_instance', rumps.App) + try: + data = app.serializer.loads(dumped) + except Exception: + traceback.print_exc() + return + + try: + notification_handler = getattr(on_notification, '*') + except AttributeError: + # notification center function not specified, no error but log warning + rumps._log( + 'WARNING: notification received but no function specified for ' + 'answering it; use @notifications decorator to register a function.' + ) + else: + notification = Notification(ns_user_notification, data) + try: + _internal.call_as_function_or_method(notification_handler, notification) + except Exception: + traceback.print_exc() + + +def notify(title, subtitle, message, data=None, sound=True, + action_button=None, other_button=None, has_reply_button=False, + icon=None): + """Send a notification to Notification Center (OS X 10.8+). If running on a + version of macOS that does not support notifications, a ``RuntimeError`` + will be raised. Apple says, + + "The userInfo content must be of reasonable serialized size (less than + 1k) or an exception will be thrown." + + So don't do that! + + :param title: text in a larger font. + :param subtitle: text in a smaller font below the `title`. + :param message: text representing the body of the notification below the + `subtitle`. + :param data: will be passed to the application's "notification center" (see + :func:`rumps.notifications`) when this notification is clicked. + :param sound: whether the notification should make a noise when it arrives. + :param action_button: title for the action button. + :param other_button: title for the other button. + :param has_reply_button: whether or not the notification has a reply button. + :param icon: the filename of an image for the notification's icon, will + replace the default. + """ + from . import rumps + + if not _ENABLED: + raise RuntimeError('OS X 10.8+ is required to send notifications') + + _internal.require_string_or_none(title, subtitle, message) + + notification = NSUserNotification.alloc().init() + + notification.setTitle_(title) + notification.setSubtitle_(subtitle) + notification.setInformativeText_(message) + + if data is not None: + app = getattr(rumps.App, '*app_instance', rumps.App) + dumped = app.serializer.dumps(data) + objc_string = _internal.string_to_objc(dumped) + ns_dict = Foundation.NSMutableDictionary.alloc().init() + ns_dict.setDictionary_({'value': objc_string}) + notification.setUserInfo_(ns_dict) + + if icon is not None: + notification.set_identityImage_(rumps._nsimage_from_file(icon)) + if sound: + notification.setSoundName_("NSUserNotificationDefaultSoundName") + if action_button: + notification.setActionButtonTitle_(action_button) + notification.set_showsButtons_(True) + if other_button: + notification.setOtherButtonTitle_(other_button) + notification.set_showsButtons_(True) + if has_reply_button: + notification.setHasReplyButton_(True) + + notification.setDeliveryDate_(Foundation.NSDate.dateWithTimeInterval_sinceDate_(0, Foundation.NSDate.date())) + notification_center = _default_user_notification_center() + notification_center.scheduleNotification_(notification) + + +class Notification(compat.collections_abc.Mapping): + def __init__(self, ns_user_notification, data): + self._ns = ns_user_notification + self._data = data + + def __repr__(self): + return '<{0}: [data: {1}]>'.format(type(self).__name__, repr(self._data)) + + @property + def title(self): + return compat.text_type(self._ns.title()) + + @property + def subtitle(self): + return compat.text_type(self._ns.subtitle()) + + @property + def message(self): + return compat.text_type(self._ns.informativeText()) + + @property + def activation_type(self): + activation_type = self._ns.activationType() + if activation_type == 1: + return 'contents_clicked' + elif activation_type == 2: + return 'action_button_clicked' + elif activation_type == 3: + return 'replied' + elif activation_type == 4: + return 'additional_action_clicked' + + @property + def delivered_at(self): + ns_date = self._ns.actualDeliveryDate() + seconds = ns_date.timeIntervalSince1970() + dt = datetime.datetime.fromtimestamp(seconds) + return dt + + @property + def response(self): + ns_attributed_string = self._ns.response() + if ns_attributed_string is None: + return None + ns_string = ns_attributed_string.string() + return compat.text_type(ns_string) + + @property + def data(self): + return self._data + + def _check_if_mapping(self): + if not isinstance(self._data, compat.collections_abc.Mapping): + raise TypeError( + 'notification cannot be used as a mapping when data is not a ' + 'mapping' + ) + + def __getitem__(self, key): + self._check_if_mapping() + return self._data[key] + + def __iter__(self): + self._check_if_mapping() + return iter(self._data) + + def __len__(self): + self._check_if_mapping() + return len(self._data) diff --git a/rumps/rumps.py b/rumps/rumps.py index 4d64d42..77a56df 100644 --- a/rumps/rumps.py +++ b/rumps/rumps.py @@ -5,33 +5,26 @@ # License: BSD, see LICENSE for details. -_NOTIFICATIONS = True - # For compatibility with pyinstaller # See: http://stackoverflow.com/questions/21058889/pyinstaller-not-finding-pyobjc-library-macos-python import Foundation import AppKit -try: - from Foundation import NSUserNotification, NSUserNotificationCenter -except ImportError: - _NOTIFICATIONS = False - from Foundation import (NSDate, NSTimer, NSRunLoop, NSDefaultRunLoopMode, NSSearchPathForDirectoriesInDomains, NSMakeRect, NSLog, NSObject, NSMutableDictionary, NSString) from AppKit import NSApplication, NSStatusBar, NSMenu, NSMenuItem, NSAlert, NSTextField, NSSecureTextField, NSImage, NSSlider, NSSize, NSWorkspace, NSWorkspaceWillSleepNotification, NSWorkspaceDidWakeNotification from PyObjCTools import AppHelper -import inspect import os import pickle -import sys import traceback import weakref -from collections import Mapping, Iterable from .utils import ListDict -from .compat import text_type, string_types, iteritems +from .compat import text_type, string_types, iteritems, collections_abc + +from . import _internal +from . import notifications _TIMERS = weakref.WeakKeyDictionary() separator = object() @@ -77,7 +70,7 @@ def alert(title=None, message='', ok=None, cancel=None, other=None, icon_path=No message = message.replace('%', '%%') if title is not None: title = text_type(title) - _require_string_or_none(ok) + _internal.require_string_or_none(ok) if not isinstance(cancel, string_types): cancel = 'Cancel' if cancel else None alert = NSAlert.alertWithMessageText_defaultButton_alternateButton_otherButton_informativeTextWithFormat_( @@ -90,119 +83,6 @@ def alert(title=None, message='', ok=None, cancel=None, other=None, icon_path=No return alert.runModal() -def _gather_info_issue_9(): - missing_plist = False - missing_bundle_ident = False - info_plist_path = os.path.join(os.path.dirname(sys.executable), 'Info.plist') - try: - with open(info_plist_path) as f: - import plistlib - try: - load_plist = plistlib.load - except AttributeError: - load_plist = plistlib.readPlist - try: - load_plist(f)['CFBundleIdentifier'] - except Exception: - missing_bundle_ident = True - - except IOError as e: - import errno - if e.errno == errno.ENOENT: # No such file or directory - missing_plist = True - - info = '\n\n' - if missing_plist: - info += 'In this case there is no file at "%(info_plist_path)s"' - info += '\n\n' - confidence = 'should' - elif missing_bundle_ident: - info += 'In this case the file at "%(info_plist_path)s" does not contain a value for "CFBundleIdentifier"' - info += '\n\n' - confidence = 'should' - else: - confidence = 'may' - info += 'Running the following command %(confidence)s fix the issue:\n' - info += '/usr/libexec/PlistBuddy -c \'Add :CFBundleIdentifier string "rumps"\' %(info_plist_path)s\n' - return info % {'info_plist_path': info_plist_path, 'confidence': confidence} - - -def _default_user_notification_center(): - notification_center = NSUserNotificationCenter.defaultUserNotificationCenter() - if notification_center is None: - info = ( - 'Failed to setup the notification center. This issue occurs when the "Info.plist" file ' - 'cannot be found or is missing "CFBundleIdentifier".' - ) - try: - info += _gather_info_issue_9() - except Exception: - pass - raise RuntimeError(info) - else: - return notification_center - - -def notification(title, subtitle, message, data=None, sound=True, action_button=None, other_button=None, - has_reply_button=False, icon=None): - """Send a notification to Notification Center (OS X 10.8+). If running on a version of macOS that does not - support notifications, a ``RuntimeError`` will be raised. Apple says, - - "The userInfo content must be of reasonable serialized size (less than 1k) or an exception will be thrown." - - So don't do that! - - :param title: text in a larger font. - :param subtitle: text in a smaller font below the `title`. - :param message: text representing the body of the notification below the `subtitle`. - :param data: will be passed to the application's "notification center" (see :func:`rumps.notifications`) when this - notification is clicked. - :param sound: whether the notification should make a noise when it arrives. - :param action_button: title for the action button. - :param other_button: title for the other button. - :param has_reply_button: whether or not the notification has a reply button. - :param icon: the filename of an image for the notification's icon, will replace the default. - """ - if not _NOTIFICATIONS: - raise RuntimeError('OS X 10.8+ is required to send notifications') - - if data is not None and not isinstance(data, Mapping): - raise TypeError('notification data must be a mapping') - - _require_string_or_none(title, subtitle, message) - - notification = NSUserNotification.alloc().init() - - notification.setTitle_(title) - notification.setSubtitle_(subtitle) - notification.setInformativeText_(message) - - if data is not None: - app = getattr(App, '*app_instance') - dumped = app.serializer.dumps(data) - ns_dict = NSMutableDictionary.alloc().init() - ns_string = NSString.alloc().initWithString_(dumped) - ns_dict.setDictionary_({'value': ns_string}) - notification.setUserInfo_(ns_dict) - - if icon is not None: - notification.set_identityImage_(_nsimage_from_file(icon)) - if sound: - notification.setSoundName_("NSUserNotificationDefaultSoundName") - if action_button: - notification.setActionButtonTitle_(action_button) - notification.set_showsButtons_(True) - if other_button: - notification.setOtherButtonTitle_(other_button) - notification.set_showsButtons_(True) - if has_reply_button: - notification.setHasReplyButton_(True) - - notification.setDeliveryDate_(NSDate.dateWithTimeInterval_sinceDate_(0, NSDate.date())) - notification_center = _default_user_notification_center() - notification_center.scheduleNotification_(notification) - - def application_support(name): """Return the application support folder path for the given `name`, creating it if it doesn't exist.""" app_support_path = os.path.join(NSSearchPathForDirectoriesInDomains(14, 1, 1).objectAtIndex_(0), name) @@ -247,18 +127,6 @@ def _nsimage_from_file(filename, dimensions=None, template=None): return image -def _require_string(*objs): - for obj in objs: - if not isinstance(obj, string_types): - raise TypeError('a string is required but given {0}, a {1}'.format(obj, type(obj).__name__)) - - -def _require_string_or_none(*objs): - for obj in objs: - if not(obj is None or isinstance(obj, string_types)): - raise TypeError('a string or None is required but given {0}, a {1}'.format(obj, type(obj).__name__)) - - # Decorators and helper function serving to register functions for dealing with interaction and events #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def timer(interval): @@ -364,36 +232,6 @@ def register_click(self): return f return decorator - -def notifications(f): - """Decorator for registering a function to serve as a "notification center" for the application. This function will - receive the data associated with an incoming macOS notification sent using :func:`rumps.notification`. This occurs - whenever the user clicks on a notification for this application in the macOS Notification Center. - - .. code-block:: python - - @rumps.notifications - def notification_center(info): - if 'unix' in info: - print 'i know this' - - """ - notifications.__dict__['*notification_center'] = f - return f - - -def _call_as_function_or_method(func, event): - # The idea here is that when using decorators in a class, the functions passed are not bound so we have to - # determine later if the functions we have (those saved as callbacks) for particular events need to be passed - # 'self'. - # - # This works for an App subclass method or a standalone decorated function. Will attempt to find function as - # a bound method of the App instance. If it is found, use it, otherwise simply call function. - app = getattr(App, '*app_instance') - for name, method in inspect.getmembers(app, predicate=inspect.ismethod): - if method.__func__ is func: - return method(event) - return func(event) #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -467,14 +305,14 @@ def parse_menu(iterable, menu, depth): menu.add(iterable) return - for n, ele in enumerate(iteritems(iterable) if isinstance(iterable, Mapping) else iterable): + for n, ele in enumerate(iteritems(iterable) if isinstance(iterable, collections_abc.Mapping) else iterable): # for mappings we recurse but don't drop down a level in the menu - if not isinstance(ele, MenuItem) and isinstance(ele, Mapping): + if not isinstance(ele, MenuItem) and isinstance(ele, collections_abc.Mapping): parse_menu(ele, menu, depth) # any iterables other than strings and MenuItems - elif not isinstance(ele, (string_types, MenuItem)) and isinstance(ele, Iterable): + elif not isinstance(ele, (string_types, MenuItem)) and isinstance(ele, collections_abc.Iterable): try: menuitem, submenu = ele except TypeError: @@ -702,7 +540,7 @@ def set_callback(self, callback, key=None): :param callback: the function to be called when the user clicks on this menu item. :param key: the key shortcut to click this menu item. """ - _require_string_or_none(key) + _internal.require_string_or_none(key) if key is not None: self._menuitem.setKeyEquivalent_(key) NSApp._ns_to_py_and_callback[self._menuitem] = self, callback @@ -859,9 +697,9 @@ def set_callback(self, callback): def callback_(self, _): _log(self) try: - return _call_as_function_or_method(getattr(self, '*callback'), self) + return _internal.call_as_function_or_method(getattr(self, '*callback'), self) except Exception: - _log(traceback.format_exc()) + traceback.print_exc() class Window(object): @@ -898,7 +736,7 @@ def __init__(self, message='', title='', default_text='', ok=None, cancel=None, self._cancel = bool(cancel) self._icon = None - _require_string_or_none(ok) + _internal.require_string_or_none(ok) if not isinstance(cancel, string_types): cancel = 'Cancel' if cancel else None @@ -981,7 +819,7 @@ def add_button(self, name): :param name: the text for a new button. Must be a string. """ - _require_string(name) + _internal.require_string(name) self._alert.addButtonWithTitle_(name) def add_buttons(self, iterable=None, *args): @@ -1061,27 +899,7 @@ class NSApp(NSObject): _ns_to_py_and_callback = {} def userNotificationCenter_didActivateNotification_(self, notification_center, notification): - notification_center.removeDeliveredNotification_(notification) - ns_dict = notification.userInfo() - if ns_dict is None: - data = None - else: - dumped = ns_dict['value'] - app = getattr(App, '*app_instance') - data = app.serializer.loads(dumped) - - try: - notification_function = getattr(notifications, '*notification_center') - except AttributeError: # notification center function not specified -> no error but warning in log - _log('WARNING: notification received but no function specified for answering it; use @notifications ' - 'decorator to register a function.') - else: - try: - data['activationType'] = notification.activationType() - data['actualDeliveryDate'] = notification.actualDeliveryDate() - _call_as_function_or_method(notification_function, data) - except Exception: - _log(traceback.format_exc()) + notifications._clicked(notification_center, notification) def initializeStatusBar(self): self.nsstatusitem = NSStatusBar.systemStatusBar().statusItemWithLength_(-1) # variable dimensions @@ -1143,9 +961,9 @@ def callback_(cls, nsmenuitem): self, callback = cls._ns_to_py_and_callback[nsmenuitem] _log(self) try: - return _call_as_function_or_method(callback, self) + return _internal.call_as_function_or_method(callback, self) except Exception: - _log(traceback.format_exc()) + traceback.print_exc() class App(object): @@ -1175,7 +993,7 @@ class App(object): serializer = pickle def __init__(self, name, title=None, icon=None, template=None, menu=None, quit_button='Quit'): - _require_string(name) + _internal.require_string(name) self._name = name self._icon = self._icon_nsimage = self._title = None self._template = template @@ -1211,7 +1029,7 @@ def title(self): @title.setter def title(self, title): - _require_string_or_none(title) + _internal.require_string_or_none(title) self._title = title try: self._nsapp.setStatusBarTitle() @@ -1333,13 +1151,7 @@ def run(self, **options): self._nsapp = NSApp.alloc().init() self._nsapp._app = self.__dict__ # allow for dynamic modification based on this App instance nsapplication.setDelegate_(self._nsapp) - if _NOTIFICATIONS: - try: - notification_center = _default_user_notification_center() - except RuntimeError: - pass - else: - notification_center.setDelegate_(self._nsapp) + notifications._init_nsapp(self._nsapp) setattr(App, '*app_instance', self) # class level ref to running instance (for passing self to App subclasses) t = b = None diff --git a/setup.py b/setup.py index 3d4253f..f1bf4b7 100644 --- a/setup.py +++ b/setup.py @@ -56,6 +56,7 @@ def fix_virtualenv(): extras_require={ 'dev': [ 'pytest>=4.3', + 'pytest-mock>=2.0.0', 'tox>=3.8' ] }, diff --git a/tests/test_internal.py b/tests/test_internal.py new file mode 100644 index 0000000..f042597 --- /dev/null +++ b/tests/test_internal.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- + +import pytest + +from rumps._internal import guard_unexpected_errors + + +class TestGuardUnexpectedErrors(object): + def test_raises(self, capfd): + + @guard_unexpected_errors + def callback_func(): + raise ValueError('-.-') + + callback_func() + + captured = capfd.readouterr() + assert not captured.out + assert captured.err.strip().startswith('Traceback (most recent call last):') + assert captured.err.strip().endswith('''ValueError: -.- + +The above exception was the direct cause of the following exception: + +rumps.exceptions.InternalRumpsError: an unexpected error occurred within an internal callback''') + + def test_no_raises(self, capfd): + + @guard_unexpected_errors + def callback_func(): + return 88 * 2 + + assert callback_func() == 176 + + captured = capfd.readouterr() + assert not captured.out + assert not captured.err diff --git a/tests/test_notifications.py b/tests/test_notifications.py new file mode 100644 index 0000000..d93cb3f --- /dev/null +++ b/tests/test_notifications.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- + +import pytest + +import rumps + +# do this hacky thing +notifications = rumps._notifications +notify = notifications.notify +_clicked = notifications._clicked +on_notification = notifications.on_notification +Notification = notifications.Notification + + +class NSUserNotificationCenterMock: + def __init__(self): + self.ns_user_notification = None + + def scheduleNotification_(self, ns_user_notification): + self.ns_user_notification = ns_user_notification + + def removeDeliveredNotification_(self, ns_user_notification): + assert ns_user_notification is self.ns_user_notification + self.ns_user_notification = None + + +class TestNotify: + path = 'rumps._notifications._default_user_notification_center' + + def test_simple(self, mocker): + """Simple notification is created and scheduled. The internal callback + handler does not raise any exceptions when processing the notification. + """ + + ns_user_notification_center_mock = NSUserNotificationCenterMock() + mocker.patch(self.path, new=lambda: ns_user_notification_center_mock) + + assert ns_user_notification_center_mock.ns_user_notification is None + notify( + 'a', + 'b', + 'c' + ) + assert ns_user_notification_center_mock.ns_user_notification is not None + _clicked( + ns_user_notification_center_mock, + ns_user_notification_center_mock.ns_user_notification + ) + assert ns_user_notification_center_mock.ns_user_notification is None + + def test_with_data(self, mocker): + """Notification that contains serializable data.""" + + ns_user_notification_center_mock = NSUserNotificationCenterMock() + mocker.patch(self.path, new=lambda: ns_user_notification_center_mock) + + @on_notification + def user_defined_notification_callback(notification): + assert notification.data == ['any', {'pickable': 'object'}, 'by', 'default'] + + assert ns_user_notification_center_mock.ns_user_notification is None + notify( + 'a', + 'b', + 'c', + data=['any', {'pickable': 'object'}, 'by', 'default'] + ) + assert ns_user_notification_center_mock.ns_user_notification is not None + _clicked( + ns_user_notification_center_mock, + ns_user_notification_center_mock.ns_user_notification + ) + assert ns_user_notification_center_mock.ns_user_notification is None + + +class TestNotification: + def test_can_access_data(self): + n = Notification(None, 'some test data') + assert n.data == 'some test data' + + def test_can_use_data_as_mapping(self): + n = Notification(None, {2: 22, 3: 333}) + assert n[2] == 22 + assert 3 in n + assert len(n) == 2 + assert list(n) == [2, 3] + + def test_raises_typeerror_when_no_mapping(self): + n = Notification(None, [4, 55, 666]) + with pytest.raises(TypeError) as excinfo: + n[2] + assert 'cannot be used as a mapping' in str(excinfo.value) + + +class TestDefaultUserNotificationCenter: + def test_basic(self): + """Ensure we can obtain a PyObjC default notification center object.""" + ns_user_notification_center = notifications._default_user_notification_center() + assert type(ns_user_notification_center).__name__ == '_NSConcreteUserNotificationCenter' + + +class TestInitNSApp: + def test_calls(self, mocker): + """Is the method called as expected?""" + path = 'rumps._notifications._default_user_notification_center' + mock_func = mocker.patch(path) + ns_app_fake = object() + notifications._init_nsapp(ns_app_fake) + mock_func().setDelegate_.assert_called_once_with(ns_app_fake) + + def test_exists(self): + """Does the method exist in the framework?""" + ns_user_notification_center = notifications._default_user_notification_center() + ns_app_fake = object() + ns_user_notification_center.setDelegate_(ns_app_fake) diff --git a/tox.ini b/tox.ini index 7d7c249..c3d1f77 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27,py34,py35,py36,py37 +envlist = py27,py35,py36,py37,py38 [testenv] whitelist_externals = pytest