diff --git a/MANIFEST.in b/MANIFEST.in index 44106112..a7f761aa 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,6 +2,7 @@ include LICENSE include README.md include README include requirements.txt +include win-requirements.txt include dev-requirements.txt recursive-include test * global-exclude *.pyc diff --git a/README b/README index d90c6ec1..d7af4cd3 100644 --- a/README +++ b/README @@ -119,6 +119,10 @@ The table below identifies the services this tool supports and some example serv xbmc://user@hostname xbmc://user:password@hostname:port + +* Windows Notifications + windows:// + Email Support ------------- * mailto:// diff --git a/README.md b/README.md index 7384d35e..da42622b 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ The table below identifies the services this tool supports and some example serv | [Telegram](https://github.com/caronc/apprise/wiki/Notify_telegram) | tgram:// | (TCP) 443 | tgram://bottoken/ChatID
tgram://bottoken/ChatID1/ChatID2/ChatIDN | [Twitter](https://github.com/caronc/apprise/wiki/Notify_twitter) | tweet:// | (TCP) 443 | tweet://user@CKey/CSecret/AKey/ASecret | [XBMC](https://github.com/caronc/apprise/wiki/Notify_xbmc) | xbmc:// or xbmcs:// | (TCP) 8080 or 443 | xbmc://hostname
xbmc://user@hostname
xbmc://user:password@hostname:port +| [Windows Notification](https://github.com/caronc/apprise/wiki/Notify_windows) | windows:// | n/a | windows:// + ### Email Support | Service ID | Default Port | Example Syntax | diff --git a/apprise/Apprise.py b/apprise/Apprise.py index 42e5e624..188d475c 100644 --- a/apprise/Apprise.py +++ b/apprise/Apprise.py @@ -204,6 +204,8 @@ class Apprise(object): instance = Apprise.instantiate(_server, asset=asset) if not instance: return_status = False + logging.error( + "Failed to load notification url: {}".format(_server)) continue # Add our initialized plugin to our server listings @@ -261,10 +263,15 @@ class Apprise(object): # Toggle our return status flag status = False + except TypeError: + # These our our internally thrown notifications + # TODO: Change this to a custom one such as AppriseNotifyError + status = False + except Exception: # A catch all so we don't have to abort early # just because one of our plugins has a bug in it. - logging.exception("notification exception") + logging.exception("Notification Exception") status = False return status diff --git a/apprise/AppriseAsset.py b/apprise/AppriseAsset.py index 5353357a..5bbba2a9 100644 --- a/apprise/AppriseAsset.py +++ b/apprise/AppriseAsset.py @@ -54,12 +54,16 @@ class AppriseAsset(object): # The default color to return if a mapping isn't found in our table above default_html_color = '#888888' + # The default image extension to use + default_extension = '.png' + # The default theme theme = 'default' # Image URL Mask image_url_mask = \ - 'http://nuxref.com/apprise/themes/{THEME}/apprise-{TYPE}-{XY}.png' + 'http://nuxref.com/apprise/themes/{THEME}/' \ + 'apprise-{TYPE}-{XY}{EXTENSION}' # Application Logo image_url_logo = \ @@ -71,11 +75,11 @@ class AppriseAsset(object): 'assets', 'themes', '{THEME}', - 'apprise-{TYPE}-{XY}.png', + 'apprise-{TYPE}-{XY}{EXTENSION}', )) def __init__(self, theme='default', image_path_mask=None, - image_url_mask=None): + image_url_mask=None, default_extension=None): """ Asset Initialization @@ -89,6 +93,9 @@ class AppriseAsset(object): if image_url_mask is not None: self.image_url_mask = image_url_mask + if default_extension is not None: + self.default_extension = default_extension + def color(self, notify_type, color_type=None): """ Returns an HTML mapped color based on passed in notify type @@ -121,7 +128,7 @@ class AppriseAsset(object): raise ValueError( 'AppriseAsset html_color(): An invalid color_type was specified.') - def image_url(self, notify_type, image_size, logo=False): + def image_url(self, notify_type, image_size, logo=False, extension=None): """ Apply our mask to our image URL @@ -134,10 +141,14 @@ class AppriseAsset(object): # No image to return return None + if extension is None: + extension = self.default_extension + re_map = { '{THEME}': self.theme if self.theme else '', '{TYPE}': notify_type, '{XY}': image_size, + '{EXTENSION}': extension, } # Iterate over above list and store content accordingly @@ -148,7 +159,8 @@ class AppriseAsset(object): return re_table.sub(lambda x: re_map[x.group()], url_mask) - def image_path(self, notify_type, image_size, must_exist=True): + def image_path(self, notify_type, image_size, must_exist=True, + extension=None): """ Apply our mask to our image file path @@ -158,10 +170,14 @@ class AppriseAsset(object): # No image to return return None + if extension is None: + extension = self.default_extension + re_map = { '{THEME}': self.theme if self.theme else '', '{TYPE}': notify_type, '{XY}': image_size, + '{EXTENSION}': extension, } # Iterate over above list and store content accordingly @@ -178,13 +194,17 @@ class AppriseAsset(object): # Return what we parsed return path - def image_raw(self, notify_type, image_size): + def image_raw(self, notify_type, image_size, extension=None): """ Returns the raw image if it can (otherwise the function returns None) """ - path = self.image_path(notify_type=notify_type, image_size=image_size) + path = self.image_path( + notify_type=notify_type, + image_size=image_size, + extension=extension, + ) if path: try: with open(path, 'rb') as fd: diff --git a/apprise/assets/themes/default/apprise-failure-128x128.ico b/apprise/assets/themes/default/apprise-failure-128x128.ico new file mode 100644 index 00000000..72e69091 Binary files /dev/null and b/apprise/assets/themes/default/apprise-failure-128x128.ico differ diff --git a/apprise/assets/themes/default/apprise-info-128x128.ico b/apprise/assets/themes/default/apprise-info-128x128.ico new file mode 100644 index 00000000..2d72c5b7 Binary files /dev/null and b/apprise/assets/themes/default/apprise-info-128x128.ico differ diff --git a/apprise/assets/themes/default/apprise-success-128x128.ico b/apprise/assets/themes/default/apprise-success-128x128.ico new file mode 100644 index 00000000..de8cc6b0 Binary files /dev/null and b/apprise/assets/themes/default/apprise-success-128x128.ico differ diff --git a/apprise/assets/themes/default/apprise-warning-128x128.ico b/apprise/assets/themes/default/apprise-warning-128x128.ico new file mode 100644 index 00000000..6600ac14 Binary files /dev/null and b/apprise/assets/themes/default/apprise-warning-128x128.ico differ diff --git a/apprise/plugins/NotifyBase.py b/apprise/plugins/NotifyBase.py index bcf811a9..9f028499 100644 --- a/apprise/plugins/NotifyBase.py +++ b/apprise/plugins/NotifyBase.py @@ -171,7 +171,7 @@ class NotifyBase(object): return - def image_url(self, notify_type, logo=False): + def image_url(self, notify_type, logo=False, extension=None): """ Returns Image URL if possible """ @@ -186,9 +186,10 @@ class NotifyBase(object): notify_type=notify_type, image_size=self.image_size, logo=logo, + extension=extension, ) - def image_path(self, notify_type): + def image_path(self, notify_type, extension=None): """ Returns the path of the image if it can """ @@ -201,9 +202,10 @@ class NotifyBase(object): return self.asset.image_path( notify_type=notify_type, image_size=self.image_size, + extension=extension, ) - def image_raw(self, notify_type): + def image_raw(self, notify_type, extension=None): """ Returns the raw image if it can """ @@ -216,6 +218,7 @@ class NotifyBase(object): return self.asset.image_raw( notify_type=notify_type, image_size=self.image_size, + extension=extension, ) def color(self, notify_type, color_type=None): diff --git a/apprise/plugins/NotifyWindows.py b/apprise/plugins/NotifyWindows.py new file mode 100644 index 00000000..aa0759de --- /dev/null +++ b/apprise/plugins/NotifyWindows.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +# +# Windows Notify Wrapper +# +# Copyright (C) 2017-2018 Chris Caron +# +# This file is part of apprise. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +from __future__ import absolute_import +from __future__ import print_function +from __future__ import unicode_literals + +import re +from time import sleep + +from .NotifyBase import NotifyBase +from ..common import NotifyImageSize + +# Default our global support flag +NOTIFY_WINDOWS_SUPPORT_ENABLED = False + +try: + # 3rd party modules (Windows Only) + import win32api + import win32con + import win32gui + + # We're good to go! + NOTIFY_WINDOWS_SUPPORT_ENABLED = True + +except ImportError: + # No problem; we just simply can't support this plugin because we're + # either using Linux, or simply do not have pypiwin32 installed. + pass + + +class NotifyWindows(NotifyBase): + """ + A wrapper for local Windows Notifications + """ + + # The default protocol + protocol = 'windows' + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_128 + + # This entry is a bit hacky, but it allows us to unit-test this library + # in an environment that simply doesn't have the windows packages + # available to us. It also allows us to handle situations where the + # packages actually are present but we need to test that they aren't. + # If anyone is seeing this had knows a better way of testing this + # outside of what is defined in test/test_windows_plugin.py, please + # let me know! :) + _enabled = NOTIFY_WINDOWS_SUPPORT_ENABLED + + def __init__(self, **kwargs): + """ + Initialize Windows Object + """ + + # Number of seconds to display notification for + self.duration = 12 + + # Define our handler + self.hwnd = None + + super(NotifyWindows, self).__init__(**kwargs) + + def _on_destroy(self, hwnd, msg, wparam, lparam): + """ + Destroy callback function + """ + + nid = (self.hwnd, 0) + win32gui.Shell_NotifyIcon(win32gui.NIM_DELETE, nid) + win32api.PostQuitMessage(0) + + return None + + def notify(self, title, body, notify_type, **kwargs): + """ + Perform Windows Notification + """ + + if not self._enabled: + self.logger.warning( + "Windows Notifications are not supported by this system.") + return False + + # Limit results to just the first 2 line otherwise + # there is just to much content to display + body = re.split('[\r\n]+', body) + body[0] = body[0].strip('#').strip() + body = '\r\n'.join(body[0:2]) + + try: + # Register destruction callback + message_map = {win32con.WM_DESTROY: self._on_destroy, } + + # Register the window class. + self.wc = win32gui.WNDCLASS() + self.hinst = self.wc.hInstance = win32api.GetModuleHandle(None) + self.wc.lpszClassName = str("PythonTaskbar") + self.wc.lpfnWndProc = message_map + self.classAtom = win32gui.RegisterClass(self.wc) + + # Styling and window type + style = win32con.WS_OVERLAPPED | win32con.WS_SYSMENU + self.hwnd = win32gui.CreateWindow( + self.classAtom, "Taskbar", style, 0, 0, + win32con.CW_USEDEFAULT, win32con.CW_USEDEFAULT, 0, 0, + self.hinst, None) + win32gui.UpdateWindow(self.hwnd) + + # image path + icon_path = self.image_path(notify_type, extension='.ico') + icon_flags = win32con.LR_LOADFROMFILE | win32con.LR_DEFAULTSIZE + + try: + hicon = win32gui.LoadImage( + self.hinst, icon_path, win32con.IMAGE_ICON, 0, 0, + icon_flags) + + except Exception as e: + self.logger.warning( + "Could not load windows notification icon ({}): {}" + .format(icon_path, e)) + + # disable icon + hicon = win32gui.LoadIcon(0, win32con.IDI_APPLICATION) + + # Taskbar icon + flags = win32gui.NIF_ICON | win32gui.NIF_MESSAGE | win32gui.NIF_TIP + nid = (self.hwnd, 0, flags, win32con.WM_USER + 20, hicon, + "Tooltip") + win32gui.Shell_NotifyIcon(win32gui.NIM_ADD, nid) + win32gui.Shell_NotifyIcon(win32gui.NIM_MODIFY, ( + self.hwnd, 0, win32gui.NIF_INFO, win32con.WM_USER + 20, hicon, + "Balloon Tooltip", body, 200, title)) + + # take a rest then destroy + sleep(self.duration) + win32gui.DestroyWindow(self.hwnd) + win32gui.UnregisterClass(self.wc.lpszClassName, None) + + self.logger.info('Sent Windows notification.') + + except Exception as e: + self.logger.warning('Failed to send Windows notification.') + self.logger.exception('Windows Exception') + return False + + return True + + @staticmethod + def parse_url(url): + """ + There are no parameters nessisary for this protocol; simply having + windows:// is all you need. This function just makes sure that + is in place. + + """ + + # return a very basic set of requirements + return { + 'schema': NotifyWindows.protocol, + 'user': None, + 'password': None, + 'port': None, + 'host': 'localhost', + 'fullpath': None, + 'path': None, + 'url': url, + 'qsd': {}, + } diff --git a/apprise/plugins/NotifyXBMC.py b/apprise/plugins/NotifyXBMC.py index 8b02fda7..8878b646 100644 --- a/apprise/plugins/NotifyXBMC.py +++ b/apprise/plugins/NotifyXBMC.py @@ -55,6 +55,9 @@ class NotifyXBMC(NotifyBase): """ super(NotifyXBMC, self).__init__(**kwargs) + # Number of micro-seconds to display notification for + self.duration = 12000 + if self.secure: self.schema = 'https' @@ -85,7 +88,7 @@ class NotifyXBMC(NotifyBase): 'title': title, 'message': body, # displaytime is defined in microseconds - 'displaytime': 12000, + 'displaytime': self.duration, }, 'id': 1, } @@ -119,7 +122,7 @@ class NotifyXBMC(NotifyBase): 'title': title, 'message': body, # displaytime is defined in microseconds - 'displaytime': 12000, + 'displaytime': self.duration, }, 'id': 1, } diff --git a/apprise/plugins/__init__.py b/apprise/plugins/__init__.py index d4991a74..02adfc13 100644 --- a/apprise/plugins/__init__.py +++ b/apprise/plugins/__init__.py @@ -43,6 +43,7 @@ from .NotifyToasty import NotifyToasty from .NotifyTwitter.NotifyTwitter import NotifyTwitter from .NotifyXBMC import NotifyXBMC from .NotifyXML import NotifyXML +from .NotifyWindows import NotifyWindows from .NotifyPushjet import pushjet from .NotifyGrowl import gntp @@ -60,7 +61,7 @@ __all__ = [ 'NotifyMatterMost', 'NotifyProwl', 'NotifyPushalot', 'NotifyPushBullet', 'NotifyPushjet', 'NotifyPushover', 'NotifyRocketChat', 'NotifySlack', 'NotifyStride', 'NotifyToasty', 'NotifyTwitter', - 'NotifyTelegram', 'NotifyXBMC', 'NotifyXML', + 'NotifyTelegram', 'NotifyXBMC', 'NotifyXML', 'NotifyWindows', # Reference 'NotifyImageSize', 'NOTIFY_IMAGE_SIZES', 'NotifyType', 'NOTIFY_TYPES', diff --git a/setup.py b/setup.py index f9f5eac0..2650f097 100755 --- a/setup.py +++ b/setup.py @@ -17,6 +17,7 @@ # import os +import platform try: from setuptools import setup @@ -26,6 +27,11 @@ except ImportError: from setuptools import find_packages install_options = os.environ.get("APPRISE_INSTALL", "").split(",") +install_requires = open('requirements.txt').readlines() +if platform.system().lower().startswith('win'): + # Windows Notification Support + install_requires += open('win-requirements.txt').readlines() + libonly_flags = set(["lib-only", "libonly", "no-cli", "without-cli"]) if libonly_flags.intersection(install_options): console_scripts = [] @@ -52,9 +58,10 @@ setup( 'apprise': [ 'assets/NotifyXML-1.0.xsd', 'assets/themes/default/*.png', + 'assets/themes/default/*.ico', ], }, - install_requires=open('requirements.txt').readlines(), + install_requires=install_requires, classifiers=( 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', diff --git a/test/test_api.py b/test/test_api.py index 60802200..4dc2e12f 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -293,8 +293,8 @@ def test_apprise_asset(tmpdir): a = AppriseAsset( theme='dark', - image_path_mask='/{THEME}/{TYPE}-{XY}.png', - image_url_mask='http://localhost/{THEME}/{TYPE}-{XY}.png', + image_path_mask='/{THEME}/{TYPE}-{XY}{EXTENSION}', + image_url_mask='http://localhost/{THEME}/{TYPE}-{XY}{EXTENSION}', ) a.default_html_color = '#abcabc' @@ -431,3 +431,32 @@ def test_apprise_asset(tmpdir): must_exist=False) is None) assert(a.image_path(NotifyType.INFO, NotifyImageSize.XY_256, must_exist=True) is None) + + # Test our default extension out + a = AppriseAsset( + image_path_mask='/{THEME}/{TYPE}-{XY}{EXTENSION}', + image_url_mask='http://localhost/{THEME}/{TYPE}-{XY}{EXTENSION}', + default_extension='.jpeg', + ) + assert(a.image_path( + NotifyType.INFO, + NotifyImageSize.XY_256, + must_exist=False) == '/default/info-256x256.jpeg') + + assert(a.image_url( + NotifyType.INFO, + NotifyImageSize.XY_256) == 'http://localhost/' + 'default/info-256x256.jpeg') + + # extension support + assert(a.image_path( + NotifyType.INFO, + NotifyImageSize.XY_128, + must_exist=False, + extension='.ico') == '/default/info-128x128.ico') + + assert(a.image_url( + NotifyType.INFO, + NotifyImageSize.XY_256, + extension='.test') == 'http://localhost/' + 'default/info-256x256.test') diff --git a/test/test_windows_plugin.py b/test/test_windows_plugin.py new file mode 100644 index 00000000..a5c09107 --- /dev/null +++ b/test/test_windows_plugin.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# +# NotifyWindows - Unit Tests +# +# Copyright (C) 2018 Chris Caron +# +# This file is part of apprise. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. + +import mock +import sys +import types + +# Rebuild our Apprise environment +import apprise + +try: + # Python v3.4+ + from importlib import reload +except ImportError: + try: + # Python v3.0-v3.3 + from imp import reload + except ImportError: + # Python v2.7 + pass + + +def test_windows_plugin(): + """ + API: NotifyWindows Plugin() + + """ + + # We need to fake our windows environment for testing purposes + win32api_name = 'win32api' + win32api = types.ModuleType(win32api_name) + sys.modules[win32api_name] = win32api + win32api.GetModuleHandle = mock.Mock( + name=win32api_name + '.GetModuleHandle') + win32api.PostQuitMessage = mock.Mock( + name=win32api_name + '.PostQuitMessage') + + win32con_name = 'win32con' + win32con = types.ModuleType(win32con_name) + sys.modules[win32con_name] = win32con + win32con.CW_USEDEFAULT = mock.Mock(name=win32con_name + '.CW_USEDEFAULT') + win32con.IDI_APPLICATION = mock.Mock( + name=win32con_name + '.IDI_APPLICATION') + win32con.IMAGE_ICON = mock.Mock(name=win32con_name + '.IMAGE_ICON') + win32con.LR_DEFAULTSIZE = 1 + win32con.LR_LOADFROMFILE = 2 + win32con.WM_DESTROY = mock.Mock(name=win32con_name + '.WM_DESTROY') + win32con.WM_USER = 0 + win32con.WS_OVERLAPPED = 1 + win32con.WS_SYSMENU = 2 + + win32gui_name = 'win32gui' + win32gui = types.ModuleType(win32gui_name) + sys.modules[win32gui_name] = win32gui + win32gui.CreateWindow = mock.Mock(name=win32gui_name + '.CreateWindow') + win32gui.DestroyWindow = mock.Mock(name=win32gui_name + '.DestroyWindow') + win32gui.LoadIcon = mock.Mock(name=win32gui_name + '.LoadIcon') + win32gui.LoadImage = mock.Mock(name=win32gui_name + '.LoadImage') + win32gui.NIF_ICON = 1 + win32gui.NIF_INFO = mock.Mock(name=win32gui_name + '.NIF_INFO') + win32gui.NIF_MESSAGE = 2 + win32gui.NIF_TIP = 4 + win32gui.NIM_ADD = mock.Mock(name=win32gui_name + '.NIM_ADD') + win32gui.NIM_DELETE = mock.Mock(name=win32gui_name + '.NIM_DELETE') + win32gui.NIM_MODIFY = mock.Mock(name=win32gui_name + '.NIM_MODIFY') + win32gui.RegisterClass = mock.Mock(name=win32gui_name + '.RegisterClass') + win32gui.UnregisterClass = mock.Mock( + name=win32gui_name + '.UnregisterClass') + win32gui.Shell_NotifyIcon = mock.Mock( + name=win32gui_name + '.Shell_NotifyIcon') + win32gui.UpdateWindow = mock.Mock(name=win32gui_name + '.UpdateWindow') + win32gui.WNDCLASS = mock.Mock(name=win32gui_name + '.WNDCLASS') + + # The following allows our mocked content to kick in. In python 3.x keys() + # returns an iterator, therefore we need to convert the keys() back into + # a list object to prevent from getting the error: + # "RuntimeError: dictionary changed size during iteration" + # + for mod in list(sys.modules.keys()): + if mod.startswith('apprise.'): + del(sys.modules[mod]) + reload(apprise) + + # Create our instance + obj = apprise.Apprise.instantiate('windows://', suppress_exceptions=False) + obj.duration = 0 + + # Check that it found our mocked environments + assert(obj._enabled is True) + + # _on_destroy check + obj._on_destroy(0, '', 0, 0) + + # test notifications + assert(obj.notify(title='title', body='body', + notify_type=apprise.NotifyType.INFO) is True) + + # Test our loading of our icon exception; it will still allow the + # notification to be sent + win32gui.LoadImage.side_effect = AttributeError + assert(obj.notify(title='title', body='body', + notify_type=apprise.NotifyType.INFO) is True) + # Undo our change + win32gui.LoadImage.side_effect = None + + # Test our global exception handling + win32gui.UpdateWindow.side_effect = AttributeError + assert(obj.notify(title='title', body='body', + notify_type=apprise.NotifyType.INFO) is False) + # Undo our change + win32gui.UpdateWindow.side_effect = None + + # Toggle our testing for when we can't send notifications because the + # package has been made unavailable to us + obj._enabled = False + assert(obj.notify(title='title', body='body', + notify_type=apprise.NotifyType.INFO) is False) diff --git a/win-requirements.txt b/win-requirements.txt new file mode 100644 index 00000000..c22890b9 --- /dev/null +++ b/win-requirements.txt @@ -0,0 +1 @@ +pypiwin32