mirror of
https://github.com/caronc/apprise.git
synced 2025-06-21 10:17:46 +02:00
Asynchronous Dynamic Module Loading Support (#1071)
This commit is contained in:
parent
52aa7f4ddb
commit
26d8e45683
@ -32,6 +32,7 @@ import sys
|
|||||||
import time
|
import time
|
||||||
import hashlib
|
import hashlib
|
||||||
import inspect
|
import inspect
|
||||||
|
import threading
|
||||||
from .utils import import_module
|
from .utils import import_module
|
||||||
from .utils import Singleton
|
from .utils import Singleton
|
||||||
from .utils import parse_list
|
from .utils import parse_list
|
||||||
@ -60,6 +61,9 @@ class PluginManager(metaclass=Singleton):
|
|||||||
# The module path to scan
|
# The module path to scan
|
||||||
module_path = join(abspath(dirname(__file__)), _id)
|
module_path = join(abspath(dirname(__file__)), _id)
|
||||||
|
|
||||||
|
# thread safe loading
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Over-ride our class instantiation to provide a singleton
|
Over-ride our class instantiation to provide a singleton
|
||||||
@ -103,11 +107,15 @@ class PluginManager(metaclass=Singleton):
|
|||||||
# effort/overhead doing it again
|
# effort/overhead doing it again
|
||||||
self._paths_previously_scanned = set()
|
self._paths_previously_scanned = set()
|
||||||
|
|
||||||
|
# Track loaded module paths to prevent from loading them again
|
||||||
|
self._loaded = set()
|
||||||
|
|
||||||
def unload_modules(self, disable_native=False):
|
def unload_modules(self, disable_native=False):
|
||||||
"""
|
"""
|
||||||
Reset our object and unload all modules
|
Reset our object and unload all modules
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
if self._custom_module_map:
|
if self._custom_module_map:
|
||||||
# Handle Custom Module Assignments
|
# Handle Custom Module Assignments
|
||||||
for meta in self._custom_module_map.values():
|
for meta in self._custom_module_map.values():
|
||||||
@ -129,14 +137,19 @@ class PluginManager(metaclass=Singleton):
|
|||||||
self._disabled.clear()
|
self._disabled.clear()
|
||||||
|
|
||||||
# Reset our variables
|
# Reset our variables
|
||||||
self._module_map = None if not disable_native else {}
|
|
||||||
self._schema_map = {}
|
self._schema_map = {}
|
||||||
self._custom_module_map = {}
|
self._custom_module_map = {}
|
||||||
|
if disable_native:
|
||||||
|
self._module_map = {}
|
||||||
|
|
||||||
|
else:
|
||||||
|
self._module_map = None
|
||||||
|
self._loaded = set()
|
||||||
|
|
||||||
# Reset our path cache
|
# Reset our path cache
|
||||||
self._paths_previously_scanned = set()
|
self._paths_previously_scanned = set()
|
||||||
|
|
||||||
def load_modules(self, path=None, name=None):
|
def load_modules(self, path=None, name=None, force=False):
|
||||||
"""
|
"""
|
||||||
Load our modules into memory
|
Load our modules into memory
|
||||||
"""
|
"""
|
||||||
@ -145,6 +158,15 @@ class PluginManager(metaclass=Singleton):
|
|||||||
module_name_prefix = self.module_name_prefix if name is None else name
|
module_name_prefix = self.module_name_prefix if name is None else name
|
||||||
module_path = self.module_path if path is None else path
|
module_path = self.module_path if path is None else path
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
if not force and module_path in self._loaded:
|
||||||
|
# We're done
|
||||||
|
return
|
||||||
|
|
||||||
|
# Our base reference
|
||||||
|
module_count = len(self._module_map) if self._module_map else 0
|
||||||
|
schema_count = len(self._schema_map) if self._schema_map else 0
|
||||||
|
|
||||||
if not self:
|
if not self:
|
||||||
# Initialize our maps
|
# Initialize our maps
|
||||||
self._module_map = {}
|
self._module_map = {}
|
||||||
@ -152,9 +174,11 @@ class PluginManager(metaclass=Singleton):
|
|||||||
self._custom_module_map = {}
|
self._custom_module_map = {}
|
||||||
|
|
||||||
# Used for the detection of additional Notify Services objects
|
# Used for the detection of additional Notify Services objects
|
||||||
# The .py extension is optional as we support loading directories too
|
# The .py extension is optional as we support loading directories
|
||||||
|
# too
|
||||||
module_re = re.compile(
|
module_re = re.compile(
|
||||||
r'^(?P<name>' + self.fname_prefix + r'[a-z0-9]+)(\.py)?$', re.I)
|
r'^(?P<name>' + self.fname_prefix + r'[a-z0-9]+)(\.py)?$',
|
||||||
|
re.I)
|
||||||
|
|
||||||
t_start = time.time()
|
t_start = time.time()
|
||||||
for f in os.listdir(module_path):
|
for f in os.listdir(module_path):
|
||||||
@ -227,7 +251,8 @@ class PluginManager(metaclass=Singleton):
|
|||||||
if schema in self._schema_map:
|
if schema in self._schema_map:
|
||||||
logger.error(
|
logger.error(
|
||||||
"{} schema ({}) mismatch detected - {} to {}"
|
"{} schema ({}) mismatch detected - {} to {}"
|
||||||
.format(self.name, schema, self._schema_map, plugin))
|
.format(self.name, schema, self._schema_map,
|
||||||
|
plugin))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Assign plugin
|
# Assign plugin
|
||||||
@ -236,10 +261,16 @@ class PluginManager(metaclass=Singleton):
|
|||||||
logger.trace(
|
logger.trace(
|
||||||
'{} {} loaded in {:.6f}s'.format(
|
'{} {} loaded in {:.6f}s'.format(
|
||||||
self.name, module_name, (time.time() - tl_start)))
|
self.name, module_name, (time.time() - tl_start)))
|
||||||
|
|
||||||
|
# Track the directory loaded so we never load it again
|
||||||
|
self._loaded.add(module_path)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
'{} {}(s) and {} Schema(s) loaded in {:.4f}s'
|
'{} {}(s) and {} Schema(s) loaded in {:.4f}s'
|
||||||
.format(
|
.format(
|
||||||
self.name, len(self._module_map), len(self._schema_map),
|
self.name,
|
||||||
|
len(self._module_map) - module_count,
|
||||||
|
len(self._schema_map) - schema_count,
|
||||||
(time.time() - t_start)))
|
(time.time() - t_start)))
|
||||||
|
|
||||||
def module_detection(self, paths, cache=True):
|
def module_detection(self, paths, cache=True):
|
||||||
@ -334,6 +365,7 @@ class PluginManager(metaclass=Singleton):
|
|||||||
# end of _import_module()
|
# end of _import_module()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
for _path in paths:
|
for _path in paths:
|
||||||
path = os.path.abspath(os.path.expanduser(_path))
|
path = os.path.abspath(os.path.expanduser(_path))
|
||||||
if (cache and path in self._paths_previously_scanned) \
|
if (cache and path in self._paths_previously_scanned) \
|
||||||
@ -366,8 +398,8 @@ class PluginManager(metaclass=Singleton):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if not cache or \
|
if not cache or \
|
||||||
(cache and
|
(cache and new_path not in
|
||||||
new_path not in self._paths_previously_scanned):
|
self._paths_previously_scanned):
|
||||||
# Load our module
|
# Load our module
|
||||||
_import_module(new_path)
|
_import_module(new_path)
|
||||||
|
|
||||||
@ -375,8 +407,9 @@ class PluginManager(metaclass=Singleton):
|
|||||||
self._paths_previously_scanned.add(new_path)
|
self._paths_previously_scanned.add(new_path)
|
||||||
else:
|
else:
|
||||||
if os.path.isdir(path):
|
if os.path.isdir(path):
|
||||||
# This logic is safe to apply because we already validated
|
# This logic is safe to apply because we already
|
||||||
# the directories state above; update our path
|
# validated the directories state above; update our
|
||||||
|
# path
|
||||||
path = os.path.join(path, '__init__.py')
|
path = os.path.join(path, '__init__.py')
|
||||||
if cache and path in self._paths_previously_scanned:
|
if cache and path in self._paths_previously_scanned:
|
||||||
continue
|
continue
|
||||||
@ -714,4 +747,4 @@ class PluginManager(metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
Determines if object has loaded or not
|
Determines if object has loaded or not
|
||||||
"""
|
"""
|
||||||
return True if self._module_map is not None else False
|
return True if self._loaded and self._module_map is not None else False
|
||||||
|
@ -29,8 +29,10 @@
|
|||||||
import re
|
import re
|
||||||
import pytest
|
import pytest
|
||||||
import types
|
import types
|
||||||
|
import threading
|
||||||
from inspect import cleandoc
|
from inspect import cleandoc
|
||||||
|
|
||||||
|
from apprise import Apprise
|
||||||
from apprise.NotificationManager import NotificationManager
|
from apprise.NotificationManager import NotificationManager
|
||||||
from apprise.plugins.NotifyBase import NotifyBase
|
from apprise.plugins.NotifyBase import NotifyBase
|
||||||
|
|
||||||
@ -248,6 +250,48 @@ def test_notification_manager_module_loading(tmpdir):
|
|||||||
N_MGR.load_modules()
|
N_MGR.load_modules()
|
||||||
N_MGR.load_modules()
|
N_MGR.load_modules()
|
||||||
|
|
||||||
|
#
|
||||||
|
# Thread Testing
|
||||||
|
#
|
||||||
|
|
||||||
|
# This tests against a racing condition when the modules have not been
|
||||||
|
# loaded. When multiple instances of Apprise are all instantiated,
|
||||||
|
# the loading of the modules will occur for each instance if detected
|
||||||
|
# having not been previously done, this tests that we can dynamically
|
||||||
|
# support the loading of modules once whe multiple instances to apprise
|
||||||
|
# are instantiated.
|
||||||
|
thread_count = 10
|
||||||
|
|
||||||
|
def thread_test(result, no):
|
||||||
|
"""
|
||||||
|
Load our apprise object with valid URLs and store our result
|
||||||
|
"""
|
||||||
|
apobj = Apprise()
|
||||||
|
result[no] = apobj.add('json://localhost') and \
|
||||||
|
apobj.add('form://localhost') and \
|
||||||
|
apobj.add('xml://localhost')
|
||||||
|
|
||||||
|
# Unload our modules
|
||||||
|
N_MGR.unload_modules()
|
||||||
|
|
||||||
|
# Prepare threads to load
|
||||||
|
results = [None] * thread_count
|
||||||
|
threads = [
|
||||||
|
threading.Thread(target=thread_test, args=(results, no))
|
||||||
|
for no in range(thread_count)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Verify we can safely load our modules in a thread safe environment
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
for t in threads:
|
||||||
|
t.join()
|
||||||
|
|
||||||
|
# Verify we loaded our urls in all threads successfully
|
||||||
|
for result in results:
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
def test_notification_manager_decorators(tmpdir):
|
def test_notification_manager_decorators(tmpdir):
|
||||||
"""
|
"""
|
||||||
@ -376,6 +420,10 @@ def test_notification_manager_decorators(tmpdir):
|
|||||||
"""))
|
"""))
|
||||||
assert 'mytest' not in N_MGR
|
assert 'mytest' not in N_MGR
|
||||||
N_MGR.load_modules(path=str(notify_base))
|
N_MGR.load_modules(path=str(notify_base))
|
||||||
|
|
||||||
|
# It's still not loaded because the path has already been scanned
|
||||||
|
assert 'mytest' not in N_MGR
|
||||||
|
N_MGR.load_modules(path=str(notify_base), force=True)
|
||||||
assert 'mytest' in N_MGR
|
assert 'mytest' in N_MGR
|
||||||
|
|
||||||
# Could not be loaded because the filename did not align with the class
|
# Could not be loaded because the filename did not align with the class
|
||||||
@ -387,3 +435,7 @@ def test_notification_manager_decorators(tmpdir):
|
|||||||
N_MGR.load_modules(path=str(notify_base))
|
N_MGR.load_modules(path=str(notify_base))
|
||||||
# Our item is still loaded as expected
|
# Our item is still loaded as expected
|
||||||
assert 'mytest' in N_MGR
|
assert 'mytest' in N_MGR
|
||||||
|
|
||||||
|
# Simple test to make sure we can handle duplicate entries loaded
|
||||||
|
N_MGR.load_modules(path=str(notify_base), force=True)
|
||||||
|
N_MGR.load_modules(path=str(notify_base), force=True)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user