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