Improve testing of NotifyDBus, NotifyGnome, and NotifyMacOSX (#689)

This commit is contained in:
Andreas Motl 2022-10-16 19:43:15 +02:00 committed by GitHub
parent c81d2465e4
commit f1836cff84
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 458 additions and 236 deletions

View File

@ -1,8 +1,8 @@
# Base # Base
FROM python:3.10-buster FROM python:3.10-buster
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y libdbus-1-dev build-essential musl-dev bash apt-get install -y libdbus-1-dev libgirepository1.0-dev build-essential musl-dev bash
RUN pip install dbus-python RUN pip install dbus-python PyGObject
# Apprise Setup # Apprise Setup
VOLUME ["/apprise"] VOLUME ["/apprise"]

View File

@ -1,8 +1,8 @@
# Base # Base
FROM python:3.6-buster FROM python:3.6-buster
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y libdbus-1-dev build-essential musl-dev bash apt-get install -y libdbus-1-dev libgirepository1.0-dev build-essential musl-dev bash
RUN pip install dbus-python RUN pip install dbus-python PyGObject
# Apprise Setup # Apprise Setup
VOLUME ["/apprise"] VOLUME ["/apprise"]

View File

@ -26,6 +26,7 @@
from __future__ import absolute_import from __future__ import absolute_import
from __future__ import print_function from __future__ import print_function
import sys
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from ..common import NotifyImageSize from ..common import NotifyImageSize
from ..common import NotifyType from ..common import NotifyType
@ -77,6 +78,13 @@ try:
NOTIFY_DBUS_SUPPORT_ENABLED = ( NOTIFY_DBUS_SUPPORT_ENABLED = (
LOOP_GLIB is not None or LOOP_QT is not None) LOOP_GLIB is not None or LOOP_QT is not None)
# ImportError: When using gi.repository you must not import static modules
# like "gobject". Please change all occurrences of "import gobject" to
# "from gi.repository import GObject".
# See: https://bugzilla.gnome.org/show_bug.cgi?id=709183
if "gobject" in sys.modules: # pragma: no cover
del sys.modules["gobject"]
try: try:
# The following is required for Image/Icon loading only # The following is required for Image/Icon loading only
import gi import gi
@ -272,12 +280,9 @@ class NotifyDBus(NotifyBase):
self.x_axis = None self.x_axis = None
self.y_axis = None self.y_axis = None
# Track whether or not we want to send an image with our notification # Track whether we want to add an image to the notification.
# or not.
self.include_image = include_image self.include_image = include_image
return
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
""" """
Perform DBus Notification Perform DBus Notification
@ -286,10 +291,10 @@ class NotifyDBus(NotifyBase):
try: try:
session = SessionBus(mainloop=MAINLOOP_MAP[self.schema]) session = SessionBus(mainloop=MAINLOOP_MAP[self.schema])
except DBusException: except DBusException as e:
# Handle exception # Handle exception
self.logger.warning('Failed to send DBus notification.') self.logger.warning('Failed to send DBus notification.')
self.logger.exception('DBus Exception') self.logger.debug(f'DBus Exception: {e}')
return False return False
# If there is no title, but there is a body, swap the two to get rid # If there is no title, but there is a body, swap the two to get rid
@ -342,8 +347,8 @@ class NotifyDBus(NotifyBase):
except Exception as e: except Exception as e:
self.logger.warning( self.logger.warning(
"Could not load Gnome notification icon ({}): {}" "Could not load notification icon (%s).", icon_path)
.format(icon_path, e)) self.logger.debug(f'DBus Exception: {e}')
try: try:
# Always call throttle() before any remote execution is made # Always call throttle() before any remote execution is made
@ -370,9 +375,9 @@ class NotifyDBus(NotifyBase):
self.logger.info('Sent DBus notification.') self.logger.info('Sent DBus notification.')
except Exception: except Exception as e:
self.logger.warning('Failed to send DBus notification.') self.logger.warning('Failed to send DBus notification.')
self.logger.exception('DBus Exception') self.logger.debug(f'DBus Exception: {e}')
return False return False
return True return True

View File

@ -54,8 +54,8 @@ except (ImportError, ValueError, AttributeError):
# be in microsoft windows, or we just don't have the python-gobject # be in microsoft windows, or we just don't have the python-gobject
# library available to us (or maybe one we don't support)? # library available to us (or maybe one we don't support)?
# Alternativey A ValueError will get thrown upon calling # Alternatively, a `ValueError` will get raised upon calling
# gi.require_version() if the requested Notify namespace isn't available # gi.require_version() if the requested Notify namespace isn't available.
pass pass
@ -175,12 +175,9 @@ class NotifyGnome(NotifyBase):
if str(urgency).lower().startswith(k)), if str(urgency).lower().startswith(k)),
NotifyGnome.template_args['urgency']['default'])) NotifyGnome.template_args['urgency']['default']))
# Track whether or not we want to send an image with our notification # Track whether we want to add an image to the notification.
# or not.
self.include_image = include_image self.include_image = include_image
return
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
""" """
Perform Gnome Notification Perform Gnome Notification
@ -214,15 +211,15 @@ class NotifyGnome(NotifyBase):
except Exception as e: except Exception as e:
self.logger.warning( self.logger.warning(
"Could not load Gnome notification icon ({}): {}" "Could not load notification icon (%s). ", icon_path)
.format(icon_path, e)) self.logger.debug(f'Gnome Exception: {e}')
notification.show() notification.show()
self.logger.info('Sent Gnome notification.') self.logger.info('Sent Gnome notification.')
except Exception: except Exception as e:
self.logger.warning('Failed to send Gnome notification.') self.logger.warning('Failed to send Gnome notification.')
self.logger.exception('Gnome Exception') self.logger.debug(f'Gnome Exception: {e}')
return False return False
return True return True

View File

@ -39,13 +39,15 @@ from ..AppriseLocale import gettext_lazy as _
# Default our global support flag # Default our global support flag
NOTIFY_MACOSX_SUPPORT_ENABLED = False NOTIFY_MACOSX_SUPPORT_ENABLED = False
# TODO: The module will be easier to test without module-level code.
if platform.system() == 'Darwin': if platform.system() == 'Darwin':
# Check this is Mac OS X 10.8, or higher # Check this is Mac OS X 10.8, or higher
major, minor = platform.mac_ver()[0].split('.')[:2] major, minor = platform.mac_ver()[0].split('.')[:2]
# Toggle our enabled flag if verion is correct and executable # Toggle our enabled flag, if version is correct and executable
# found. This is done in such a way to provide verbosity to the # found. This is done in such a way to provide verbosity to the
# end user so they know why it may or may not work for them. # end user, so they know why it may or may not work for them.
NOTIFY_MACOSX_SUPPORT_ENABLED = \ NOTIFY_MACOSX_SUPPORT_ENABLED = \
(int(major) > 10 or (int(major) == 10 and int(minor) >= 8)) (int(major) > 10 or (int(major) == 10 and int(minor) >= 8))
@ -95,6 +97,8 @@ class NotifyMacOSX(NotifyBase):
notify_paths = ( notify_paths = (
'/opt/homebrew/bin/terminal-notifier', '/opt/homebrew/bin/terminal-notifier',
'/usr/local/bin/terminal-notifier', '/usr/local/bin/terminal-notifier',
'/usr/bin/terminal-notifier',
'/bin/terminal-notifier',
) )
# Define object templates # Define object templates
@ -126,17 +130,15 @@ class NotifyMacOSX(NotifyBase):
super().__init__(**kwargs) super().__init__(**kwargs)
# Track whether or not we want to send an image with our notification # Track whether we want to add an image to the notification.
# or not.
self.include_image = include_image self.include_image = include_image
# Acquire the notify path # Acquire the path to the `terminal-notifier` program.
self.notify_path = next( # pragma: no branch self.notify_path = next( # pragma: no branch
(p for p in self.notify_paths if os.access(p, os.X_OK)), None) (p for p in self.notify_paths if os.access(p, os.X_OK)), None)
# Set sound object (no q/a for now) # Set sound object (no q/a for now)
self.sound = sound self.sound = sound
return
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
""" """

View File

@ -1,6 +1,6 @@
version: "3.3" version: "3.3"
services: services:
test.py35: test.py36:
build: build:
context: . context: .
dockerfile: Dockerfile.py36 dockerfile: Dockerfile.py36

View File

@ -22,24 +22,18 @@
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
from importlib import import_module, reload
import sys import sys
from importlib import reload
def reload_plugin(name, replace_in=None):
def reload_plugin(name):
""" """
Reload builtin plugin module, e.g. `NotifyGnome`. Reload built-in plugin module, e.g. `NotifyGnome`.
set filename to plugin to be reloaded (for example NotifyGnome.py)
The following libraries need to be reloaded to prevent Reloading plugin modules is needed when testing module-level code of
TypeError: super(type, obj): obj must be an instance or subtype of type notification plugins.
This is better explained in this StackOverflow post:
https://stackoverflow.com/questions/31363311/\
any-way-to-manually-fix-operation-of-\
super-after-ipython-reload-avoiding-ty
See also https://stackoverflow.com/questions/31363311.
""" """
module_name = f"apprise.plugins.{name}" module_name = f"apprise.plugins.{name}"
@ -53,3 +47,10 @@ def reload_plugin(name):
reload(sys.modules['apprise.Apprise']) reload(sys.modules['apprise.Apprise'])
reload(sys.modules['apprise.utils']) reload(sys.modules['apprise.utils'])
reload(sys.modules['apprise']) reload(sys.modules['apprise'])
# Fix reference to new plugin class in given module.
# Needed for updating the module-level import reference like
# `from apprise.plugins.NotifyMacOSX import NotifyMacOSX`.
if replace_in is not None:
mod = import_module(module_name)
setattr(replace_in, name, getattr(mod, name))

View File

@ -22,40 +22,37 @@
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
import importlib
import logging
import re import re
import pytest
from unittest import mock
import sys import sys
import types import types
from unittest.mock import Mock, call, ANY
import pytest
import apprise import apprise
from helpers import reload_plugin from helpers import reload_plugin
from importlib import reload
# Disable logging for a cleaner testing output # Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL) logging.disable(logging.CRITICAL)
# Skip tests when Python environment does not provide the `dbus` package.
if 'dbus' not in sys.modules: if 'dbus' not in sys.modules:
# Environment doesn't allow for dbus
pytest.skip("Skipping dbus-python based tests", allow_module_level=True) pytest.skip("Skipping dbus-python based tests", allow_module_level=True)
from dbus import DBusException # noqa E402 from dbus import DBusException # noqa E402
from apprise.plugins.NotifyDBus import DBusUrgency # noqa E402 from apprise.plugins.NotifyDBus import DBusUrgency, NotifyDBus # noqa E402
@mock.patch('dbus.SessionBus') def setup_glib_environment():
@mock.patch('dbus.Interface')
@mock.patch('dbus.ByteArray')
@mock.patch('dbus.Byte')
@mock.patch('dbus.mainloop')
def test_plugin_dbus_general(mock_mainloop, mock_byte, mock_bytearray,
mock_interface, mock_sessionbus):
""" """
NotifyDBus() General Tests Setup a heavily mocked Glib environment.
""" """
mock_mainloop = Mock()
# Our module base # Our module base
gi_name = 'gi' gi_name = 'gi'
@ -68,15 +65,15 @@ def test_plugin_dbus_general(mock_mainloop, mock_byte, mock_bytearray,
# for the purpose of testing and capture the handling of the # for the purpose of testing and capture the handling of the
# library when it is missing # library when it is missing
del sys.modules[gi_name] del sys.modules[gi_name]
reload(sys.modules['apprise.plugins.NotifyDBus']) importlib.reload(sys.modules['apprise.plugins.NotifyDBus'])
# We need to fake our dbus environment for testing purposes since # We need to fake our dbus environment for testing purposes since
# the gi library isn't available in Travis CI # the gi library isn't available in Travis CI
gi = types.ModuleType(gi_name) gi = types.ModuleType(gi_name)
gi.repository = types.ModuleType(gi_name + '.repository') gi.repository = types.ModuleType(gi_name + '.repository')
mock_pixbuf = mock.Mock() mock_pixbuf = Mock()
mock_image = mock.Mock() mock_image = Mock()
mock_pixbuf.new_from_file.return_value = mock_image mock_pixbuf.new_from_file.return_value = mock_image
mock_image.get_width.return_value = 100 mock_image.get_width.return_value = 100
@ -92,7 +89,7 @@ def test_plugin_dbus_general(mock_mainloop, mock_byte, mock_bytearray,
gi.repository.GdkPixbuf.Pixbuf = mock_pixbuf gi.repository.GdkPixbuf.Pixbuf = mock_pixbuf
# Emulate require_version function: # Emulate require_version function:
gi.require_version = mock.Mock( gi.require_version = Mock(
name=gi_name + '.require_version') name=gi_name + '.require_version')
# Force the fake module to exist # Force the fake module to exist
@ -103,18 +100,53 @@ def test_plugin_dbus_general(mock_mainloop, mock_byte, mock_bytearray,
mock_mainloop.qt.DBusQtMainLoop.return_value = True mock_mainloop.qt.DBusQtMainLoop.return_value = True
mock_mainloop.qt.DBusQtMainLoop.side_effect = ImportError mock_mainloop.qt.DBusQtMainLoop.side_effect = ImportError
sys.modules['dbus.mainloop.qt'] = mock_mainloop.qt sys.modules['dbus.mainloop.qt'] = mock_mainloop.qt
reload(sys.modules['apprise.plugins.NotifyDBus'])
mock_mainloop.qt.DBusQtMainLoop.side_effect = None mock_mainloop.qt.DBusQtMainLoop.side_effect = None
mock_mainloop.glib.NativeMainLoop.return_value = True mock_mainloop.glib.NativeMainLoop.return_value = True
mock_mainloop.glib.NativeMainLoop.side_effect = ImportError() mock_mainloop.glib.NativeMainLoop.side_effect = ImportError()
sys.modules['dbus.mainloop.glib'] = mock_mainloop.glib sys.modules['dbus.mainloop.glib'] = mock_mainloop.glib
reload(sys.modules['apprise.plugins.NotifyDBus'])
mock_mainloop.glib.DBusGMainLoop.side_effect = None mock_mainloop.glib.DBusGMainLoop.side_effect = None
mock_mainloop.glib.NativeMainLoop.side_effect = None mock_mainloop.glib.NativeMainLoop.side_effect = None
reload_plugin('NotifyDBus') # When patching something which has a side effect on the module-level code
from apprise.plugins.NotifyDBus import NotifyDBus # of a plugin, make sure to reload it.
current_module = sys.modules[__name__]
reload_plugin('NotifyDBus', replace_in=current_module)
@pytest.fixture
def dbus_environment(mocker):
"""
Fixture to provide a mocked Dbus environment to test case functions.
"""
interface_mock = mocker.patch('dbus.Interface', spec=True,
Notify=Mock())
mocker.patch('dbus.SessionBus', spec=True,
**{"get_object.return_value": interface_mock})
@pytest.fixture
def glib_environment():
"""
Fixture to provide a mocked Glib environment to test case functions.
"""
setup_glib_environment()
@pytest.fixture
def dbus_glib_environment(dbus_environment, glib_environment):
"""
Fixture to provide a mocked Glib/DBus environment to test case functions.
"""
pass
def test_plugin_dbus_general_success(mocker, dbus_glib_environment):
"""
NotifyDBus() general tests
Test class loading using different arguments, provided via URL.
"""
# Create our instance (identify all supported types) # Create our instance (identify all supported types)
obj = apprise.Apprise.instantiate('dbus://', suppress_exceptions=False) obj = apprise.Apprise.instantiate('dbus://', suppress_exceptions=False)
@ -135,10 +167,6 @@ def test_plugin_dbus_general(mock_mainloop, mock_byte, mock_bytearray,
assert obj.url().startswith('glib://_/') assert obj.url().startswith('glib://_/')
obj.duration = 0 obj.duration = 0
# Test our class loading using a series of arguments
with pytest.raises(TypeError):
NotifyDBus(**{'schema': 'invalid'})
# Set our X and Y coordinate and try the notification # Set our X and Y coordinate and try the notification
assert NotifyDBus( assert NotifyDBus(
x_axis=0, y_axis=0, **{'schema': 'dbus'})\ x_axis=0, y_axis=0, **{'schema': 'dbus'})\
@ -245,9 +273,21 @@ def test_plugin_dbus_general(mock_mainloop, mock_byte, mock_bytearray,
title='title', body='body', title='title', body='body',
notify_type=apprise.NotifyType.INFO) is True notify_type=apprise.NotifyType.INFO) is True
def test_plugin_dbus_general_failure(dbus_glib_environment):
"""
Verify a few failure conditions.
"""
with pytest.raises(TypeError): with pytest.raises(TypeError):
obj = apprise.Apprise.instantiate( NotifyDBus(**{'schema': 'invalid'})
'dbus://_/?x=invalid&y=invalid', suppress_exceptions=False)
with pytest.raises(TypeError):
apprise.Apprise.instantiate('dbus://_/?x=invalid&y=invalid',
suppress_exceptions=False)
def test_plugin_dbus_parse_configuration(dbus_glib_environment):
# Test configuration parsing # Test configuration parsing
content = """ content = """
@ -318,104 +358,160 @@ def test_plugin_dbus_general(mock_mainloop, mock_byte, mock_bytearray,
for s in aobj.find(tag='dbus_invalid'): for s in aobj.find(tag='dbus_invalid'):
assert s.urgency == DBusUrgency.NORMAL assert s.urgency == DBusUrgency.NORMAL
# If our underlining object throws for whatever rea on, we will
# gracefully fail
mock_notify = mock.Mock()
mock_interface.return_value = mock_notify
mock_notify.Notify.side_effect = AttributeError()
assert obj.notify(
title='', body='body',
notify_type=apprise.NotifyType.INFO) is False
mock_notify.Notify.side_effect = None
# Test our loading of our icon exception; it will still allow the def test_plugin_dbus_missing_icon(mocker, dbus_glib_environment):
# notification to be sent """
mock_pixbuf.new_from_file.side_effect = AttributeError() Test exception when loading icon; the notification will still be sent.
"""
# Inject error when loading icon.
gi = importlib.import_module("gi")
gi.repository.GdkPixbuf.Pixbuf.new_from_file.side_effect = \
AttributeError("Something failed")
obj = apprise.Apprise.instantiate('dbus://', suppress_exceptions=False)
logger: Mock = mocker.spy(obj, "logger")
assert obj.notify( assert obj.notify(
title='title', body='body', title='title', body='body',
notify_type=apprise.NotifyType.INFO) is True notify_type=apprise.NotifyType.INFO) is True
# Undo our change assert logger.mock_calls == [
mock_pixbuf.new_from_file.side_effect = None call.warning('Could not load notification icon (%s). '
'Reason: Something failed', ANY),
call.info('Sent DBus notification.'),
]
def test_plugin_dbus_disabled_plugin(dbus_glib_environment):
"""
Verify notification will not be submitted if plugin is disabled.
"""
obj = apprise.Apprise.instantiate('dbus://', suppress_exceptions=False)
# Test our exception handling during initialization
# Toggle our testing for when we can't send notifications because the
# package has been made unavailable to us
obj.enabled = False obj.enabled = False
assert obj.notify( assert obj.notify(
title='title', body='body', title='title', body='body',
notify_type=apprise.NotifyType.INFO) is False notify_type=apprise.NotifyType.INFO) is False
# Test the setting of a the urgency
def test_plugin_dbus_set_urgency():
"""
Test the setting of an urgency.
"""
NotifyDBus(urgency=0) NotifyDBus(urgency=0)
#
# We can still notify if the gi library is the only inaccessible
# compontent
#
# Emulate require_version function: def test_plugin_dbus_gi_missing(dbus_glib_environment):
"""
Verify notification succeeds even if the `gi` package is not available.
"""
# Make `require_version` function raise an ImportError.
gi = importlib.import_module("gi")
gi.require_version.side_effect = ImportError() gi.require_version.side_effect = ImportError()
reload_plugin('NotifyDBus')
from apprise.plugins.NotifyDBus import NotifyDBus
# Create our instance # When patching something which has a side effect on the module-level code
# of a plugin, make sure to reload it.
current_module = sys.modules[__name__]
reload_plugin('NotifyDBus', replace_in=current_module)
# Create the instance.
obj = apprise.Apprise.instantiate('glib://', suppress_exceptions=False) obj = apprise.Apprise.instantiate('glib://', suppress_exceptions=False)
assert isinstance(obj, NotifyDBus) is True assert isinstance(obj, NotifyDBus) is True
obj.duration = 0 obj.duration = 0
# Test url() call # Test url() call.
assert isinstance(obj.url(), str) is True assert isinstance(obj.url(), str) is True
# Our notification succeeds even though the gi library was not loaded # The notification succeeds even though the gi library was not loaded.
assert obj.notify( assert obj.notify(
title='title', body='body', title='title', body='body',
notify_type=apprise.NotifyType.INFO) is True notify_type=apprise.NotifyType.INFO) is True
# Verify this all works in the event a ValueError is also thronw
# out of the call to gi.require_version()
mock_sessionbus.side_effect = DBusException('test') def test_plugin_dbus_gi_require_version_error(dbus_glib_environment):
# Handle Dbus Session Initialization error """
assert obj.notify( Verify notification succeeds even if `gi.require_version()` croaks.
title='title', body='body', """
notify_type=apprise.NotifyType.INFO) is False
# Return side effect to normal # Make `require_version` function raise a ValueError.
mock_sessionbus.side_effect = None gi = importlib.import_module("gi")
gi.require_version.side_effect = ValueError("Something failed")
# Emulate require_version function: # When patching something which has a side effect on the module-level code
gi.require_version.side_effect = ValueError() # of a plugin, make sure to reload it.
reload_plugin('NotifyDBus') current_module = sys.modules[__name__]
from apprise.plugins.NotifyDBus import NotifyDBus reload_plugin('NotifyDBus', replace_in=current_module)
# Create our instance # Create instance.
obj = apprise.Apprise.instantiate('glib://', suppress_exceptions=False) obj = apprise.Apprise.instantiate('glib://', suppress_exceptions=False)
assert isinstance(obj, NotifyDBus) is True assert isinstance(obj, NotifyDBus) is True
obj.duration = 0 obj.duration = 0
# Test url() call # Test url() call.
assert isinstance(obj.url(), str) is True assert isinstance(obj.url(), str) is True
# Our notification succeeds even though the gi library was not loaded # The notification succeeds even though the gi library was not loaded.
assert obj.notify( assert obj.notify(
title='title', body='body', title='title', body='body',
notify_type=apprise.NotifyType.INFO) is True notify_type=apprise.NotifyType.INFO) is True
# Force a global import error
_session_bus = sys.modules['dbus']
sys.modules['dbus'] = compile('raise ImportError()', 'dbus', 'exec')
# Reload our modules def test_plugin_dbus_module_croaks(mocker, dbus_glib_environment):
reload_plugin('NotifyDBus') """
Verify plugin is not available when `dbus` module is missing.
"""
# We can no longer instantiate an instance because dbus has been # Make importing `dbus` raise an ImportError.
# officialy marked unavailable and thus the module is marked mocker.patch.dict(
# as such sys.modules, {'dbus': compile('raise ImportError()', 'dbus', 'exec')})
# When patching something which has a side effect on the module-level code
# of a plugin, make sure to reload it.
current_module = sys.modules[__name__]
reload_plugin('NotifyDBus', replace_in=current_module)
# Verify plugin is not available.
obj = apprise.Apprise.instantiate('glib://', suppress_exceptions=False) obj = apprise.Apprise.instantiate('glib://', suppress_exceptions=False)
assert obj is None assert obj is None
# Since playing with the sys.modules is not such a good idea,
# let's just put our old configuration back: def test_plugin_dbus_session_croaks(mocker, dbus_glib_environment):
sys.modules['dbus'] = _session_bus """
# Reload our modules Verify notification fails if DBus croaks.
reload_plugin('NotifyDBus') """
mocker.patch('dbus.SessionBus', side_effect=DBusException('test'))
setup_glib_environment()
obj = apprise.Apprise.instantiate('dbus://', suppress_exceptions=False)
# Emulate DBus session initialization error.
assert obj.notify(
title='title', body='body',
notify_type=apprise.NotifyType.INFO) is False
def test_plugin_dbus_interface_notify_croaks(mocker):
"""
Fail gracefully if underlying object croaks for whatever reason.
"""
# Inject an error when invoking `dbus.Interface().Notify()`.
mocker.patch('dbus.SessionBus', spec=True)
mocker.patch('dbus.Interface', spec=True,
Notify=Mock(side_effect=AttributeError("Something failed")))
setup_glib_environment()
obj = apprise.Apprise.instantiate('dbus://', suppress_exceptions=False)
assert isinstance(obj, NotifyDBus) is True
logger: Mock = mocker.spy(obj, "logger")
assert obj.notify(
title='title', body='body',
notify_type=apprise.NotifyType.INFO) is False
assert [
call.warning('Failed to send DBus notification. '
'Reason: Something failed'),
call.exception('DBus Exception')
] in logger.mock_calls

View File

@ -22,24 +22,26 @@
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
import importlib
import logging
import sys import sys
import types import types
from unittest import mock from unittest import mock
from unittest.mock import Mock, call, ANY
import pytest
import apprise import apprise
from apprise.plugins.NotifyGnome import GnomeUrgency from apprise.plugins.NotifyGnome import GnomeUrgency, NotifyGnome
from helpers import reload_plugin from helpers import reload_plugin
# Disable logging for a cleaner testing output # Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL) logging.disable(logging.CRITICAL)
def test_plugin_gnome_general(): def setup_glib_environment():
""" """
NotifyGnome() General Checks Setup a heavily mocked Glib environment.
""" """
# Our module base # Our module base
@ -89,12 +91,30 @@ def test_plugin_gnome_general():
mock_notify.new.return_value = notify_obj mock_notify.new.return_value = notify_obj
mock_pixbuf.new_from_file.return_value = True mock_pixbuf.new_from_file.return_value = True
reload_plugin('NotifyGnome') # When patching something which has a side effect on the module-level code
from apprise.plugins.NotifyGnome import NotifyGnome # of a plugin, make sure to reload it.
current_module = sys.modules[__name__]
reload_plugin('NotifyGnome', replace_in=current_module)
@pytest.fixture
def glib_environment():
"""
Fixture to provide a mocked Glib environment to test case functions.
"""
setup_glib_environment()
@pytest.fixture
def obj(glib_environment):
"""
Fixture to provide a mocked Apprise instance.
"""
# Create our instance # Create our instance
obj = apprise.Apprise.instantiate('gnome://', suppress_exceptions=False) obj = apprise.Apprise.instantiate('gnome://', suppress_exceptions=False)
assert obj is not None assert obj is not None
assert isinstance(obj, NotifyGnome) is True
# Set our duration to 0 to speed up timeouts (for testing) # Set our duration to 0 to speed up timeouts (for testing)
obj.duration = 0 obj.duration = 0
@ -102,6 +122,14 @@ def test_plugin_gnome_general():
# Check that it found our mocked environments # Check that it found our mocked environments
assert obj.enabled is True assert obj.enabled is True
return obj
def test_plugin_gnome_general_success(obj):
"""
NotifyGnome() general checks
"""
# Test url() call # Test url() call
assert isinstance(obj.url(), str) is True assert isinstance(obj.url(), str) is True
@ -113,9 +141,14 @@ def test_plugin_gnome_general():
assert obj.notify(title='', body='body', assert obj.notify(title='', body='body',
notify_type=apprise.NotifyType.INFO) is True notify_type=apprise.NotifyType.INFO) is True
def test_plugin_gnome_image_success(glib_environment):
"""
Verify using the `image` query argument works as intended.
"""
obj = apprise.Apprise.instantiate( obj = apprise.Apprise.instantiate(
'gnome://_/?image=True', suppress_exceptions=False) 'gnome://_/?image=True', suppress_exceptions=False)
print("obj:", obj, type(obj))
assert isinstance(obj, NotifyGnome) is True assert isinstance(obj, NotifyGnome) is True
assert obj.notify(title='title', body='body', assert obj.notify(title='title', body='body',
notify_type=apprise.NotifyType.INFO) is True notify_type=apprise.NotifyType.INFO) is True
@ -126,6 +159,12 @@ def test_plugin_gnome_general():
assert obj.notify(title='title', body='body', assert obj.notify(title='title', body='body',
notify_type=apprise.NotifyType.INFO) is True notify_type=apprise.NotifyType.INFO) is True
def test_plugin_gnome_priority(glib_environment):
"""
Verify correctness of the `priority` query argument.
"""
# Test Priority (alias of urgency) # Test Priority (alias of urgency)
obj = apprise.Apprise.instantiate( obj = apprise.Apprise.instantiate(
'gnome://_/?priority=invalid', suppress_exceptions=False) 'gnome://_/?priority=invalid', suppress_exceptions=False)
@ -148,6 +187,12 @@ def test_plugin_gnome_general():
assert obj.notify(title='title', body='body', assert obj.notify(title='title', body='body',
notify_type=apprise.NotifyType.INFO) is True notify_type=apprise.NotifyType.INFO) is True
def test_plugin_gnome_urgency(glib_environment):
"""
Verify correctness of the `urgency` query argument.
"""
# Test Urgeny # Test Urgeny
obj = apprise.Apprise.instantiate( obj = apprise.Apprise.instantiate(
'gnome://_/?urgency=invalid', suppress_exceptions=False) 'gnome://_/?urgency=invalid', suppress_exceptions=False)
@ -170,6 +215,12 @@ def test_plugin_gnome_general():
assert obj.notify(title='title', body='body', assert obj.notify(title='title', body='body',
notify_type=apprise.NotifyType.INFO) is True notify_type=apprise.NotifyType.INFO) is True
def test_plugin_gnome_parse_configuration(obj):
"""
Verify configuration parsing works correctly.
"""
# Test configuration parsing # Test configuration parsing
content = """ content = """
urls: urls:
@ -239,43 +290,82 @@ def test_plugin_gnome_general():
for s in aobj.find(tag='gnome_invalid'): for s in aobj.find(tag='gnome_invalid'):
assert s.urgency == GnomeUrgency.NORMAL assert s.urgency == GnomeUrgency.NORMAL
# Test our loading of our icon exception; it will still allow the
# notification to be sent def test_plugin_gnome_missing_icon(mocker, obj):
mock_pixbuf.new_from_file.side_effect = AttributeError() """
Verify the notification will be submitted, even if loading the icon fails.
"""
# Inject error when loading icon.
gi = importlib.import_module("gi")
gi.repository.GdkPixbuf.Pixbuf.new_from_file.side_effect = \
AttributeError("Something failed")
logger: Mock = mocker.spy(obj, "logger")
assert obj.notify(title='title', body='body', assert obj.notify(title='title', body='body',
notify_type=apprise.NotifyType.INFO) is True notify_type=apprise.NotifyType.INFO) is True
# Undo our change assert logger.mock_calls == [
mock_pixbuf.new_from_file.side_effect = None call.warning('Could not load notification icon (%s). '
'Reason: Something failed', ANY),
call.info('Sent Gnome notification.'),
]
# Test our exception handling during initialization
sys.modules['gi.repository.Notify']\
.Notification.new.return_value = None
sys.modules['gi.repository.Notify']\
.Notification.new.side_effect = AttributeError()
assert obj.notify(title='title', body='body',
notify_type=apprise.NotifyType.INFO) is False
# Undo our change def test_plugin_gnome_disabled_plugin(obj):
sys.modules['gi.repository.Notify']\ """
.Notification.new.side_effect = None Verify notification will not be submitted if plugin is disabled.
"""
# Toggle our testing for when we can't send notifications because the
# package has been made unavailable to us
obj.enabled = False obj.enabled = False
assert obj.notify(title='title', body='body', assert obj.notify(title='title', body='body',
notify_type=apprise.NotifyType.INFO) is False notify_type=apprise.NotifyType.INFO) is False
# Test the setting of a the urgency (through priority keyword)
def test_plugin_gnome_set_urgency():
"""
Test the setting of an urgency, through `priority` keyword argument.
"""
NotifyGnome(priority=0) NotifyGnome(priority=0)
# Verify this all works in the event a ValueError is also thronw
# out of the call to gi.require_version()
# Emulate require_version function: def test_plugin_gnome_gi_croaks():
gi.require_version.side_effect = ValueError() """
reload_plugin('NotifyGnome') Verify notification fails when `gi.require_version()` croaks.
"""
# We can now no longer load our instance # Make `require_version` function raise an error.
# The object internally is marked disabled try:
gi = importlib.import_module("gi")
except ModuleNotFoundError:
raise pytest.skip("`gi` package not installed")
gi.require_version.side_effect = ValueError("Something failed")
# When patching something which has a side effect on the module-level code
# of a plugin, make sure to reload it.
current_module = sys.modules[__name__]
reload_plugin('NotifyGnome', replace_in=current_module)
# Create instance.
obj = apprise.Apprise.instantiate('gnome://', suppress_exceptions=False) obj = apprise.Apprise.instantiate('gnome://', suppress_exceptions=False)
# The notifier is marked disabled.
assert obj is None assert obj is None
def test_plugin_gnome_notify_croaks(mocker, obj):
"""
Fail gracefully if underlying object croaks for whatever reason.
"""
# Inject an error when invoking `gi.repository.Notify`.
mocker.patch('gi.repository.Notify.Notification.new',
side_effect=AttributeError("Something failed"))
logger: Mock = mocker.spy(obj, "logger")
assert obj.notify(
title='title', body='body',
notify_type=apprise.NotifyType.INFO) is False
assert logger.mock_calls == [
call.warning('Failed to send Gnome notification. '
'Reason: Something failed'),
call.exception('Gnome Exception')
]

View File

@ -22,49 +22,67 @@
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
import logging
import os import os
from unittest import mock import sys
from unittest.mock import Mock
from helpers import reload_plugin import pytest
import apprise import apprise
from apprise.plugins.NotifyMacOSX import NotifyMacOSX
from helpers import reload_plugin
# Disable logging for a cleaner testing output
import logging # Disable logging for a cleaner testing output.
logging.disable(logging.CRITICAL) logging.disable(logging.CRITICAL)
@mock.patch('subprocess.Popen') @pytest.fixture
@mock.patch('platform.system') def pretend_macos(mocker):
@mock.patch('platform.mac_ver')
def test_plugin_macosx_general(mock_macver, mock_system, mock_popen, tmpdir):
""" """
NotifyMacOSX() General Checks Fixture to simulate a macOS environment.
""" """
mocker.patch("platform.system", return_value="Darwin")
mocker.patch("platform.mac_ver", return_value=('10.8', ('', '', ''), ''))
# Create a temporary binary file we can reference # Reload plugin module, in order to re-run module-level code.
script = tmpdir.join("terminal-notifier") current_module = sys.modules[__name__]
script.write('') reload_plugin("NotifyMacOSX", replace_in=current_module)
# Give execute bit
os.chmod(str(script), 0o755)
mock_cmd_response = mock.Mock()
# Set a successful response
mock_cmd_response.returncode = 0
# Simulate a Mac Environment @pytest.fixture
mock_system.return_value = 'Darwin' def terminal_notifier(mocker, tmp_path):
mock_macver.return_value = ('10.8', ('', '', ''), '') """
mock_popen.return_value = mock_cmd_response Fixture for providing a surrogate for the `terminal-notifier` program.
"""
notifier_program = tmp_path.joinpath("terminal-notifier")
notifier_program.write_text('#!/bin/sh\n\necho hello')
# Ensure our environment is loaded with this configuration # Set execute bit.
reload_plugin('NotifyMacOSX') os.chmod(notifier_program, 0o755)
from apprise.plugins.NotifyMacOSX import NotifyMacOSX
# Point our object to our new temporary existing file # Make the notifier use the temporary file instead of `terminal-notifier`.
NotifyMacOSX.notify_paths = (str(script), ) mocker.patch("apprise.plugins.NotifyMacOSX.NotifyMacOSX.notify_paths",
(str(notifier_program),))
yield notifier_program
@pytest.fixture
def macos_notify_environment(pretend_macos, terminal_notifier):
"""
Fixture to bundle general test case setup.
Use this fixture if you don't need access to the individual members.
"""
pass
def test_plugin_macosx_general_success(macos_notify_environment):
"""
NotifyMacOSX() general checks
"""
obj = apprise.Apprise.instantiate( obj = apprise.Apprise.instantiate(
'macosx://_/?image=True', suppress_exceptions=False) 'macosx://_/?image=True', suppress_exceptions=False)
@ -103,68 +121,81 @@ def test_plugin_macosx_general(mock_macver, mock_system, mock_popen, tmpdir):
assert obj.notify(title='title', body='body', assert obj.notify(title='title', body='body',
notify_type=apprise.NotifyType.INFO) is True notify_type=apprise.NotifyType.INFO) is True
# If our binary is inacccessible (or not executable), we can
# no longer send our notifications def test_plugin_macosx_terminal_notifier_not_executable(
os.chmod(str(script), 0o644) pretend_macos, terminal_notifier):
"""
When the `terminal-notifier` program is inaccessible or not executable,
we are unable to send notifications.
"""
obj = apprise.Apprise.instantiate('macosx://', suppress_exceptions=False)
# Unset the executable bit.
os.chmod(terminal_notifier, 0o644)
assert obj.notify(title='title', body='body', assert obj.notify(title='title', body='body',
notify_type=apprise.NotifyType.INFO) is False notify_type=apprise.NotifyType.INFO) is False
# Restore permission
os.chmod(str(script), 0o755)
# But now let's disrupt the path location def test_plugin_macosx_terminal_notifier_invalid(macos_notify_environment):
"""
When the `terminal-notifier` program is wrongly addressed,
notifications should fail.
"""
obj = apprise.Apprise.instantiate('macosx://', suppress_exceptions=False)
# Let's disrupt the path location.
obj.notify_path = 'invalid_missing-file' obj.notify_path = 'invalid_missing-file'
assert not os.path.isfile(obj.notify_path) assert not os.path.isfile(obj.notify_path)
assert obj.notify(title='title', body='body', assert obj.notify(title='title', body='body',
notify_type=apprise.NotifyType.INFO) is False notify_type=apprise.NotifyType.INFO) is False
# Test cases where the script just flat out fails
mock_cmd_response.returncode = 1 def test_plugin_macosx_terminal_notifier_croaks(
obj = apprise.Apprise.instantiate( mocker, macos_notify_environment):
'macosx://', suppress_exceptions=False) """
When the `terminal-notifier` program croaks on execution,
notifications should fail.
"""
# Emulate a failing program.
mocker.patch("subprocess.Popen", return_value=Mock(returncode=1))
obj = apprise.Apprise.instantiate('macosx://', suppress_exceptions=False)
assert isinstance(obj, NotifyMacOSX) is True assert isinstance(obj, NotifyMacOSX) is True
assert obj.notify(title='title', body='body', assert obj.notify(title='title', body='body',
notify_type=apprise.NotifyType.INFO) is False notify_type=apprise.NotifyType.INFO) is False
# Restore script return value
mock_cmd_response.returncode = 0
# Test case where we simply aren't on a mac def test_plugin_macosx_pretend_linux(mocker, pretend_macos):
mock_system.return_value = 'Linux' """
reload_plugin('NotifyMacOSX') The notification object is disabled when pretending to run on Linux.
"""
# Point our object to our new temporary existing file # When patching something which has a side effect on the module-level code
NotifyMacOSX.notify_paths = (str(script), ) # of a plugin, make sure to reload it.
mocker.patch("platform.system", return_value="Linux")
reload_plugin("NotifyMacOSX")
# Our object is disabled # Our object is disabled.
obj = apprise.Apprise.instantiate( obj = apprise.Apprise.instantiate('macosx://', suppress_exceptions=False)
'macosx://_/?sound=default', suppress_exceptions=False)
assert obj is None assert obj is None
# Restore mac environment
mock_system.return_value = 'Darwin'
# Now we must be Mac OS v10.8 or higher... @pytest.mark.parametrize("macos_version", ["9.12", "10.7"])
mock_macver.return_value = ('10.7', ('', '', ''), '') def test_plugin_macosx_pretend_old_macos(mocker, macos_version):
reload_plugin('NotifyMacOSX') """
The notification object is disabled when pretending to run on older macOS.
"""
# Point our object to our new temporary existing file # When patching something which has a side effect on the module-level code
NotifyMacOSX.notify_paths = (str(script), ) # of a plugin, make sure to reload it.
mocker.patch("platform.mac_ver",
return_value=(macos_version, ('', '', ''), ''))
reload_plugin("NotifyMacOSX")
obj = apprise.Apprise.instantiate( obj = apprise.Apprise.instantiate('macosx://', suppress_exceptions=False)
'macosx://_/?sound=default', suppress_exceptions=False)
assert obj is None
# A newer environment to test edge case where this is tested
mock_macver.return_value = ('9.12', ('', '', ''), '')
reload_plugin('NotifyMacOSX')
# Point our object to our new temporary existing file
NotifyMacOSX.notify_paths = (str(script), )
# This is just to test that the the minor (in this case .12)
# is only weighed with respect to the major number as wel
# with respect to the versioning
obj = apprise.Apprise.instantiate(
'macosx://_/?sound=default', suppress_exceptions=False)
assert obj is None assert obj is None