Windows notification support; refs #14

This commit is contained in:
Chris Caron 2018-07-01 12:40:56 -04:00
parent 804e27c5fd
commit 68270459d0
17 changed files with 412 additions and 17 deletions

View File

@ -2,6 +2,7 @@ include LICENSE
include README.md include README.md
include README include README
include requirements.txt include requirements.txt
include win-requirements.txt
include dev-requirements.txt include dev-requirements.txt
recursive-include test * recursive-include test *
global-exclude *.pyc global-exclude *.pyc

4
README
View File

@ -119,6 +119,10 @@ The table below identifies the services this tool supports and some example serv
xbmc://user@hostname xbmc://user@hostname
xbmc://user:password@hostname:port xbmc://user:password@hostname:port
* Windows Notifications
windows://
Email Support Email Support
------------- -------------
* mailto:// * mailto://

View File

@ -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<br />tgram://bottoken/ChatID1/ChatID2/ChatIDN | [Telegram](https://github.com/caronc/apprise/wiki/Notify_telegram) | tgram:// | (TCP) 443 | tgram://bottoken/ChatID<br />tgram://bottoken/ChatID1/ChatID2/ChatIDN
| [Twitter](https://github.com/caronc/apprise/wiki/Notify_twitter) | tweet:// | (TCP) 443 | tweet://user@CKey/CSecret/AKey/ASecret | [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<br />xbmc://user@hostname<br />xbmc://user:password@hostname:port | [XBMC](https://github.com/caronc/apprise/wiki/Notify_xbmc) | xbmc:// or xbmcs:// | (TCP) 8080 or 443 | xbmc://hostname<br />xbmc://user@hostname<br />xbmc://user:password@hostname:port
| [Windows Notification](https://github.com/caronc/apprise/wiki/Notify_windows) | windows:// | n/a | windows://
### Email Support ### Email Support
| Service ID | Default Port | Example Syntax | | Service ID | Default Port | Example Syntax |

View File

@ -204,6 +204,8 @@ class Apprise(object):
instance = Apprise.instantiate(_server, asset=asset) instance = Apprise.instantiate(_server, asset=asset)
if not instance: if not instance:
return_status = False return_status = False
logging.error(
"Failed to load notification url: {}".format(_server))
continue continue
# Add our initialized plugin to our server listings # Add our initialized plugin to our server listings
@ -261,10 +263,15 @@ class Apprise(object):
# Toggle our return status flag # Toggle our return status flag
status = False status = False
except TypeError:
# These our our internally thrown notifications
# TODO: Change this to a custom one such as AppriseNotifyError
status = False
except Exception: except Exception:
# A catch all so we don't have to abort early # A catch all so we don't have to abort early
# just because one of our plugins has a bug in it. # just because one of our plugins has a bug in it.
logging.exception("notification exception") logging.exception("Notification Exception")
status = False status = False
return status return status

View File

@ -54,12 +54,16 @@ class AppriseAsset(object):
# The default color to return if a mapping isn't found in our table above # The default color to return if a mapping isn't found in our table above
default_html_color = '#888888' default_html_color = '#888888'
# The default image extension to use
default_extension = '.png'
# The default theme # The default theme
theme = 'default' theme = 'default'
# Image URL Mask # Image URL Mask
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 # Application Logo
image_url_logo = \ image_url_logo = \
@ -71,11 +75,11 @@ class AppriseAsset(object):
'assets', 'assets',
'themes', 'themes',
'{THEME}', '{THEME}',
'apprise-{TYPE}-{XY}.png', 'apprise-{TYPE}-{XY}{EXTENSION}',
)) ))
def __init__(self, theme='default', image_path_mask=None, def __init__(self, theme='default', image_path_mask=None,
image_url_mask=None): image_url_mask=None, default_extension=None):
""" """
Asset Initialization Asset Initialization
@ -89,6 +93,9 @@ class AppriseAsset(object):
if image_url_mask is not None: if image_url_mask is not None:
self.image_url_mask = image_url_mask 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): def color(self, notify_type, color_type=None):
""" """
Returns an HTML mapped color based on passed in notify type Returns an HTML mapped color based on passed in notify type
@ -121,7 +128,7 @@ class AppriseAsset(object):
raise ValueError( raise ValueError(
'AppriseAsset html_color(): An invalid color_type was specified.') '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 Apply our mask to our image URL
@ -134,10 +141,14 @@ class AppriseAsset(object):
# No image to return # No image to return
return None return None
if extension is None:
extension = self.default_extension
re_map = { re_map = {
'{THEME}': self.theme if self.theme else '', '{THEME}': self.theme if self.theme else '',
'{TYPE}': notify_type, '{TYPE}': notify_type,
'{XY}': image_size, '{XY}': image_size,
'{EXTENSION}': extension,
} }
# Iterate over above list and store content accordingly # 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) 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 Apply our mask to our image file path
@ -158,10 +170,14 @@ class AppriseAsset(object):
# No image to return # No image to return
return None return None
if extension is None:
extension = self.default_extension
re_map = { re_map = {
'{THEME}': self.theme if self.theme else '', '{THEME}': self.theme if self.theme else '',
'{TYPE}': notify_type, '{TYPE}': notify_type,
'{XY}': image_size, '{XY}': image_size,
'{EXTENSION}': extension,
} }
# Iterate over above list and store content accordingly # Iterate over above list and store content accordingly
@ -178,13 +194,17 @@ class AppriseAsset(object):
# Return what we parsed # Return what we parsed
return path 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) 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: if path:
try: try:
with open(path, 'rb') as fd: with open(path, 'rb') as fd:

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

View File

@ -171,7 +171,7 @@ class NotifyBase(object):
return return
def image_url(self, notify_type, logo=False): def image_url(self, notify_type, logo=False, extension=None):
""" """
Returns Image URL if possible Returns Image URL if possible
""" """
@ -186,9 +186,10 @@ class NotifyBase(object):
notify_type=notify_type, notify_type=notify_type,
image_size=self.image_size, image_size=self.image_size,
logo=logo, 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 Returns the path of the image if it can
""" """
@ -201,9 +202,10 @@ class NotifyBase(object):
return self.asset.image_path( return self.asset.image_path(
notify_type=notify_type, notify_type=notify_type,
image_size=self.image_size, 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 Returns the raw image if it can
""" """
@ -216,6 +218,7 @@ class NotifyBase(object):
return self.asset.image_raw( return self.asset.image_raw(
notify_type=notify_type, notify_type=notify_type,
image_size=self.image_size, image_size=self.image_size,
extension=extension,
) )
def color(self, notify_type, color_type=None): def color(self, notify_type, color_type=None):

View File

@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
#
# Windows Notify Wrapper
#
# Copyright (C) 2017-2018 Chris Caron <lead2gold@gmail.com>
#
# 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': {},
}

View File

@ -55,6 +55,9 @@ class NotifyXBMC(NotifyBase):
""" """
super(NotifyXBMC, self).__init__(**kwargs) super(NotifyXBMC, self).__init__(**kwargs)
# Number of micro-seconds to display notification for
self.duration = 12000
if self.secure: if self.secure:
self.schema = 'https' self.schema = 'https'
@ -85,7 +88,7 @@ class NotifyXBMC(NotifyBase):
'title': title, 'title': title,
'message': body, 'message': body,
# displaytime is defined in microseconds # displaytime is defined in microseconds
'displaytime': 12000, 'displaytime': self.duration,
}, },
'id': 1, 'id': 1,
} }
@ -119,7 +122,7 @@ class NotifyXBMC(NotifyBase):
'title': title, 'title': title,
'message': body, 'message': body,
# displaytime is defined in microseconds # displaytime is defined in microseconds
'displaytime': 12000, 'displaytime': self.duration,
}, },
'id': 1, 'id': 1,
} }

View File

@ -43,6 +43,7 @@ from .NotifyToasty import NotifyToasty
from .NotifyTwitter.NotifyTwitter import NotifyTwitter from .NotifyTwitter.NotifyTwitter import NotifyTwitter
from .NotifyXBMC import NotifyXBMC from .NotifyXBMC import NotifyXBMC
from .NotifyXML import NotifyXML from .NotifyXML import NotifyXML
from .NotifyWindows import NotifyWindows
from .NotifyPushjet import pushjet from .NotifyPushjet import pushjet
from .NotifyGrowl import gntp from .NotifyGrowl import gntp
@ -60,7 +61,7 @@ __all__ = [
'NotifyMatterMost', 'NotifyProwl', 'NotifyPushalot', 'NotifyMatterMost', 'NotifyProwl', 'NotifyPushalot',
'NotifyPushBullet', 'NotifyPushjet', 'NotifyPushover', 'NotifyRocketChat', 'NotifyPushBullet', 'NotifyPushjet', 'NotifyPushover', 'NotifyRocketChat',
'NotifySlack', 'NotifyStride', 'NotifyToasty', 'NotifyTwitter', 'NotifySlack', 'NotifyStride', 'NotifyToasty', 'NotifyTwitter',
'NotifyTelegram', 'NotifyXBMC', 'NotifyXML', 'NotifyTelegram', 'NotifyXBMC', 'NotifyXML', 'NotifyWindows',
# Reference # Reference
'NotifyImageSize', 'NOTIFY_IMAGE_SIZES', 'NotifyType', 'NOTIFY_TYPES', 'NotifyImageSize', 'NOTIFY_IMAGE_SIZES', 'NotifyType', 'NOTIFY_TYPES',

View File

@ -17,6 +17,7 @@
# #
import os import os
import platform
try: try:
from setuptools import setup from setuptools import setup
@ -26,6 +27,11 @@ except ImportError:
from setuptools import find_packages from setuptools import find_packages
install_options = os.environ.get("APPRISE_INSTALL", "").split(",") 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"]) libonly_flags = set(["lib-only", "libonly", "no-cli", "without-cli"])
if libonly_flags.intersection(install_options): if libonly_flags.intersection(install_options):
console_scripts = [] console_scripts = []
@ -52,9 +58,10 @@ setup(
'apprise': [ 'apprise': [
'assets/NotifyXML-1.0.xsd', 'assets/NotifyXML-1.0.xsd',
'assets/themes/default/*.png', 'assets/themes/default/*.png',
'assets/themes/default/*.ico',
], ],
}, },
install_requires=open('requirements.txt').readlines(), install_requires=install_requires,
classifiers=( classifiers=(
'Development Status :: 5 - Production/Stable', 'Development Status :: 5 - Production/Stable',
'Intended Audience :: Developers', 'Intended Audience :: Developers',

View File

@ -292,8 +292,8 @@ def test_apprise_asset(tmpdir):
a = AppriseAsset( a = AppriseAsset(
theme='dark', theme='dark',
image_path_mask='/{THEME}/{TYPE}-{XY}.png', image_path_mask='/{THEME}/{TYPE}-{XY}{EXTENSION}',
image_url_mask='http://localhost/{THEME}/{TYPE}-{XY}.png', image_url_mask='http://localhost/{THEME}/{TYPE}-{XY}{EXTENSION}',
) )
a.default_html_color = '#abcabc' a.default_html_color = '#abcabc'
@ -430,3 +430,32 @@ def test_apprise_asset(tmpdir):
must_exist=False) is None) must_exist=False) is None)
assert(a.image_path(NotifyType.INFO, NotifyImageSize.XY_256, assert(a.image_path(NotifyType.INFO, NotifyImageSize.XY_256,
must_exist=True) is None) 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')

132
test/test_windows_plugin.py Normal file
View File

@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
#
# NotifyWindows - Unit Tests
#
# Copyright (C) 2018 Chris Caron <lead2gold@gmail.com>
#
# 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)

1
win-requirements.txt Normal file
View File

@ -0,0 +1 @@
pypiwin32