diff --git a/apprise/Apprise.py b/apprise/Apprise.py index e93bede8..19dde830 100644 --- a/apprise/Apprise.py +++ b/apprise/Apprise.py @@ -30,7 +30,9 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. +import asyncio import os +from functools import partial from itertools import chain from . import common from .conversion import convert_between @@ -50,11 +52,6 @@ from .plugins.NotifyBase import NotifyBase from . import plugins from . import __version__ -# Python v3+ support code made importable, so it can remain backwards -# compatible with Python v2 -# TODO: Review after dropping support for Python 2. -from . import py3compat - class Apprise: """ @@ -376,91 +373,118 @@ class Apprise: such as turning a \n into an actual new line, etc. """ - return py3compat.asyncio.tosync( - self.async_notify( + try: + # Process arguments and build synchronous and asynchronous calls + # (this step can throw internal errors). + sync_partials, async_cors = self._create_notify_calls( body, title, notify_type=notify_type, body_format=body_format, tag=tag, match_always=match_always, attach=attach, - interpret_escapes=interpret_escapes, - ), - debug=self.debug - ) + interpret_escapes=interpret_escapes + ) - def async_notify(self, *args, **kwargs): + except TypeError: + # No notifications sent, and there was an internal error. + return False + + if not sync_partials and not async_cors: + # Nothing to send + return None + + sync_result = Apprise._notify_all(*sync_partials) + + if async_cors: + # A single coroutine sends all asynchronous notifications in + # parallel. + all_cor = Apprise._async_notify_all(*async_cors) + + try: + # Python <3.7 automatically starts an event loop if there isn't + # already one for the main thread. + loop = asyncio.get_event_loop() + + except RuntimeError: + # Python >=3.7 raises this exception if there isn't already an + # event loop. So, we can spin up our own. + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.set_debug(self.debug) + + # Run the coroutine and wait for the result. + async_result = loop.run_until_complete(all_cor) + + # Clean up the loop. + loop.close() + asyncio.set_event_loop(None) + + else: + old_debug = loop.get_debug() + loop.set_debug(self.debug) + + # Run the coroutine and wait for the result. + async_result = loop.run_until_complete(all_cor) + + loop.set_debug(old_debug) + + else: + async_result = True + + return sync_result and async_result + + async def async_notify(self, *args, **kwargs): """ Send a notification to all the plugins previously loaded, for - asynchronous callers. This method is an async method that should be - awaited on, even if it is missing the async keyword in its signature. - (This is omitted to preserve syntax compatibility with Python 2.) + asynchronous callers. The arguments are identical to those of Apprise.notify(). """ try: - coroutines = list( - self._notifyall( - Apprise._notifyhandlerasync, *args, **kwargs)) + # Process arguments and build synchronous and asynchronous calls + # (this step can throw internal errors). + sync_partials, async_cors = self._create_notify_calls( + *args, **kwargs) except TypeError: # No notifications sent, and there was an internal error. - return py3compat.asyncio.toasyncwrapvalue(False) - - else: - if len(coroutines) > 0: - # All notifications sent, return False if any failed. - return py3compat.asyncio.notify(coroutines) - - else: - # No notifications sent. - return py3compat.asyncio.toasyncwrapvalue(None) - - @staticmethod - def _notifyhandler(server, **kwargs): - """ - The synchronous notification sender. Returns True if the notification - sent successfully. - """ - - try: - # Send notification - return server.notify(**kwargs) - - except TypeError: - # These our our internally thrown notifications return 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. - logger.exception("Unhandled Notification Exception") - return False + if not sync_partials and not async_cors: + # Nothing to send + return None - @staticmethod - def _notifyhandlerasync(server, **kwargs): - """ - The asynchronous notification sender. Returns a coroutine that yields - True if the notification sent successfully. - """ + sync_result = Apprise._notify_all(*sync_partials) + async_result = await Apprise._async_notify_all(*async_cors) + return sync_result and async_result - if server.asset.async_mode: - return server.async_notify(**kwargs) - - else: - # Send the notification immediately, and wrap the result in a - # coroutine. - status = Apprise._notifyhandler(server, **kwargs) - return py3compat.asyncio.toasyncwrapvalue(status) - - def _notifyall(self, handler, body, title='', - notify_type=common.NotifyType.INFO, body_format=None, - tag=common.MATCH_ALL_TAG, match_always=True, attach=None, - interpret_escapes=None): + def _create_notify_calls(self, *args, **kwargs): """ Creates notifications for all the plugins loaded. - Returns a generator that calls handler for each notification. The first - and only argument supplied to handler is the server, and the keyword - arguments are exactly as they would be passed to server.notify(). + Returns a list of synchronous calls (partial functions with no + arguments required) for plugins with async disabled and a list of + asynchronous calls (coroutines) for plugins with async enabled. + """ + + all_calls = list(self._create_notify_gen(*args, **kwargs)) + + # Split into synchronous partials and asynchronous coroutines. + sync_partials, async_cors = [], [] + for notify in all_calls: + if asyncio.iscoroutine(notify): + async_cors.append(notify) + else: + sync_partials.append(notify) + + return sync_partials, async_cors + + def _create_notify_gen(self, body, title='', + notify_type=common.NotifyType.INFO, + body_format=None, tag=common.MATCH_ALL_TAG, + match_always=True, attach=None, + interpret_escapes=None): + """ + Internal generator function for _create_notify_calls(). """ if len(self) == 0: @@ -553,14 +577,67 @@ class Apprise: logger.error(msg) raise TypeError(msg) - yield handler( - server, + kwargs = dict( body=conversion_body_map[server.notify_format], title=conversion_title_map[server.notify_format], notify_type=notify_type, attach=attach, - body_format=body_format, + body_format=body_format ) + if server.asset.async_mode: + yield server.async_notify(**kwargs) + else: + yield partial(server.notify, **kwargs) + + @staticmethod + def _notify_all(*partials): + """ + Process a list of synchronous notify() calls. + """ + + success = True + + for notify in partials: + try: + # Send notification + result = notify() + success = success and result + + except TypeError: + # These are our internally thrown notifications. + success = 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. + logger.exception("Unhandled Notification Exception") + success = False + + return success + + @staticmethod + async def _async_notify_all(*cors): + """ + Process a list of asynchronous async_notify() calls. + """ + + # Create log entry + logger.info('Notifying %d service(s) asynchronously.', len(cors)) + + results = await asyncio.gather(*cors, return_exceptions=True) + + if any(isinstance(status, Exception) + and not isinstance(status, TypeError) for status in results): + # A catch all so we don't have to abort early just because + # one of our plugins has a bug in it. + logger.exception("Unhandled Notification Exception") + return False + + if any(isinstance(status, TypeError) for status in results): + # These are our internally thrown notifications. + return False + + return all(results) def details(self, lang=None, show_requirements=False, show_disabled=False): """ diff --git a/apprise/plugins/NotifyBase.py b/apprise/plugins/NotifyBase.py index 9678be66..1b07baa7 100644 --- a/apprise/plugins/NotifyBase.py +++ b/apprise/plugins/NotifyBase.py @@ -30,7 +30,9 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. +import asyncio import re +from functools import partial from ..URLBase import URLBase from ..common import NotifyType @@ -43,12 +45,7 @@ from ..AppriseLocale import gettext_lazy as _ from ..AppriseAttachment import AppriseAttachment -# Wrap our base with the asyncio wrapper -from ..py3compat.asyncio import AsyncNotifyBase -BASE_OBJECT = AsyncNotifyBase - - -class NotifyBase(BASE_OBJECT): +class NotifyBase(URLBase): """ This is the base class for all notification services """ @@ -274,19 +271,64 @@ class NotifyBase(BASE_OBJECT): color_type=color_type, ) - def notify(self, body, title=None, notify_type=NotifyType.INFO, - overflow=None, attach=None, body_format=None, **kwargs): + def notify(self, *args, **kwargs): """ Performs notification + """ + try: + # Build a list of dictionaries that can be used to call send(). + send_calls = list(self._build_send_calls(*args, **kwargs)) + except TypeError: + # Internal error + return False + + else: + # Loop through each call, one at a time. (Use a list rather than a + # generator to call all the partials, even in case of a failure.) + the_calls = [self.send(**kwargs2) for kwargs2 in send_calls] + return all(the_calls) + + async def async_notify(self, *args, **kwargs): + """ + Performs notification for asynchronous callers + """ + try: + # Build a list of dictionaries that can be used to call send(). + send_calls = list(self._build_send_calls(*args, **kwargs)) + + except TypeError: + # Internal error + return False + + else: + loop = asyncio.get_event_loop() + + # Wrap each call in a coroutine that uses the default executor. + # TODO: In the future, allow plugins to supply a native + # async_send() method. + async def do_send(**kwargs2): + send = partial(self.send, **kwargs2) + result = await loop.run_in_executor(None, send) + return result + + # gather() all calls in parallel. + the_cors = (do_send(**kwargs2) for kwargs2 in send_calls) + return all(await asyncio.gather(*the_cors)) + + def _build_send_calls(self, body, title=None, + notify_type=NotifyType.INFO, overflow=None, + attach=None, body_format=None, **kwargs): + """ + Get a list of dictionaries that can be used to call send() or + (in the future) async_send(). """ if not self.enabled: # Deny notifications issued to services that are disabled - self.logger.warning( - "{} is currently disabled on this system.".format( - self.service_name)) - return False + msg = f"{self.service_name} is currently disabled on this system." + self.logger.warning(msg) + raise TypeError(msg) # Prepare attachments if required if attach is not None and not isinstance(attach, AppriseAttachment): @@ -295,7 +337,7 @@ class NotifyBase(BASE_OBJECT): except TypeError: # bad attachments - return False + raise # Handle situations where the title is None title = '' if not title else title @@ -306,14 +348,11 @@ class NotifyBase(BASE_OBJECT): body_format=body_format): # Send notification - if not self.send(body=chunk['body'], title=chunk['title'], - notify_type=notify_type, attach=attach, - body_format=body_format): - - # Toggle our return status flag - return False - - return True + yield dict( + body=chunk['body'], title=chunk['title'], + notify_type=notify_type, attach=attach, + body_format=body_format + ) def _apply_overflow(self, body, title=None, overflow=None, body_format=None): diff --git a/apprise/py3compat/__init__.py b/apprise/py3compat/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/apprise/py3compat/asyncio.py b/apprise/py3compat/asyncio.py deleted file mode 100644 index ca0e586e..00000000 --- a/apprise/py3compat/asyncio.py +++ /dev/null @@ -1,147 +0,0 @@ -# -*- coding: utf-8 -*- -# BSD 3-Clause License -# -# Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE -# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. - -import sys -import asyncio -from functools import partial -from ..URLBase import URLBase -from ..logger import logger - - -# A global flag that tracks if we are Python v3.7 or higher -ASYNCIO_RUN_SUPPORT = \ - sys.version_info.major > 3 or \ - (sys.version_info.major == 3 and sys.version_info.minor >= 7) - - -async def notify(coroutines): - """ - An async wrapper to the AsyncNotifyBase.async_notify() calls allowing us - to call gather() and collect the responses - """ - - # Create log entry - logger.info( - 'Notifying {} service(s) asynchronously.'.format(len(coroutines))) - - results = await asyncio.gather(*coroutines, return_exceptions=True) - - # Returns True if all notifications succeeded, otherwise False is - # returned. - failed = any(not status or isinstance(status, Exception) - for status in results) - return not failed - - -def tosync(cor, debug=False): - """ - Await a coroutine from non-async code. - """ - - if ASYNCIO_RUN_SUPPORT: - try: - loop = asyncio.get_running_loop() - - except RuntimeError: - # There is no existing event loop, so we can start our own. - return asyncio.run(cor, debug=debug) - - else: - # Enable debug mode - loop.set_debug(debug) - - # Run the coroutine and wait for the result. - task = loop.create_task(cor) - return asyncio.ensure_future(task, loop=loop) - - else: - # The Deprecated Way (<= Python v3.6) - try: - # acquire access to our event loop - loop = asyncio.get_event_loop() - - except RuntimeError: - # This happens if we're inside a thread of another application - # where there is no running event_loop(). Pythong v3.7 and - # higher automatically take care of this case for us. But for - # the lower versions we need to do the following: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - # Enable debug mode - loop.set_debug(debug) - - return loop.run_until_complete(cor) - - -async def toasyncwrapvalue(v): - """ - Create a coroutine that, when run, returns the provided value. - """ - - return v - - -async def toasyncwrap(fn): - """ - Create a coroutine that, when run, executes the provided function. - """ - - return fn() - - -class AsyncNotifyBase(URLBase): - """ - asyncio wrapper for the NotifyBase object - """ - - async def async_notify(self, *args, **kwargs): - """ - Async Notification Wrapper - """ - - loop = asyncio.get_event_loop() - - try: - return await loop.run_in_executor( - None, partial(self.notify, *args, **kwargs)) - - except TypeError: - # These are our internally thrown notifications - pass - - except Exception: - # A catch-all so we don't have to abort early - # just because one of our plugins has a bug in it. - logger.exception("Notification Exception") - - return False diff --git a/test/helpers/__init__.py b/test/helpers/__init__.py index f29d3bfa..8b95595f 100644 --- a/test/helpers/__init__.py +++ b/test/helpers/__init__.py @@ -31,9 +31,11 @@ # POSSIBILITY OF SUCH DAMAGE. from .rest import AppriseURLTester +from .asyncio import OuterEventLoop from .module import reload_plugin __all__ = [ 'AppriseURLTester', + 'OuterEventLoop', 'reload_plugin', ] diff --git a/test/helpers/asyncio.py b/test/helpers/asyncio.py new file mode 100644 index 00000000..d76c4b57 --- /dev/null +++ b/test/helpers/asyncio.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2022 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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 +# THE SOFTWARE. +import asyncio + + +class OuterEventLoop(): + """ + An event loop that is easy to put up and tear down from synchronous test + code. + """ + + def __init__(self): + self._loop = None + + def __enter__(self): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + self._loop = loop + return loop + + def __exit__(self, type, value, traceback): + asyncio.set_event_loop(None) + self._loop.close() diff --git a/test/test_api.py b/test/test_api.py index 6c7b62c7..d97cae56 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -31,6 +31,7 @@ # POSSIBILITY OF SUCH DAMAGE. from __future__ import print_function +import asyncio import re import sys import pytest @@ -56,12 +57,9 @@ from apprise import common from apprise.plugins import __load_matrix from apprise.plugins import __reset_matrix from apprise.utils import parse_list +from helpers import OuterEventLoop import inspect -# Sending notifications requires the coroutines to be awaited, so we need to -# wrap the original function when mocking it. -import apprise.py3compat.asyncio as py3aio - # Disable logging for a cleaner testing output import logging logging.disable(logging.CRITICAL) @@ -86,10 +84,12 @@ def test_apprise_async(): API: Apprise() object asynchronous methods """ - def do_notify(server, *args, **kwargs): - return py3aio.tosync(server.async_notify(*args, **kwargs)) + with OuterEventLoop() as loop: + def do_notify(server, *args, **kwargs): + return loop.run_until_complete( + server.async_notify(*args, **kwargs)) - apprise_test(do_notify) + apprise_test(do_notify) def apprise_test(do_notify): @@ -299,6 +299,10 @@ def apprise_test(do_notify): # Pretend everything is okay raise TypeError() + async def async_notify(self, **kwargs): + # Pretend everything is okay (async) + raise TypeError() + def url(self, **kwargs): # Support URL return '' @@ -308,6 +312,10 @@ def apprise_test(do_notify): # Pretend everything is okay raise RuntimeError() + async def async_notify(self, **kwargs): + # Pretend everything is okay (async) + raise TypeError() + def url(self, **kwargs): # Support URL return '' @@ -318,6 +326,10 @@ def apprise_test(do_notify): # Pretend everything is okay return False + async def async_notify(self, **kwargs): + # Pretend everything is okay (async) + raise TypeError() + def url(self, **kwargs): # Support URL return '' @@ -546,10 +558,12 @@ def test_apprise_tagging_async(mock_post, mock_get): API: Apprise() object tagging functionality asynchronous methods """ - def do_notify(server, *args, **kwargs): - return py3aio.tosync(server.async_notify(*args, **kwargs)) + with OuterEventLoop() as loop: + def do_notify(server, *args, **kwargs): + return loop.run_until_complete( + server.async_notify(*args, **kwargs)) - apprise_tagging_test(mock_post, mock_get, do_notify) + apprise_tagging_test(mock_post, mock_get, do_notify) def apprise_tagging_test(mock_post, mock_get, do_notify): @@ -733,7 +747,10 @@ def test_apprise_notify_formats(tmpdir): # other if/else parts of the code that aren't otherwise called __load_matrix() - a = Apprise() + # Need to set async_mode=False to call notify() instead of async_notify(). + asset = AppriseAsset(async_mode=False) + + a = Apprise(asset=asset) # no items assert len(a) == 0 @@ -743,7 +760,7 @@ def test_apprise_notify_formats(tmpdir): notify_format = NotifyFormat.TEXT def __init__(self, **kwargs): - super().__init__() + super().__init__(**kwargs) def notify(self, **kwargs): # Pretend everything is okay @@ -759,7 +776,7 @@ def test_apprise_notify_formats(tmpdir): notify_format = NotifyFormat.HTML def __init__(self, **kwargs): - super().__init__() + super().__init__(**kwargs) def notify(self, **kwargs): # Pretend everything is okay @@ -775,7 +792,7 @@ def test_apprise_notify_formats(tmpdir): notify_format = NotifyFormat.MARKDOWN def __init__(self, **kwargs): - super().__init__() + super().__init__(**kwargs) def notify(self, **kwargs): # Pretend everything is okay @@ -1763,8 +1780,8 @@ def test_apprise_details_plugin_verification(): @mock.patch('requests.post') -@mock.patch('apprise.py3compat.asyncio.notify', wraps=py3aio.notify) -def test_apprise_async_mode(mock_async_notify, mock_post, tmpdir): +@mock.patch('asyncio.gather', wraps=asyncio.gather) +def test_apprise_async_mode(mock_gather, mock_post, tmpdir): """ API: Apprise() async_mode tests @@ -1798,8 +1815,8 @@ def test_apprise_async_mode(mock_async_notify, mock_post, tmpdir): assert a.notify("async") is True # Verify our async code got executed - assert mock_async_notify.call_count == 1 - mock_async_notify.reset_mock() + assert mock_gather.call_count > 0 + mock_gather.reset_mock() # Provide an over-ride now asset = AppriseAsset(async_mode=False) @@ -1823,9 +1840,9 @@ def test_apprise_async_mode(mock_async_notify, mock_post, tmpdir): # Send Notifications Syncronously assert a.notify("sync") is True - # Verify our async code got called - assert mock_async_notify.call_count == 1 - mock_async_notify.reset_mock() + # Sequential send doesn't require a gather + assert mock_gather.call_count == 0 + mock_gather.reset_mock() # another way of looking a our false set asset configuration assert a[0].asset.async_mode is False @@ -1847,8 +1864,8 @@ def test_apprise_async_mode(mock_async_notify, mock_post, tmpdir): assert a.notify("a mixed batch") is True # Verify our async code got called - assert mock_async_notify.call_count == 1 - mock_async_notify.reset_mock() + assert mock_gather.call_count > 0 + mock_gather.reset_mock() def test_notify_matrix_dynamic_importing(tmpdir): diff --git a/test/test_apprise_config.py b/test/test_apprise_config.py index e920866e..7dd9b6ff 100644 --- a/test/test_apprise_config.py +++ b/test/test_apprise_config.py @@ -561,7 +561,7 @@ def test_apprise_config_with_apprise_obj(tmpdir): super().__init__( notify_format=NotifyFormat.HTML, **kwargs) - def notify(self, **kwargs): + async def async_notify(self, **kwargs): # Pretend everything is okay return True diff --git a/test/test_asyncio.py b/test/test_asyncio.py index 72f0c646..c1ecc22d 100644 --- a/test/test_asyncio.py +++ b/test/test_asyncio.py @@ -39,8 +39,6 @@ from apprise import NotifyFormat from apprise.common import NOTIFY_SCHEMA_MAP -import apprise.py3compat.asyncio as py3aio - # Disable logging for a cleaner testing output import logging logging.disable(logging.CRITICAL) @@ -87,7 +85,11 @@ def test_apprise_asyncio_runtime_error(): import asyncio # Get our event loop - loop = asyncio.get_event_loop() + try: + loop = asyncio.get_event_loop() + + except RuntimeError: + loop = None # Adjust out event loop to not point at anything asyncio.set_event_loop(None) @@ -102,67 +104,6 @@ def test_apprise_asyncio_runtime_error(): # enough to create a new event loop and continue... assert a.notify(title="title", body="body") is True - # Verify we have an active event loop - new_loop = asyncio.get_event_loop() - - # We didn't throw an exception above; thus we have an event loop at - # this point - assert new_loop - - # Close off the internal loop created inside a.notify() - new_loop.close() - finally: # Restore our event loop (in the event the above test failed) asyncio.set_event_loop(loop) - - -@pytest.mark.skipif(sys.version_info < (3, 7), - reason="Requires Python 3.7+") -def test_apprise_works_in_async_loop(): - """ - API: Apprise() can execute synchronously in an existing event loop - - """ - class GoodNotification(NotifyBase): - def __init__(self, **kwargs): - super().__init__( - notify_format=NotifyFormat.HTML, **kwargs) - - def url(self, **kwargs): - # Support URL - return '' - - def send(self, **kwargs): - # Pretend everything is okay - return True - - @staticmethod - def parse_url(url, *args, **kwargs): - # always parseable - return NotifyBase.parse_url(url, verify_host=False) - - # Store our good notification in our schema map - NOTIFY_SCHEMA_MAP['good'] = GoodNotification - - # Create ourselves an Apprise object - a = Apprise() - - # Add a few entries - for _ in range(25): - a.add('good://') - - # To ensure backwards compatibility, it should be possible to call - # asynchronous Apprise methods from code that already uses an event loop, - # even when using the synchronous notify() method. - # see https://github.com/caronc/apprise/issues/610 - import asyncio - - def try_notify(): - a.notify(title="title", body="body") - - # Convert to a coroutine to run asynchronously. - cor = py3aio.toasyncwrap(try_notify) - - # Should execute successfully. - asyncio.run(cor) diff --git a/test/test_cli.py b/test/test_cli.py index 2207ec9c..d29a7b2c 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -68,6 +68,10 @@ def test_apprise_cli_nux_env(tmpdir): super().__init__(*args, **kwargs) def notify(self, **kwargs): + # Pretend everything is okay (when passing --disable-async) + return True + + async def async_notify(self, **kwargs): # Pretend everything is okay return True @@ -79,8 +83,8 @@ def test_apprise_cli_nux_env(tmpdir): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - def notify(self, **kwargs): - # Force a notification failure + async def async_notify(self, **kwargs): + # Pretend everything is okay return False def url(self, *args, **kwargs):