Skip to content

Commit 01aa8bb

Browse files
Plugins installation improvements (inventree#8503)
* Append plugins dir to pythonpath * Error handling in plugin helpers * Install plugin into "plugins" directory * Use plugins dir when installing from plugins.txt * Implement removal of plugin from plugins dir * Remove the dist-info dirs too * Cleanup * Catch errors * Specify plugin location for CI * Remove plugins.txt support * Improve regex for plugin matching * Revert "Remove plugins.txt support" This reverts commit 0278350. * Remove PLUGIN_ON_STARTUP support * Better error catching for broken packages * Cleanup * Revert "Cleanup" This reverts commit a40c85d. * Improved exception handling for plugin loading * More logging * Revert uninstall behaviour * Revert python path update * Improve check for plugins file * Revert check on startup * Better management of plugins file - Use file hash to determine if it should be reloaded * Fix docstring * Update unit tests * revert gh env * No cache * Update src/backend/InvenTree/plugin/installer.py Co-authored-by: Matthias Mair <code@mjmair.com> * Use hashlib.file_digest * Remove --no-cache-dir * Revert "Use hashlib.file_digest" This reverts commit bf84c81. * Add note for future selves --------- Co-authored-by: Matthias Mair <code@mjmair.com>
1 parent 13440a6 commit 01aa8bb

8 files changed

Lines changed: 121 additions & 93 deletions

File tree

src/backend/InvenTree/InvenTree/settings.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,8 @@
217217
'INVENTREE_PLUGIN_RETRY', 'PLUGIN_RETRY', 3, typecast=int
218218
) # How often should plugin loading be tried?
219219

220-
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
220+
# Hash of the plugin file (will be updated on each change)
221+
PLUGIN_FILE_HASH = ''
221222

222223
STATICFILES_DIRS = []
223224

src/backend/InvenTree/InvenTree/tests.py

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,18 +1184,8 @@ def test_initial_install(self):
11841184
"""Test if install of plugins on startup works."""
11851185
from plugin import registry
11861186

1187-
if not settings.DOCKER:
1188-
# Check an install run
1189-
response = registry.install_plugin_file()
1190-
self.assertEqual(response, 'first_run')
1191-
1192-
# Set dynamic setting to True and rerun to launch install
1193-
InvenTreeSetting.set_setting('PLUGIN_ON_STARTUP', True, self.user)
1194-
registry.reload_plugins(full_reload=True)
1195-
1196-
# Check that there was another run
1197-
response = registry.install_plugin_file()
1198-
self.assertEqual(response, True)
1187+
registry.reload_plugins(full_reload=True, collect=True)
1188+
self.assertGreater(len(settings.PLUGIN_FILE_HASH), 0)
11991189

12001190
def test_helpers_cfg_file(self):
12011191
"""Test get_config_file."""

src/backend/InvenTree/plugin/apps.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@ class PluginAppConfig(AppConfig):
2323

2424
def ready(self):
2525
"""The ready method is extended to initialize plugins."""
26-
# skip loading if we run in a background thread
27-
2826
if not isInMainThread() and not isInWorkerThread():
2927
return
3028

src/backend/InvenTree/plugin/helpers.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,17 @@ def get_modules(pkg, path=None):
177177
elif type(path) is not list:
178178
path = [path]
179179

180-
for finder, name, _ in pkgutil.walk_packages(path):
180+
packages = pkgutil.walk_packages(path)
181+
182+
while True:
183+
try:
184+
finder, name, _ = next(packages)
185+
except StopIteration:
186+
break
187+
except Exception as error:
188+
log_error({pkg.__name__: str(error)}, 'discovery')
189+
continue
190+
181191
try:
182192
if sys.version_info < (3, 12):
183193
module = finder.find_module(name).load_module(name)
@@ -202,9 +212,13 @@ def get_modules(pkg, path=None):
202212
return [v for k, v in context.items()]
203213

204214

205-
def get_classes(module):
215+
def get_classes(module) -> list:
206216
"""Get all classes in a given module."""
207-
return inspect.getmembers(module, inspect.isclass)
217+
try:
218+
return inspect.getmembers(module, inspect.isclass)
219+
except Exception:
220+
log_error({module.__name__: 'Could not get classes'}, 'discovery')
221+
return []
208222

209223

210224
def get_plugins(pkg, baseclass, path=None):

src/backend/InvenTree/plugin/installer.py

Lines changed: 81 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,15 @@
1919
def pip_command(*args):
2020
"""Build and run a pip command using using the current python executable.
2121
22-
returns: subprocess.check_output
23-
throws: subprocess.CalledProcessError
22+
Returns: The output of the pip command
23+
24+
Raises:
25+
subprocess.CalledProcessError: If the pip command fails
2426
"""
2527
python = sys.executable
2628

2729
command = [python, '-m', 'pip']
30+
2831
command.extend(args)
2932

3033
command = [str(x) for x in command]
@@ -63,39 +66,55 @@ def handle_pip_error(error, path: str) -> list:
6366
raise ValidationError(errors[0])
6467

6568

66-
def check_package_path(packagename: str):
67-
"""Determine the install path of a particular package.
69+
def get_install_info(packagename: str) -> dict:
70+
"""Determine the install information for a particular package.
6871
69-
- If installed, return the installation path
70-
- If not installed, return False
72+
- Uses 'pip show' to determine the install location of a package.
7173
"""
72-
logger.debug('check_package_path: %s', packagename)
74+
logger.debug('get_install_info: %s', packagename)
7375

7476
# Remove version information
75-
for c in '<>=! ':
77+
for c in '<>=!@ ':
7678
packagename = packagename.split(c)[0]
7779

80+
info = {}
81+
7882
try:
7983
result = pip_command('show', packagename)
8084

8185
output = result.decode('utf-8').split('\n')
8286

8387
for line in output:
84-
# Check if line matches pattern "Location: ..."
85-
match = re.match(r'^Location:\s+(.+)$', line.strip())
88+
parts = line.split(':')
89+
90+
if len(parts) >= 2:
91+
key = str(parts[0].strip().lower().replace('-', '_'))
92+
value = str(parts[1].strip())
8693

87-
if match:
88-
return match.group(1)
94+
info[key] = value
8995

9096
except subprocess.CalledProcessError as error:
91-
log_error('check_package_path')
97+
log_error('get_install_info')
9298

9399
output = error.output.decode('utf-8')
100+
info['error'] = output
94101
logger.exception('Plugin lookup failed: %s', str(output))
95-
return False
96102

97-
# If we get here, the package is not installed
98-
return False
103+
return info
104+
105+
106+
def plugins_file_hash():
107+
"""Return the file hash for the plugins file."""
108+
import hashlib
109+
110+
pf = settings.PLUGIN_FILE
111+
112+
if not pf or not pf.exists():
113+
return None
114+
115+
with pf.open('rb') as f:
116+
# Note: Once we support 3.11 as a minimum, we can use hashlib.file_digest
117+
return hashlib.sha256(f.read()).hexdigest()
99118

100119

101120
def install_plugins_file():
@@ -108,8 +127,10 @@ def install_plugins_file():
108127
logger.warning('Plugin file %s does not exist', str(pf))
109128
return
110129

130+
cmd = ['install', '--disable-pip-version-check', '-U', '-r', str(pf)]
131+
111132
try:
112-
pip_command('install', '-r', str(pf))
133+
pip_command(*cmd)
113134
except subprocess.CalledProcessError as error:
114135
output = error.output.decode('utf-8')
115136
logger.exception('Plugin file installation failed: %s', str(output))
@@ -120,17 +141,25 @@ def install_plugins_file():
120141
log_error('pip')
121142
return False
122143

123-
# Update static files
144+
# Collect plugin static files
124145
plugin.staticfiles.collect_plugins_static_files()
125-
plugin.staticfiles.clear_plugins_static_files()
126146

127147
# At this point, the plugins file has been installed
128148
return True
129149

130150

131-
def update_plugins_file(install_name, remove=False):
151+
def update_plugins_file(install_name, full_package=None, version=None, remove=False):
132152
"""Add a plugin to the plugins file."""
133-
logger.info('Adding plugin to plugins file: %s', install_name)
153+
if remove:
154+
logger.info('Removing plugin from plugins file: %s', install_name)
155+
else:
156+
logger.info('Adding plugin to plugins file: %s', install_name)
157+
158+
# If a full package name is provided, use that instead
159+
if full_package and full_package != install_name:
160+
new_value = full_package
161+
else:
162+
new_value = f'{install_name}=={version}' if version else install_name
134163

135164
pf = settings.PLUGIN_FILE
136165

@@ -140,7 +169,7 @@ def update_plugins_file(install_name, remove=False):
140169

141170
def compare_line(line: str):
142171
"""Check if a line in the file matches the installname."""
143-
return line.strip().split('==')[0] == install_name.split('==')[0]
172+
return re.match(rf'^{install_name}[\s=@]', line.strip())
144173

145174
# First, read in existing plugin file
146175
try:
@@ -166,13 +195,13 @@ def compare_line(line: str):
166195
found = True
167196
if not remove:
168197
# Replace line with new install name
169-
output.append(install_name)
198+
output.append(new_value)
170199
else:
171200
output.append(line)
172201

173202
# Append plugin to file
174203
if not found and not remove:
175-
output.append(install_name)
204+
output.append(new_value)
176205

177206
# Write file back to disk
178207
try:
@@ -203,15 +232,8 @@ def install_plugin(url=None, packagename=None, user=None, version=None):
203232

204233
logger.info('install_plugin: %s, %s', url, packagename)
205234

206-
# Check if we are running in a virtual environment
207-
# For now, just log a warning
208-
in_venv = sys.prefix != sys.base_prefix
209-
210-
if not in_venv:
211-
logger.warning('InvenTree is not running in a virtual environment')
212-
213235
# build up the command
214-
install_name = ['install', '-U']
236+
install_name = ['install', '-U', '--disable-pip-version-check']
215237

216238
full_pkg = ''
217239

@@ -246,23 +268,25 @@ def install_plugin(url=None, packagename=None, user=None, version=None):
246268
ret['result'] = ret['success'] = _('Installed plugin successfully')
247269
ret['output'] = str(result, 'utf-8')
248270

249-
if packagename and (path := check_package_path(packagename)):
250-
# Override result information
251-
ret['result'] = _(f'Installed plugin into {path}')
271+
if packagename and (info := get_install_info(packagename)):
272+
if path := info.get('location'):
273+
ret['result'] = _(f'Installed plugin into {path}')
274+
ret['version'] = info.get('version')
252275

253276
except subprocess.CalledProcessError as error:
254277
handle_pip_error(error, 'plugin_install')
255278

256-
# Save plugin to plugins file
257-
update_plugins_file(full_pkg)
279+
if version := ret.get('version'):
280+
# Save plugin to plugins file
281+
update_plugins_file(packagename, full_package=full_pkg, version=version)
258282

259-
# Reload the plugin registry, to discover the new plugin
260-
from plugin.registry import registry
283+
# Reload the plugin registry, to discover the new plugin
284+
from plugin.registry import registry
261285

262-
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
286+
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
263287

264-
# Update static files
265-
plugin.staticfiles.collect_plugins_static_files()
288+
# Update static files
289+
plugin.staticfiles.collect_plugins_static_files()
266290

267291
return ret
268292

@@ -303,23 +327,24 @@ def uninstall_plugin(cfg: plugin.models.PluginConfig, user=None, delete_config=T
303327
_('Plugin cannot be uninstalled as it is currently active')
304328
)
305329

330+
if not cfg.is_installed():
331+
raise ValidationError(_('Plugin is not installed'))
332+
306333
validate_package_plugin(cfg, user)
307334
package_name = cfg.package_name
308-
logger.info('Uninstalling plugin: %s', package_name)
309-
310-
cmd = ['uninstall', '-y', package_name]
311-
312-
try:
313-
result = pip_command(*cmd)
314335

315-
ret = {
316-
'result': _('Uninstalled plugin successfully'),
317-
'success': True,
318-
'output': str(result, 'utf-8'),
319-
}
336+
pkg_info = get_install_info(package_name)
320337

321-
except subprocess.CalledProcessError as error:
322-
handle_pip_error(error, 'plugin_uninstall')
338+
if path := pkg_info.get('location'):
339+
# Uninstall the plugin using pip
340+
logger.info('Uninstalling plugin: %s from %s', package_name, path)
341+
try:
342+
pip_command('uninstall', '-y', package_name)
343+
except subprocess.CalledProcessError as error:
344+
handle_pip_error(error, 'plugin_uninstall')
345+
else:
346+
# No matching install target found
347+
raise ValidationError(_('Plugin installation not found'))
323348

324349
# Update the plugins file
325350
update_plugins_file(package_name, remove=True)
@@ -334,4 +359,4 @@ def uninstall_plugin(cfg: plugin.models.PluginConfig, user=None, delete_config=T
334359
# Reload the plugin registry
335360
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
336361

337-
return ret
362+
return {'result': _('Uninstalled plugin successfully'), 'success': True}

src/backend/InvenTree/plugin/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def __str__(self) -> str:
7070
"""Nice name for printing."""
7171
name = f'{self.name} - {self.key}'
7272
if not self.active:
73-
name += '(not active)'
73+
name += ' (not active)'
7474
return name
7575

7676
# extra attributes from the registry

src/backend/InvenTree/plugin/plugin.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,9 +239,10 @@ def file(cls) -> Path:
239239
"""File that contains plugin definition."""
240240
return Path(inspect.getfile(cls))
241241

242-
def path(self) -> Path:
242+
@classmethod
243+
def path(cls) -> Path:
243244
"""Path to plugins base folder."""
244-
return self.file().parent
245+
return cls.file().parent
245246

246247
def _get_value(self, meta_name: str, package_name: str) -> str:
247248
"""Extract values from class meta or package info.

0 commit comments

Comments
 (0)