mirror of
https://github.com/caronc/apprise.git
synced 2024-11-25 17:44:00 +01:00
refactor: handle parallel synchronous notifications with a thread pool (#839)
This commit is contained in:
parent
6458ab0506
commit
3a2af45e4d
@ -31,8 +31,8 @@
|
|||||||
# POSSIBILITY OF SUCH DAMAGE.
|
# POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import concurrent.futures as cf
|
||||||
import os
|
import os
|
||||||
from functools import partial
|
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
from . import common
|
from . import common
|
||||||
from .conversion import convert_between
|
from .conversion import convert_between
|
||||||
@ -376,7 +376,7 @@ class Apprise:
|
|||||||
try:
|
try:
|
||||||
# Process arguments and build synchronous and asynchronous calls
|
# Process arguments and build synchronous and asynchronous calls
|
||||||
# (this step can throw internal errors).
|
# (this step can throw internal errors).
|
||||||
sync_partials, async_cors = self._create_notify_calls(
|
sequential_calls, parallel_calls = self._create_notify_calls(
|
||||||
body, title,
|
body, title,
|
||||||
notify_type=notify_type, body_format=body_format,
|
notify_type=notify_type, body_format=body_format,
|
||||||
tag=tag, match_always=match_always, attach=attach,
|
tag=tag, match_always=match_always, attach=attach,
|
||||||
@ -387,49 +387,13 @@ class Apprise:
|
|||||||
# No notifications sent, and there was an internal error.
|
# No notifications sent, and there was an internal error.
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not sync_partials and not async_cors:
|
if not sequential_calls and not parallel_calls:
|
||||||
# Nothing to send
|
# Nothing to send
|
||||||
return None
|
return None
|
||||||
|
|
||||||
sync_result = Apprise._notify_all(*sync_partials)
|
sequential_result = Apprise._notify_sequential(*sequential_calls)
|
||||||
|
parallel_result = Apprise._notify_parallel_threadpool(*parallel_calls)
|
||||||
if async_cors:
|
return sequential_result and parallel_result
|
||||||
# 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):
|
async def async_notify(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -442,41 +406,42 @@ class Apprise:
|
|||||||
try:
|
try:
|
||||||
# Process arguments and build synchronous and asynchronous calls
|
# Process arguments and build synchronous and asynchronous calls
|
||||||
# (this step can throw internal errors).
|
# (this step can throw internal errors).
|
||||||
sync_partials, async_cors = self._create_notify_calls(
|
sequential_calls, parallel_calls = self._create_notify_calls(
|
||||||
*args, **kwargs)
|
*args, **kwargs)
|
||||||
|
|
||||||
except TypeError:
|
except TypeError:
|
||||||
# No notifications sent, and there was an internal error.
|
# No notifications sent, and there was an internal error.
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not sync_partials and not async_cors:
|
if not sequential_calls and not parallel_calls:
|
||||||
# Nothing to send
|
# Nothing to send
|
||||||
return None
|
return None
|
||||||
|
|
||||||
sync_result = Apprise._notify_all(*sync_partials)
|
sequential_result = Apprise._notify_sequential(*sequential_calls)
|
||||||
async_result = await Apprise._async_notify_all(*async_cors)
|
parallel_result = \
|
||||||
return sync_result and async_result
|
await Apprise._notify_parallel_asyncio(*parallel_calls)
|
||||||
|
return sequential_result and parallel_result
|
||||||
|
|
||||||
def _create_notify_calls(self, *args, **kwargs):
|
def _create_notify_calls(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Creates notifications for all the plugins loaded.
|
Creates notifications for all the plugins loaded.
|
||||||
|
|
||||||
Returns a list of synchronous calls (partial functions with no
|
Returns a list of (server, notify() kwargs) tuples for plugins with
|
||||||
arguments required) for plugins with async disabled and a list of
|
parallelism disabled and another list for plugins with parallelism
|
||||||
asynchronous calls (coroutines) for plugins with async enabled.
|
enabled.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
all_calls = list(self._create_notify_gen(*args, **kwargs))
|
all_calls = list(self._create_notify_gen(*args, **kwargs))
|
||||||
|
|
||||||
# Split into synchronous partials and asynchronous coroutines.
|
# Split into sequential and parallel notify() calls.
|
||||||
sync_partials, async_cors = [], []
|
sequential, parallel = [], []
|
||||||
for notify in all_calls:
|
for (server, notify_kwargs) in all_calls:
|
||||||
if asyncio.iscoroutine(notify):
|
if server.asset.async_mode:
|
||||||
async_cors.append(notify)
|
parallel.append((server, notify_kwargs))
|
||||||
else:
|
else:
|
||||||
sync_partials.append(notify)
|
sequential.append((server, notify_kwargs))
|
||||||
|
|
||||||
return sync_partials, async_cors
|
return sequential, parallel
|
||||||
|
|
||||||
def _create_notify_gen(self, body, title='',
|
def _create_notify_gen(self, body, title='',
|
||||||
notify_type=common.NotifyType.INFO,
|
notify_type=common.NotifyType.INFO,
|
||||||
@ -584,23 +549,20 @@ class Apprise:
|
|||||||
attach=attach,
|
attach=attach,
|
||||||
body_format=body_format
|
body_format=body_format
|
||||||
)
|
)
|
||||||
if server.asset.async_mode:
|
yield (server, kwargs)
|
||||||
yield server.async_notify(**kwargs)
|
|
||||||
else:
|
|
||||||
yield partial(server.notify, **kwargs)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _notify_all(*partials):
|
def _notify_sequential(*servers_kwargs):
|
||||||
"""
|
"""
|
||||||
Process a list of synchronous notify() calls.
|
Process a list of notify() calls sequentially and synchronously.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
success = True
|
success = True
|
||||||
|
|
||||||
for notify in partials:
|
for (server, kwargs) in servers_kwargs:
|
||||||
try:
|
try:
|
||||||
# Send notification
|
# Send notification
|
||||||
result = notify()
|
result = server.notify(**kwargs)
|
||||||
success = success and result
|
success = success and result
|
||||||
|
|
||||||
except TypeError:
|
except TypeError:
|
||||||
@ -616,14 +578,59 @@ class Apprise:
|
|||||||
return success
|
return success
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _async_notify_all(*cors):
|
def _notify_parallel_threadpool(*servers_kwargs):
|
||||||
"""
|
"""
|
||||||
Process a list of asynchronous async_notify() calls.
|
Process a list of notify() calls in parallel and synchronously.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# 0-length case
|
||||||
|
if not servers_kwargs:
|
||||||
|
return True
|
||||||
|
|
||||||
# Create log entry
|
# Create log entry
|
||||||
logger.info('Notifying %d service(s) asynchronously.', len(cors))
|
logger.info(
|
||||||
|
'Notifying %d service(s) with threads.', len(servers_kwargs))
|
||||||
|
|
||||||
|
with cf.ThreadPoolExecutor() as executor:
|
||||||
|
success = True
|
||||||
|
futures = [executor.submit(server.notify, **kwargs)
|
||||||
|
for (server, kwargs) in servers_kwargs]
|
||||||
|
|
||||||
|
for future in cf.as_completed(futures):
|
||||||
|
try:
|
||||||
|
result = future.result()
|
||||||
|
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 _notify_parallel_asyncio(*servers_kwargs):
|
||||||
|
"""
|
||||||
|
Process a list of async_notify() calls in parallel and asynchronously.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 0-length case
|
||||||
|
if not servers_kwargs:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Create log entry
|
||||||
|
logger.info(
|
||||||
|
'Notifying %d service(s) asynchronously.', len(servers_kwargs))
|
||||||
|
|
||||||
|
async def do_call(server, kwargs):
|
||||||
|
return await server.async_notify(**kwargs)
|
||||||
|
|
||||||
|
cors = (do_call(server, kwargs) for (server, kwargs) in servers_kwargs)
|
||||||
results = await asyncio.gather(*cors, return_exceptions=True)
|
results = await asyncio.gather(*cors, return_exceptions=True)
|
||||||
|
|
||||||
if any(isinstance(status, Exception)
|
if any(isinstance(status, Exception)
|
||||||
|
@ -32,6 +32,7 @@
|
|||||||
|
|
||||||
from __future__ import print_function
|
from __future__ import print_function
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import concurrent.futures
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import pytest
|
import pytest
|
||||||
@ -1781,7 +1782,9 @@ def test_apprise_details_plugin_verification():
|
|||||||
|
|
||||||
@mock.patch('requests.post')
|
@mock.patch('requests.post')
|
||||||
@mock.patch('asyncio.gather', wraps=asyncio.gather)
|
@mock.patch('asyncio.gather', wraps=asyncio.gather)
|
||||||
def test_apprise_async_mode(mock_gather, mock_post, tmpdir):
|
@mock.patch('concurrent.futures.ThreadPoolExecutor',
|
||||||
|
wraps=concurrent.futures.ThreadPoolExecutor)
|
||||||
|
def test_apprise_async_mode(mock_threadpool, mock_gather, mock_post, tmpdir):
|
||||||
"""
|
"""
|
||||||
API: Apprise() async_mode tests
|
API: Apprise() async_mode tests
|
||||||
|
|
||||||
@ -1814,9 +1817,9 @@ def test_apprise_async_mode(mock_gather, mock_post, tmpdir):
|
|||||||
# Send Notifications Asyncronously
|
# Send Notifications Asyncronously
|
||||||
assert a.notify("async") is True
|
assert a.notify("async") is True
|
||||||
|
|
||||||
# Verify our async code got executed
|
# Verify our thread pool was created
|
||||||
assert mock_gather.call_count > 0
|
assert mock_threadpool.call_count == 1
|
||||||
mock_gather.reset_mock()
|
mock_threadpool.reset_mock()
|
||||||
|
|
||||||
# Provide an over-ride now
|
# Provide an over-ride now
|
||||||
asset = AppriseAsset(async_mode=False)
|
asset = AppriseAsset(async_mode=False)
|
||||||
@ -1863,9 +1866,9 @@ def test_apprise_async_mode(mock_gather, mock_post, tmpdir):
|
|||||||
# Send 1 Notification Syncronously, the other Asyncronously
|
# Send 1 Notification Syncronously, the other Asyncronously
|
||||||
assert a.notify("a mixed batch") is True
|
assert a.notify("a mixed batch") is True
|
||||||
|
|
||||||
# Verify our async code got called
|
# Verify our thread pool was created
|
||||||
assert mock_gather.call_count > 0
|
assert mock_threadpool.call_count == 1
|
||||||
mock_gather.reset_mock()
|
mock_threadpool.reset_mock()
|
||||||
|
|
||||||
|
|
||||||
def test_notify_matrix_dynamic_importing(tmpdir):
|
def test_notify_matrix_dynamic_importing(tmpdir):
|
||||||
|
@ -561,7 +561,7 @@ def test_apprise_config_with_apprise_obj(tmpdir):
|
|||||||
super().__init__(
|
super().__init__(
|
||||||
notify_format=NotifyFormat.HTML, **kwargs)
|
notify_format=NotifyFormat.HTML, **kwargs)
|
||||||
|
|
||||||
async def async_notify(self, **kwargs):
|
def notify(self, **kwargs):
|
||||||
# Pretend everything is okay
|
# Pretend everything is okay
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user