diff --git a/openlp/core/common/__init__.py b/openlp/core/common/__init__.py index b7d38ad4f..66e83b26b 100644 --- a/openlp/core/common/__init__.py +++ b/openlp/core/common/__init__.py @@ -24,7 +24,7 @@ The :mod:`common` module contains most of the components and libraries that make OpenLP work. """ import hashlib - +import importlib import logging import os import re @@ -32,6 +32,7 @@ import sys import traceback from chardet.universaldetector import UniversalDetector from ipaddress import IPv4Address, IPv6Address, AddressValueError +from pathlib import Path from shutil import which from subprocess import check_output, CalledProcessError, STDOUT @@ -79,6 +80,49 @@ def check_directory_exists(directory, do_not_log=False): log.exception('failed to check if directory exists or create directory') +def extension_loader(glob_pattern, excluded_files=[]): + """ + A utility function to find and load OpenLP extensions, such as plugins, presentation and media controllers and + importers. + + :param glob_pattern: A glob pattern used to find the extension(s) to be imported. Should be relative to the + application directory. i.e. openlp/plugins/*/*plugin.py + :type glob_pattern: str + + :param excluded_files: A list of file names to exclude that the glob pattern may find. + :type excluded_files: list of strings + + :return: None + :rtype: None + """ + app_dir = Path(AppLocation.get_directory(AppLocation.AppDir)).parent + for extension_path in app_dir.glob(glob_pattern): + extension_path = extension_path.relative_to(app_dir) + if extension_path.name in excluded_files: + continue + module_name = path_to_module(extension_path) + try: + importlib.import_module(module_name) + except (ImportError, OSError): + # On some platforms importing vlc.py might cause OSError exceptions. (e.g. Mac OS X) + log.warning('Failed to import {module_name} on path {extension_path}' + .format(module_name=module_name, extension_path=str(extension_path))) + + +def path_to_module(path): + """ + Convert a path to a module name (i.e openlp.core.common) + + :param path: The path to convert to a module name. + :type path: Path + + :return: The module name. + :rtype: str + """ + module_path = path.with_suffix('') + return '.'.join(module_path.parts) + + def get_frozen_path(frozen_option, non_frozen_option): """ Return a path based on the system status. diff --git a/openlp/core/lib/pluginmanager.py b/openlp/core/lib/pluginmanager.py index 3c17a7d7f..bd2b7b9e1 100644 --- a/openlp/core/lib/pluginmanager.py +++ b/openlp/core/lib/pluginmanager.py @@ -23,10 +23,9 @@ Provide plugin management """ import os -import imp from openlp.core.lib import Plugin, PluginStatus -from openlp.core.common import AppLocation, RegistryProperties, OpenLPMixin, RegistryMixin +from openlp.core.common import AppLocation, RegistryProperties, OpenLPMixin, RegistryMixin, extension_loader class PluginManager(RegistryMixin, OpenLPMixin, RegistryProperties): @@ -70,32 +69,8 @@ class PluginManager(RegistryMixin, OpenLPMixin, RegistryProperties): """ Scan a directory for objects inheriting from the ``Plugin`` class. """ - start_depth = len(os.path.abspath(self.base_path).split(os.sep)) - present_plugin_dir = os.path.join(self.base_path, 'presentations') - self.log_debug('finding plugins in {path} at depth {depth:d}'.format(path=self.base_path, depth=start_depth)) - for root, dirs, files in os.walk(self.base_path): - for name in files: - if name.endswith('.py') and not name.startswith('__'): - path = os.path.abspath(os.path.join(root, name)) - this_depth = len(path.split(os.sep)) - if this_depth - start_depth > 2: - # skip anything lower down - break - module_name = name[:-3] - # import the modules - self.log_debug('Importing {name} from {root}. Depth {depth:d}'.format(name=module_name, - root=root, - depth=this_depth)) - try: - # Use the "imp" library to try to get around a problem with the PyUNO library which - # monkey-patches the __import__ function to do some magic. This causes issues with our tests. - # First, try to find the module we want to import, searching the directory in root - fp, path_name, description = imp.find_module(module_name, [root]) - # Then load the module (do the actual import) using the details from find_module() - imp.load_module(module_name, fp, path_name, description) - except ImportError as e: - self.log_exception('Failed to import module {name} on path {path}: ' - '{args}'.format(name=module_name, path=path, args=e.args[0])) + glob_pattern = os.path.join('openlp', 'plugins', '*', '*plugin.py') + extension_loader(glob_pattern) plugin_classes = Plugin.__subclasses__() plugin_objects = [] for p in plugin_classes: diff --git a/openlp/core/ui/media/mediacontroller.py b/openlp/core/ui/media/mediacontroller.py index 5a0dfb042..13bdf3bd5 100644 --- a/openlp/core/ui/media/mediacontroller.py +++ b/openlp/core/ui/media/mediacontroller.py @@ -28,7 +28,8 @@ import os import datetime from PyQt5 import QtCore, QtWidgets -from openlp.core.common import OpenLPMixin, Registry, RegistryMixin, RegistryProperties, Settings, UiStrings, translate +from openlp.core.common import OpenLPMixin, Registry, RegistryMixin, RegistryProperties, Settings, UiStrings, \ + extension_loader, translate from openlp.core.lib import ItemCapabilities from openlp.core.lib.ui import critical_error_message_box from openlp.core.common import AppLocation @@ -39,6 +40,7 @@ from openlp.core.ui.media import MediaState, MediaInfo, MediaType, get_media_pla parse_optical_path from openlp.core.ui.lib.toolbar import OpenLPToolbar + log = logging.getLogger(__name__) TICK_TIME = 200 @@ -172,19 +174,9 @@ class MediaController(RegistryMixin, OpenLPMixin, RegistryProperties): Check to see if we have any media Player's available. """ log.debug('_check_available_media_players') - controller_dir = os.path.join(AppLocation.get_directory(AppLocation.AppDir), 'core', 'ui', 'media') - for filename in os.listdir(controller_dir): - if filename.endswith('player.py') and filename != 'mediaplayer.py': - path = os.path.join(controller_dir, filename) - if os.path.isfile(path): - module_name = 'openlp.core.ui.media.' + os.path.splitext(filename)[0] - log.debug('Importing controller %s', module_name) - try: - __import__(module_name, globals(), locals(), []) - # On some platforms importing vlc.py might cause - # also OSError exceptions. (e.g. Mac OS X) - except (ImportError, OSError): - log.warning('Failed to import %s on path %s', module_name, path) + controller_dir = os.path.join('openlp', 'core', 'ui', 'media') + glob_pattern = os.path.join(controller_dir, '*player.py') + extension_loader(glob_pattern, ['mediaplayer.py']) player_classes = MediaPlayer.__subclasses__() for player_class in player_classes: self.register_players(player_class(self)) diff --git a/openlp/plugins/presentations/lib/impresscontroller.py b/openlp/plugins/presentations/lib/impresscontroller.py index 57828f4db..1f751bbdc 100644 --- a/openlp/plugins/presentations/lib/impresscontroller.py +++ b/openlp/plugins/presentations/lib/impresscontroller.py @@ -58,7 +58,8 @@ from PyQt5 import QtCore from openlp.core.lib import ScreenList from openlp.core.common import get_uno_command, get_uno_instance -from .presentationcontroller import PresentationController, PresentationDocument, TextType +from openlp.plugins.presentations.lib.presentationcontroller import PresentationController, PresentationDocument, \ + TextType log = logging.getLogger(__name__) diff --git a/openlp/plugins/presentations/lib/pdfcontroller.py b/openlp/plugins/presentations/lib/pdfcontroller.py index 47d7e3161..d36db36f0 100644 --- a/openlp/plugins/presentations/lib/pdfcontroller.py +++ b/openlp/plugins/presentations/lib/pdfcontroller.py @@ -29,7 +29,7 @@ from subprocess import check_output, CalledProcessError from openlp.core.common import AppLocation, check_binary_exists from openlp.core.common import Settings, is_win from openlp.core.lib import ScreenList -from .presentationcontroller import PresentationController, PresentationDocument +from openlp.plugins.presentations.lib.presentationcontroller import PresentationController, PresentationDocument if is_win(): from subprocess import STARTUPINFO, STARTF_USESHOWWINDOW diff --git a/openlp/plugins/presentations/lib/powerpointcontroller.py b/openlp/plugins/presentations/lib/powerpointcontroller.py index 08dcc4165..0ee165deb 100644 --- a/openlp/plugins/presentations/lib/powerpointcontroller.py +++ b/openlp/plugins/presentations/lib/powerpointcontroller.py @@ -43,7 +43,7 @@ if is_win(): from openlp.core.lib import ScreenList from openlp.core.lib.ui import UiStrings, critical_error_message_box, translate from openlp.core.common import trace_error_handler, Registry -from .presentationcontroller import PresentationController, PresentationDocument +from openlp.plugins.presentations.lib.presentationcontroller import PresentationController, PresentationDocument log = logging.getLogger(__name__) diff --git a/openlp/plugins/presentations/lib/pptviewcontroller.py b/openlp/plugins/presentations/lib/pptviewcontroller.py index aafe37121..0c33d1559 100644 --- a/openlp/plugins/presentations/lib/pptviewcontroller.py +++ b/openlp/plugins/presentations/lib/pptviewcontroller.py @@ -35,7 +35,7 @@ if is_win(): from openlp.core.common import AppLocation from openlp.core.lib import ScreenList -from .presentationcontroller import PresentationController, PresentationDocument +from openlp.plugins.presentations.lib.presentationcontroller import PresentationController, PresentationDocument log = logging.getLogger(__name__) diff --git a/openlp/plugins/presentations/lib/presentationtab.py b/openlp/plugins/presentations/lib/presentationtab.py index f8d4b935b..8a9f63989 100644 --- a/openlp/plugins/presentations/lib/presentationtab.py +++ b/openlp/plugins/presentations/lib/presentationtab.py @@ -25,7 +25,7 @@ from PyQt5 import QtGui, QtWidgets from openlp.core.common import Settings, UiStrings, translate from openlp.core.lib import SettingsTab, build_icon from openlp.core.lib.ui import critical_error_message_box -from .pdfcontroller import PdfController +from openlp.plugins.presentations.lib.pdfcontroller import PdfController class PresentationTab(SettingsTab): diff --git a/openlp/plugins/presentations/presentationplugin.py b/openlp/plugins/presentations/presentationplugin.py index 91b98d801..210f8a531 100644 --- a/openlp/plugins/presentations/presentationplugin.py +++ b/openlp/plugins/presentations/presentationplugin.py @@ -20,19 +20,18 @@ # Temple Place, Suite 330, Boston, MA 02111-1307 USA # ############################################################################### """ -The :mod:`presentationplugin` module provides the ability for OpenLP to display presentations from a variety of document -formats. +The :mod:`openlp.plugins.presentations.presentationplugin` module provides the ability for OpenLP to display +presentations from a variety of document formats. """ import os import logging from PyQt5 import QtCore -from openlp.core.common import AppLocation, translate +from openlp.core.common import AppLocation, extension_loader, translate from openlp.core.lib import Plugin, StringContent, build_icon from openlp.plugins.presentations.lib import PresentationController, PresentationMediaItem, PresentationTab - log = logging.getLogger(__name__) @@ -122,17 +121,9 @@ class PresentationPlugin(Plugin): Check to see if we have any presentation software available. If not do not install the plugin. """ log.debug('check_pre_conditions') - controller_dir = os.path.join(AppLocation.get_directory(AppLocation.PluginsDir), 'presentations', 'lib') - for filename in os.listdir(controller_dir): - if filename.endswith('controller.py') and filename != 'presentationcontroller.py': - path = os.path.join(controller_dir, filename) - if os.path.isfile(path): - module_name = 'openlp.plugins.presentations.lib.' + os.path.splitext(filename)[0] - log.debug('Importing controller {name}'.format(name=module_name)) - try: - __import__(module_name, globals(), locals(), []) - except ImportError: - log.warning('Failed to import {name} on path {path}'.format(name=module_name, path=path)) + controller_dir = os.path.join('openlp', 'plugins', 'presentations', 'lib') + glob_pattern = os.path.join(controller_dir, '*controller.py') + extension_loader(glob_pattern, ['presentationcontroller.py']) controller_classes = PresentationController.__subclasses__() for controller_class in controller_classes: controller = controller_class(self) diff --git a/tests/functional/openlp_core_common/test_common.py b/tests/functional/openlp_core_common/test_common.py index 7960fb7be..e70a82328 100644 --- a/tests/functional/openlp_core_common/test_common.py +++ b/tests/functional/openlp_core_common/test_common.py @@ -22,11 +22,13 @@ """ Functional tests to test the AppLocation class and related methods. """ +from pathlib import Path from unittest import TestCase -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch -from openlp.core.common import check_directory_exists, de_hump, trace_error_handler, translate, is_win, is_macosx, \ - is_linux, clean_button_text +from openlp.core import common +from openlp.core.common import check_directory_exists, clean_button_text, de_hump, extension_loader, is_macosx, \ + is_linux, is_win, path_to_module, trace_error_handler, translate class TestCommonFunctions(TestCase): @@ -72,6 +74,72 @@ class TestCommonFunctions(TestCase): mocked_exists.assert_called_with(directory_to_check) self.assertRaises(ValueError, check_directory_exists, directory_to_check) + def test_extension_loader_no_files_found(self): + """ + Test the `extension_loader` function when no files are found + """ + # GIVEN: A mocked `Path.glob` method which does not match any files + with patch('openlp.core.common.AppLocation.get_directory', return_value='/app/dir/openlp'), \ + patch.object(common.Path, 'glob', return_value=[]), \ + patch('openlp.core.common.importlib.import_module') as mocked_import_module: + + # WHEN: Calling `extension_loader` + extension_loader('glob', ['file2.py', 'file3.py']) + + # THEN: `extension_loader` should not try to import any files + self.assertFalse(mocked_import_module.called) + + def test_extension_loader_files_found(self): + """ + Test the `extension_loader` function when it successfully finds and loads some files + """ + # GIVEN: A mocked `Path.glob` method which returns a list of files + with patch('openlp.core.common.AppLocation.get_directory', return_value='/app/dir/openlp'), \ + patch.object(common.Path, 'glob', return_value=[Path('/app/dir/openlp/import_dir/file1.py'), + Path('/app/dir/openlp/import_dir/file2.py'), + Path('/app/dir/openlp/import_dir/file3.py'), + Path('/app/dir/openlp/import_dir/file4.py')]), \ + patch('openlp.core.common.importlib.import_module') as mocked_import_module: + + # WHEN: Calling `extension_loader` with a list of files to exclude + extension_loader('glob', ['file2.py', 'file3.py']) + + # THEN: `extension_loader` should only try to import the files that are matched by the blob, excluding the + # files listed in the `excluded_files` argument + mocked_import_module.assert_has_calls([call('openlp.import_dir.file1'), call('openlp.import_dir.file4')]) + + def test_extension_loader_import_error(self): + """ + Test the `extension_loader` function when `SourceFileLoader` raises a `ImportError` + """ + # GIVEN: A mocked `import_module` which raises an `ImportError` + with patch('openlp.core.common.AppLocation.get_directory', return_value='/app/dir/openlp'), \ + patch.object(common.Path, 'glob', return_value=[Path('/app/dir/openlp/import_dir/file1.py')]), \ + patch('openlp.core.common.importlib.import_module', side_effect=ImportError()), \ + patch('openlp.core.common.log') as mocked_logger: + + # WHEN: Calling `extension_loader` + extension_loader('glob') + + # THEN: The `ImportError` should be caught and logged + self.assertTrue(mocked_logger.warning.called) + + def test_extension_loader_os_error(self): + """ + Test the `extension_loader` function when `import_module` raises a `ImportError` + """ + # GIVEN: A mocked `SourceFileLoader` which raises an `OSError` + with patch('openlp.core.common.AppLocation.get_directory', return_value='/app/dir/openlp'), \ + patch.object(common.Path, 'glob', return_value=[Path('/app/dir/openlp/import_dir/file1.py')]), \ + patch('openlp.core.common.importlib.import_module', side_effect=OSError()), \ + patch('openlp.core.common.log') as mocked_logger: + + # WHEN: Calling `extension_loader` + extension_loader('glob') + + # THEN: The `OSError` should be caught and logged + self.assertTrue(mocked_logger.warning.called) + def test_de_hump_conversion(self): """ Test the de_hump function with a class name @@ -83,7 +151,7 @@ class TestCommonFunctions(TestCase): new_string = de_hump(string) # THEN: the new string should be converted to python format - self.assertTrue(new_string == "my_class", 'The class name should have been converted') + self.assertEqual(new_string, "my_class", 'The class name should have been converted') def test_de_hump_static(self): """ @@ -96,7 +164,20 @@ class TestCommonFunctions(TestCase): new_string = de_hump(string) # THEN: the new string should be converted to python format - self.assertTrue(new_string == "my_class", 'The class name should have been preserved') + self.assertEqual(new_string, "my_class", 'The class name should have been preserved') + + def test_path_to_module(self): + """ + Test `path_to_module` when supplied with a `Path` object + """ + # GIVEN: A `Path` object + path = Path('openlp/core/ui/media/webkitplayer.py') + + # WHEN: Calling path_to_module with the `Path` object + result = path_to_module(path) + + # THEN: path_to_module should return the module name + self.assertEqual(result, 'openlp.core.ui.media.webkitplayer') def test_trace_error_handler(self): """