Asynchronous Dynamic Module Loading Support (#1071)

This commit is contained in:
Chris Caron 2024-03-03 14:22:17 -05:00 committed by GitHub
parent 52aa7f4ddb
commit 26d8e45683
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 254 additions and 169 deletions

View File

@ -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

View File

@ -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)