Coverage for src/backend/InvenTree/plugin/installer.py: 18%
216 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
1"""Install a plugin into the python virtual environment."""
3import re
4import subprocess
5import sys
7from django.conf import settings
8from django.core.exceptions import ValidationError
9from django.utils.translation import gettext_lazy as _
11import structlog
13import plugin.models
14import plugin.staticfiles
15from InvenTree.exceptions import log_error
17logger = structlog.get_logger('inventree')
20def pip_command(*args):
21 """Build and run a pip command using using the current python executable.
23 Returns: The output of the pip command
25 Raises:
26 subprocess.CalledProcessError: If the pip command fails
27 """
28 python = sys.executable
30 command = [python, '-m', 'pip']
32 command.extend(args)
34 command = [str(x) for x in command]
36 logger.info('Running pip command: %s', ' '.join(command))
38 return subprocess.check_output(
39 command, cwd=settings.BASE_DIR.parent, stderr=subprocess.STDOUT
40 )
43def handle_pip_error(error, path: str) -> list:
44 """Raise a ValidationError when the pip command fails.
46 - Log the error to the database
47 - Format the output from a pip command into a list of error messages.
48 - Raise an appropriate error
49 """
50 log_error(path, scope='pip')
52 output = error.output.decode('utf-8')
54 logger.error('Pip command failed: %s', output)
56 errors = []
58 for line in output.split('\n'):
59 line = line.strip()
61 if line:
62 errors.append(line)
64 if len(errors) > 1:
65 raise ValidationError(errors)
66 else:
67 raise ValidationError(errors[0])
70def get_install_info(packagename: str) -> dict:
71 """Determine the install information for a particular package.
73 - Uses 'pip show' to determine the install location of a package.
74 """
75 logger.debug('get_install_info: %s', packagename)
77 # Remove version information
78 for c in '<>=!@ ':
79 packagename = packagename.split(c)[0]
81 info = {}
83 try:
84 result = pip_command('show', packagename)
86 output = result.decode('utf-8').split('\n')
88 for line in output:
89 parts = line.split(':')
91 if len(parts) >= 2:
92 key = str(parts[0].strip().lower().replace('-', '_'))
93 value = str(parts[1].strip())
95 info[key] = value
97 except subprocess.CalledProcessError as error:
98 log_error('get_install_info', scope='pip')
100 output = error.output.decode('utf-8')
101 info['error'] = output
102 logger.exception('Plugin lookup failed: %s', output)
103 except Exception:
104 log_error('get_install_info', scope='pip')
106 return info
109def plugins_file_hash():
110 """Return the file hash for the plugins file."""
111 import hashlib
113 pf = settings.PLUGIN_FILE
115 if not pf or not pf.exists(): 115 ↛ 116line 115 didn't jump to line 116 because the condition on line 115 was never true
116 return None
118 try:
119 with pf.open('rb') as f:
120 # Note: Once we support 3.11 as a minimum, we can use hashlib.file_digest
121 return hashlib.sha256(f.read()).hexdigest()
122 except Exception:
123 log_error('plugins_file_hash', scope='plugins')
124 return None
127def install_plugins_file():
128 """Install plugins from the plugins file."""
129 logger.info('Installing plugins from plugins file')
131 pf = settings.PLUGIN_FILE
133 if not pf or not pf.exists(): 133 ↛ 134line 133 didn't jump to line 134 because the condition on line 133 was never true
134 logger.warning('Plugin file %s does not exist', pf)
135 return
137 cmd = ['install', '--disable-pip-version-check', '-U', '-r', str(pf)]
139 try:
140 pip_command(*cmd)
141 except subprocess.CalledProcessError as error:
142 output = error.output.decode('utf-8')
143 logger.exception('Plugin file installation failed: %s', output)
144 log_error('install_plugins_file', scope='pip')
145 return False
146 except Exception as exc:
147 logger.exception('Plugin file installation failed: %s', exc)
148 log_error('install_plugins_file', scope='pip')
149 return False
151 # Collect plugin static files
152 try:
153 plugin.staticfiles.collect_plugins_static_files()
154 except Exception:
155 log_error('collect_plugins_static_files', scope='plugins')
157 # At this point, the plugins file has been installed
158 return True
161def update_plugins_file(install_name, full_package=None, version=None, remove=False):
162 """Add a plugin to the plugins file."""
163 if remove:
164 logger.info('Removing plugin from plugins file: %s', install_name)
165 else:
166 logger.info('Adding plugin to plugins file: %s', install_name)
168 # If a full package name is provided, use that instead
169 if full_package and full_package != install_name:
170 new_value = full_package
171 else:
172 new_value = f'{install_name}=={version}' if version else install_name
174 pf = settings.PLUGIN_FILE
176 if not pf or not pf.exists():
177 logger.warning('Plugin file %s does not exist', pf)
178 return
180 def compare_line(line: str):
181 """Check if a line in the file matches the installname."""
182 return re.match(rf'^{install_name}[\s=@]', line.strip())
184 # First, read in existing plugin file
185 try:
186 with pf.open(mode='r') as f:
187 lines = f.readlines()
188 except Exception as exc:
189 logger.exception('Failed to read plugins file: %s', exc)
190 log_error('update_plugins_file', scope='plugins')
191 return
193 # Reconstruct output file
194 output = []
196 found = False
198 # Check if plugin is already in file
199 for line in lines:
200 # Ignore processing for any commented lines
201 if line.strip().startswith('#'):
202 output.append(line)
203 continue
205 if compare_line(line):
206 found = True
207 if not remove:
208 # Replace line with new install name
209 output.append(new_value)
210 else:
211 output.append(line)
213 # Append plugin to file
214 if not found and not remove:
215 output.append(new_value)
217 # Write file back to disk
218 try:
219 with pf.open(mode='w') as f:
220 for line in output:
221 f.write(line)
223 if not line.endswith('\n'):
224 f.write('\n')
225 except Exception as exc:
226 logger.exception('Failed to add plugin to plugins file: %s', exc)
227 log_error('update_plugins_file', scope='plugins')
230def install_plugin(url=None, packagename=None, user=None, version=None):
231 """Install a plugin into the python virtual environment.
233 Args:
234 packagename: Optional package name to install
235 url: Optional URL to install from
236 user: Optional user performing the installation
237 version: Optional version specifier
238 """
239 if user and not user.is_superuser: 239 ↛ 240line 239 didn't jump to line 240 because the condition on line 239 was never true
240 raise ValidationError(_('Only superuser accounts can administer plugins'))
242 if settings.PLUGINS_INSTALL_DISABLED: 242 ↛ 245line 242 didn't jump to line 245 because the condition on line 242 was always true
243 raise ValidationError(_('Plugin installation is disabled'))
245 logger.info('install_plugin: %s, %s', url, packagename)
247 # build up the command
248 install_name = ['install', '-U', '--disable-pip-version-check']
250 full_pkg = ''
252 if url:
253 # use custom registration / VCS
254 if True in [
255 identifier in url for identifier in ['git+https', 'hg+https', 'svn+svn']
256 ]:
257 # using a VCS provider
258 full_pkg = f'{packagename}@{url}' if packagename else url
259 elif url:
260 install_name.append('-i')
261 full_pkg = url
262 elif packagename:
263 full_pkg = packagename
265 elif packagename:
266 # use pypi
267 full_pkg = packagename
269 if version:
270 full_pkg = f'{full_pkg}=={version}'
272 if not full_pkg:
273 raise ValidationError(_('No package name or URL provided for installation'))
275 # Sanitize the package name for installation
276 if any(c in full_pkg for c in ';&|`$()'):
277 raise ValidationError(_('Invalid characters in package name or URL'))
279 install_name.append(full_pkg)
281 ret = {}
283 # Execute installation via pip
284 try:
285 result = pip_command(*install_name)
287 ret['result'] = ret['success'] = _('Installed plugin successfully')
288 ret['output'] = str(result, 'utf-8')
290 if packagename and (info := get_install_info(packagename)):
291 if path := info.get('location'):
292 ret['result'] = _(f'Installed plugin into {path}')
293 ret['version'] = info.get('version')
295 except subprocess.CalledProcessError as error:
296 handle_pip_error(error, 'plugin_install')
297 except Exception:
298 log_error('install_plugin', scope='plugins')
300 if version := ret.get('version'):
301 # Save plugin to plugins file
302 update_plugins_file(packagename, full_package=full_pkg, version=version)
304 # Reload the plugin registry, to discover the new plugin
305 from plugin.registry import registry
307 registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
309 # Update static files
310 plugin.staticfiles.collect_plugins_static_files()
312 return ret
315def validate_package_plugin(cfg: plugin.models.PluginConfig, user=None):
316 """Validate a package plugin for update or removal."""
317 if not cfg.plugin:
318 raise ValidationError(_('Plugin was not found in registry'))
320 if not cfg.is_package():
321 raise ValidationError(_('Plugin is not a packaged plugin'))
323 if not cfg.package_name:
324 raise ValidationError(_('Plugin package name not found'))
326 if user and not user.is_staff:
327 raise ValidationError(_('Only staff users can administer plugins'))
330def uninstall_plugin(cfg: plugin.models.PluginConfig, user=None, delete_config=True):
331 """Uninstall a plugin from the python virtual environment.
333 - The plugin must not be active
334 - The plugin must be a "package" and have a valid package name
336 Args:
337 cfg: PluginConfig object
338 user: User performing the uninstall
339 delete_config: If True, delete the plugin configuration from the database
340 """
341 from plugin.registry import registry
343 if user and not user.is_superuser: 343 ↛ 344line 343 didn't jump to line 344 because the condition on line 343 was never true
344 raise ValidationError(_('Only superuser accounts can administer plugins'))
346 if settings.PLUGINS_INSTALL_DISABLED: 346 ↛ 349line 346 didn't jump to line 349 because the condition on line 346 was always true
347 raise ValidationError(_('Plugin uninstalling is disabled'))
349 if cfg.active:
350 raise ValidationError(
351 _('Plugin cannot be uninstalled as it is currently active')
352 )
354 if cfg.is_mandatory(): # pragma: no cover
355 # This is only an additional check, as mandatory plugins cannot be deactivated
356 raise ValidationError(
357 'INVE-E10' + _('Plugin cannot be uninstalled as it is mandatory')
358 )
360 if cfg.is_sample():
361 raise ValidationError(
362 'INVE-E10' + _('Plugin cannot be uninstalled as it is a sample plugin')
363 )
365 if cfg.is_builtin():
366 raise ValidationError(
367 'INVE-E10' + _('Plugin cannot be uninstalled as it is a built-in plugin')
368 )
370 if not cfg.is_installed():
371 raise ValidationError(_('Plugin is not installed'))
373 validate_package_plugin(cfg, user)
374 package_name = cfg.package_name
376 pkg_info = get_install_info(package_name)
378 if path := pkg_info.get('location'):
379 # Uninstall the plugin using pip
380 logger.info('Uninstalling plugin: %s from %s', package_name, path)
381 try:
382 pip_command('uninstall', '-y', package_name)
383 except subprocess.CalledProcessError as error:
384 handle_pip_error(error, 'plugin_uninstall')
385 except Exception:
386 log_error('uninstall_plugin', scope='plugins')
387 else:
388 # No matching install target found
389 raise ValidationError(_('Plugin installation not found'))
391 # Update the plugins file
392 update_plugins_file(package_name, remove=True)
394 if delete_config:
395 logger.info('Deleting plugin configuration from database: %s', cfg.key)
396 # Remove the plugin configuration from the database
397 cfg.delete()
399 # Remove static files associated with this plugin
400 plugin.staticfiles.clear_plugin_static_files(cfg.key)
402 # Reload the plugin registry
403 registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
405 return {'result': _('Uninstalled plugin successfully'), 'success': True}