diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py index 9fdd43e6c6..bb0c8cfb94 100644 --- a/InvenTree/plugin/apps.py +++ b/InvenTree/plugin/apps.py @@ -43,7 +43,7 @@ class PluginAppConfig(AppConfig): pass # get plugins and init them - registry.collect_plugins() + registry.plugin_modules = registry.collect_plugins() registry.load_plugins() # drop out of maintenance diff --git a/InvenTree/plugin/helpers.py b/InvenTree/plugin/helpers.py index 723543caea..baeb1847ce 100644 --- a/InvenTree/plugin/helpers.py +++ b/InvenTree/plugin/helpers.py @@ -241,12 +241,15 @@ def get_module_meta(mdl_name): # Get spec for module spec = find_spec(mdl_name) + if not spec: # pragma: no cover + raise PackageNotFoundError(mdl_name) + # Try to get specific package for the module result = None for dist in distributions(): try: relative = pathlib.Path(spec.origin).relative_to(dist.locate_file('')) - except ValueError: + except ValueError: # pragma: no cover pass else: if relative in dist.files: @@ -254,7 +257,7 @@ def get_module_meta(mdl_name): # Check if a distribution was found # A no should not be possible here as a call can only be made on a discovered module but better save then sorry - if not result: + if not result: # pragma: no cover raise PackageNotFoundError(mdl_name) # Return metadata diff --git a/InvenTree/plugin/mock/__init__.py b/InvenTree/plugin/mock/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/InvenTree/plugin/mock/simple.py b/InvenTree/plugin/mock/simple.py new file mode 100644 index 0000000000..8130aa7eed --- /dev/null +++ b/InvenTree/plugin/mock/simple.py @@ -0,0 +1,10 @@ +"""Very simple sample plugin""" + +from plugin import InvenTreePlugin + + +class SimplePlugin(InvenTreePlugin): + """A very simple plugin.""" + + NAME = 'SimplePlugin' + SLUG = "simple" diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index 5d05cb4ecc..ec0b13410c 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -4,6 +4,7 @@ - Manages setup and teardown of plugin class instances """ +import imp import importlib import logging import os @@ -200,7 +201,7 @@ class PluginsRegistry: if settings.TESTING: custom_dirs = os.getenv('INVENTREE_PLUGIN_TEST_DIR', None) - else: + else: # pragma: no cover custom_dirs = get_setting('INVENTREE_PLUGIN_DIR', 'plugin_dir') # Load from user specified directories (unless in testing mode) @@ -215,7 +216,7 @@ class PluginsRegistry: if not pd.exists(): try: pd.mkdir(exist_ok=True) - except Exception: + except Exception: # pragma: no cover logger.error(f"Could not create plugin directory '{pd}'") continue @@ -225,24 +226,31 @@ class PluginsRegistry: if not init_filename.exists(): try: init_filename.write_text("# InvenTree plugin directory\n") - except Exception: + except Exception: # pragma: no cover logger.error(f"Could not create file '{init_filename}'") continue + # By this point, we have confirmed that the directory at least exists if pd.exists() and pd.is_dir(): - # By this point, we have confirmed that the directory at least exists - logger.info(f"Added plugin directory: '{pd}'") - dirs.append(pd) + # Convert to python dot-path + if pd.is_relative_to(settings.BASE_DIR): + pd_path = '.'.join(pd.relative_to(settings.BASE_DIR).parts) + else: + pd_path = str(pd) + + # Add path + dirs.append(pd_path) + logger.info(f"Added plugin directory: '{pd}' as '{pd_path}'") return dirs def collect_plugins(self): - """Collect plugins from all possible ways of loading.""" + """Collect plugins from all possible ways of loading. Returned as list.""" if not settings.PLUGINS_ENABLED: # Plugins not enabled, do nothing return # pragma: no cover - self.plugin_modules = [] # clear + collected_plugins = [] # Collect plugins from paths for plugin in self.plugin_dirs(): @@ -258,26 +266,33 @@ class PluginsRegistry: parent_path = str(parent_obj.parent) plugin = parent_obj.name - modules = get_plugins(importlib.import_module(plugin), InvenTreePlugin, path=parent_path) + # Gather Modules + if parent_path: + raw_module = imp.load_source(plugin, str(parent_obj.joinpath('__init__.py'))) + else: + raw_module = importlib.import_module(plugin) + modules = get_plugins(raw_module, InvenTreePlugin, path=parent_path) if modules: - [self.plugin_modules.append(item) for item in modules] + [collected_plugins.append(item) for item in modules] # Check if not running in testing mode and apps should be loaded from hooks if (not settings.PLUGIN_TESTING) or (settings.PLUGIN_TESTING and settings.PLUGIN_TESTING_SETUP): # Collect plugins from setup entry points - for entry in get_entrypoints(): # pragma: no cover + for entry in get_entrypoints(): try: plugin = entry.load() plugin.is_package = True plugin._get_package_metadata() - self.plugin_modules.append(plugin) - except Exception as error: + collected_plugins.append(plugin) + except Exception as error: # pragma: no cover handle_error(error, do_raise=False, log_name='discovery') # Log collected plugins - logger.info(f'Collected {len(self.plugin_modules)} plugins!') - logger.info(", ".join([a.__module__ for a in self.plugin_modules])) + logger.info(f'Collected {len(collected_plugins)} plugins!') + logger.info(", ".join([a.__module__ for a in collected_plugins])) + + return collected_plugins def install_plugin_file(self): """Make sure all plugins are installed in the current enviroment.""" diff --git a/InvenTree/plugin/test_helpers.py b/InvenTree/plugin/test_helpers.py index 53b2622592..4b2ac8538c 100644 --- a/InvenTree/plugin/test_helpers.py +++ b/InvenTree/plugin/test_helpers.py @@ -2,7 +2,7 @@ from django.test import TestCase -from .helpers import render_template +from .helpers import get_module_meta, render_template class HelperTests(TestCase): @@ -21,3 +21,15 @@ class HelperTests(TestCase): response = render_template(ErrorSource(), 'sample/wrongsample.html', {'abc': 123}) self.assertTrue('lert alert-block alert-danger' in response) self.assertTrue('Template file sample/wrongsample.html' in response) + + def test_get_module_meta(self): + """Test for get_module_meta.""" + + # We need a stable, known good that will be in enviroment for sure + # and it can't be stdlib because does might differ depending on the abstraction layer + # and version + meta = get_module_meta('django') + + # Lets just hope they do not change the name or author + self.assertEqual(meta['Name'], 'Django') + self.assertEqual(meta['Author'], 'Django Software Foundation') diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py index 37862b5545..7dfdaf44ad 100644 --- a/InvenTree/plugin/test_plugin.py +++ b/InvenTree/plugin/test_plugin.py @@ -1,8 +1,14 @@ """Unit tests for plugins.""" +import os +import shutil +import subprocess +import tempfile from datetime import datetime +from pathlib import Path +from unittest import mock -from django.test import TestCase +from django.test import TestCase, override_settings import plugin.templatetags.plugin_extras as plugin_tags from plugin import InvenTreePlugin, registry @@ -162,3 +168,67 @@ class InvenTreePluginTests(TestCase): self.assertEqual(self.plugin_old.slug, 'old') # check default value is used self.assertEqual(self.plugin_old.get_meta_value('ABC', 'ABCD', '123'), '123') + + +class RegistryTests(TestCase): + """Tests for registry loading methods.""" + + def run_package_test(self, directory): + """General runner for testing package based installs.""" + + # Patch enviroment varible to add dir + envs = {'INVENTREE_PLUGIN_TEST_DIR': directory} + with mock.patch.dict(os.environ, envs): + # Reload to redicsover plugins + registry.reload_plugins(full_reload=True) + + # Depends on the meta set in InvenTree/plugin/mock/simple:SimplePlugin + plg = registry.get_plugin('simple') + self.assertEqual(plg.slug, 'simple') + self.assertEqual(plg.human_name, 'SimplePlugin') + + def test_custom_loading(self): + """Test if data in custom dir is loaded correctly.""" + test_dir = Path('plugin_test_dir') + + # Patch env + envs = {'INVENTREE_PLUGIN_TEST_DIR': 'plugin_test_dir'} + with mock.patch.dict(os.environ, envs): + # Run plugin directory discovery again + registry.plugin_dirs() + + # Check the directory was created + self.assertTrue(test_dir.exists()) + + # Clean folder up + shutil.rmtree(test_dir, ignore_errors=True) + + def test_subfolder_loading(self): + """Test that plugins in subfolders get loaded.""" + self.run_package_test('InvenTree/plugin/mock') + + def test_folder_loading(self): + """Test that plugins in folders outside of BASE_DIR get loaded.""" + + # Run in temporary directory -> always a new random name + with tempfile.TemporaryDirectory() as tmp: + # Fill directory with sample data + new_dir = Path(tmp).joinpath('mock') + shutil.copytree(Path('InvenTree/plugin/mock').absolute(), new_dir) + + # Run tests + self.run_package_test(str(new_dir)) + + @override_settings(PLUGIN_TESTING_SETUP=True) + def test_package_loading(self): + """Test that package distributed plugins work.""" + # Install sample package + subprocess.check_output('pip install inventree-zapier'.split()) + + # Reload to discover plugin + registry.reload_plugins(full_reload=True) + + # Test that plugin was installed + plg = registry.get_plugin('zapier') + self.assertEqual(plg.slug, 'zapier') + self.assertEqual(plg.name, 'inventree_zapier')