2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

Plugin reload fix (#8922)

* Add option to disable auto-reload of dev server

* Force plugin reload

* Add unit testing for plugin reload

- Requires modifications to registry.py
This commit is contained in:
Oliver 2025-01-22 16:34:13 +11:00 committed by GitHub
parent 4a9785d5e9
commit 8e8b7158b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 179 additions and 15 deletions

2
src/backend/InvenTree/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
# Files generated during unit testing
_testfolder/

View File

@ -142,7 +142,7 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
def save(self, force_insert=False, force_update=False, *args, **kwargs): def save(self, force_insert=False, force_update=False, *args, **kwargs):
"""Extend save method to reload plugins if the 'active' status changes.""" """Extend save method to reload plugins if the 'active' status changes."""
reload = kwargs.pop('no_reload', False) # check if no_reload flag is set no_reload = kwargs.pop('no_reload', False) # check if no_reload flag is set
super().save(force_insert, force_update, *args, **kwargs) super().save(force_insert, force_update, *args, **kwargs)
@ -150,10 +150,10 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
# Force active if builtin # Force active if builtin
self.active = True self.active = True
if not reload and self.active != self.__org_active: if not no_reload and self.active != self.__org_active:
if settings.PLUGIN_TESTING: if settings.PLUGIN_TESTING:
warnings.warn('A reload was triggered', stacklevel=2) warnings.warn('A plugin registry reload was triggered', stacklevel=2)
registry.reload_plugins() registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
@admin.display(boolean=True, description=_('Installed')) @admin.display(boolean=True, description=_('Installed'))
def is_installed(self) -> bool: def is_installed(self) -> bool:

View File

@ -453,6 +453,8 @@ class PluginsRegistry:
Args: Args:
plugin: Plugin module plugin: Plugin module
configs: Plugin configuration dictionary
force_reload (bool, optional): Force reload of plugin. Defaults to False.
""" """
from InvenTree import version from InvenTree import version
@ -485,6 +487,7 @@ class PluginsRegistry:
# Check if this is a 'builtin' plugin # Check if this is a 'builtin' plugin
builtin = plugin.check_is_builtin() builtin = plugin.check_is_builtin()
sample = plugin.check_is_sample()
package_name = None package_name = None
@ -510,11 +513,37 @@ class PluginsRegistry:
# Initialize package - we can be sure that an admin has activated the plugin # Initialize package - we can be sure that an admin has activated the plugin
logger.debug('Loading plugin `%s`', plg_name) logger.debug('Loading plugin `%s`', plg_name)
# If this is a third-party plugin, reload the source module
# This is required to ensure that separate processes are using the same code
if not builtin and not sample:
plugin_name = plugin.__name__
module_name = plugin.__module__
if plugin_module := sys.modules.get(module_name):
logger.debug('Reloading plugin `%s`', plg_name)
# Reload the module
try:
importlib.reload(plugin_module)
plugin = getattr(plugin_module, plugin_name)
except ModuleNotFoundError:
# No module found - try to import it directly
try:
raw_module = _load_source(
module_name, plugin_module.__file__
)
plugin = getattr(raw_module, plugin_name)
except Exception:
pass
except Exception:
logger.exception('Failed to reload plugin `%s`', plg_name)
try: try:
t_start = time.time() t_start = time.time()
plg_i: InvenTreePlugin = plugin() plg_i: InvenTreePlugin = plugin()
dt = time.time() - t_start dt = time.time() - t_start
logger.debug('Loaded plugin `%s` in %.3fs', plg_name, dt) logger.debug('Loaded plugin `%s` in %.3fs', plg_name, dt)
except ModuleNotFoundError as e:
raise e
except Exception as error: except Exception as error:
handle_error( handle_error(
error, log_name='init' error, log_name='init'
@ -745,7 +774,7 @@ class PluginsRegistry:
if old_hash != self.registry_hash: if old_hash != self.registry_hash:
try: try:
logger.debug( logger.info(
'Updating plugin registry hash: %s', str(self.registry_hash) 'Updating plugin registry hash: %s', str(self.registry_hash)
) )
set_global_setting( set_global_setting(
@ -839,11 +868,16 @@ def _load_source(modname, filename):
See https://docs.python.org/3/whatsnew/3.12.html#imp See https://docs.python.org/3/whatsnew/3.12.html#imp
""" """
loader = importlib.machinery.SourceFileLoader(modname, filename) if modname in sys.modules:
spec = importlib.util.spec_from_file_location(modname, filename, loader=loader) del sys.modules[modname]
# loader = importlib.machinery.SourceFileLoader(modname, filename)
spec = importlib.util.spec_from_file_location(modname, filename) # , loader=loader)
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
# The module is always executed and not cached in sys.modules.
# Uncomment the following line to cache the module. sys.modules[module.__name__] = module
# sys.modules[module.__name__] = module
loader.exec_module(module) if spec.loader:
spec.loader.exec_module(module)
return module return module

View File

@ -205,7 +205,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
plg_inactive.active = True plg_inactive.active = True
plg_inactive.save() plg_inactive.save()
self.assertEqual(cm.warning.args[0], 'A reload was triggered') self.assertEqual(cm.warning.args[0], 'A plugin registry reload was triggered')
def test_check_plugin(self): def test_check_plugin(self):
"""Test check_plugin function.""" """Test check_plugin function."""

View File

@ -4,9 +4,11 @@ import os
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
import textwrap
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
from unittest.mock import patch
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
@ -18,6 +20,9 @@ from plugin.samples.integration.another_sample import (
) )
from plugin.samples.integration.sample import SampleIntegrationPlugin from plugin.samples.integration.sample import SampleIntegrationPlugin
# Directory for testing plugins during CI
PLUGIN_TEST_DIR = '_testfolder/test_plugins'
class PluginTagTests(TestCase): class PluginTagTests(TestCase):
"""Tests for the plugin extras.""" """Tests for the plugin extras."""
@ -287,3 +292,111 @@ class RegistryTests(TestCase):
self.assertEqual( self.assertEqual(
registry.errors.get('init')[0]['broken_sample'], "'This is a dummy error'" registry.errors.get('init')[0]['broken_sample'], "'This is a dummy error'"
) )
@override_settings(PLUGIN_TESTING=True, PLUGIN_TESTING_SETUP=True)
@patch.dict(os.environ, {'INVENTREE_PLUGIN_TEST_DIR': PLUGIN_TEST_DIR})
def test_registry_reload(self):
"""Test that the registry correctly reloads plugin modules.
- Create a simple plugin which we can change the version
- Ensure that the "hash" of the plugin registry changes
"""
dummy_file = os.path.join(PLUGIN_TEST_DIR, 'dummy_ci_plugin.py')
# Ensure the plugin dir exists
os.makedirs(PLUGIN_TEST_DIR, exist_ok=True)
# Create an __init__.py file
init_file = os.path.join(PLUGIN_TEST_DIR, '__init__.py')
if not os.path.exists(init_file):
with open(os.path.join(init_file), 'w', encoding='utf-8') as f:
f.write('')
def plugin_content(version):
"""Return the content of the plugin file."""
content = f"""
from plugin import InvenTreePlugin
PLG_VERSION = "{version}"
print(">>> LOADING DUMMY PLUGIN v" + PLG_VERSION + " <<<")
class DummyCIPlugin(InvenTreePlugin):
NAME = "DummyCIPlugin"
SLUG = "dummyci"
TITLE = "Dummy plugin for CI testing"
VERSION = PLG_VERSION
"""
return textwrap.dedent(content)
def create_plugin_file(
version: str, enabled: bool = True, reload: bool = True
) -> str:
"""Create a plugin file with the given version.
Arguments:
version: The version string to use for the plugin file
enabled: Whether the plugin should be enabled or not
Returns:
str: The plugin registry hash
"""
import time
content = plugin_content(version)
with open(dummy_file, 'w', encoding='utf-8') as f:
f.write(content)
# Wait for the file to be written
time.sleep(2)
if reload:
# Ensure the plugin is activated
registry.set_plugin_state('dummyci', enabled)
registry.reload_plugins(
full_reload=True, collect=True, force_reload=True
)
registry.update_plugin_hash()
return registry.registry_hash
# Initial hash, with plugin disabled
hash_disabled = create_plugin_file('0.0.1', enabled=False, reload=False)
# Perform initial registry reload
registry.reload_plugins(full_reload=True, collect=True, force_reload=True)
# Start plugin in known state
registry.set_plugin_state('dummyci', False)
hash_disabled = create_plugin_file('0.0.1', enabled=False)
# Enable the plugin
hash_enabled = create_plugin_file('0.1.0', enabled=True)
# Hash must be different!
self.assertNotEqual(hash_disabled, hash_enabled)
plugin_hash = hash_enabled
for v in ['0.1.1', '7.1.2', '1.2.1', '4.0.1']:
h = create_plugin_file(v, enabled=True)
self.assertNotEqual(plugin_hash, h)
plugin_hash = h
# Revert back to original 'version'
h = create_plugin_file('0.1.0', enabled=True)
self.assertEqual(hash_enabled, h)
# Disable the plugin
h = create_plugin_file('0.0.1', enabled=False)
self.assertEqual(hash_disabled, h)
# Finally, ensure that the plugin file is removed after testing
os.remove(dummy_file)

View File

@ -911,13 +911,28 @@ def gunicorn(c, address='0.0.0.0:8000', workers=None):
run(c, cmd, pty=True) run(c, cmd, pty=True)
@task(pre=[wait], help={'address': 'Server address:port (default=127.0.0.1:8000)'}) @task(
def server(c, address='127.0.0.1:8000'): pre=[wait],
help={
'address': 'Server address:port (default=127.0.0.1:8000)',
'no_reload': 'Do not automatically reload the server in response to code changes',
'no_threading': 'Disable multi-threading for the development server',
},
)
def server(c, address='127.0.0.1:8000', no_reload=False, no_threading=False):
"""Launch a (development) server using Django's in-built webserver. """Launch a (development) server using Django's in-built webserver.
Note: This is *not* sufficient for a production installation. Note: This is *not* sufficient for a production installation.
""" """
manage(c, f'runserver {address}', pty=True) cmd = f'runserver {address}'
if no_reload:
cmd += ' --noreload'
if no_threading:
cmd += ' --nothreading'
manage(c, cmd, pty=True)
@task(pre=[wait]) @task(pre=[wait])