Coverage for src/backend/InvenTree/plugin/helpers.py: 55%
127 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"""Helpers for plugin app."""
3import inspect
4import os
5import pathlib
6import pkgutil
7import sys
8import sysconfig
9import traceback
10from importlib.metadata import entry_points
11from importlib.util import module_from_spec
13from django.conf import settings
14from django.core.exceptions import AppRegistryNotReady
15from django.db.utils import IntegrityError
17import structlog
19logger = structlog.get_logger('inventree')
22# region logging / errors
23class IntegrationPluginError(Exception):
24 """Error that encapsulates another error and adds the path / reference of the raising plugin."""
26 def __init__(self, path, message):
27 """Init a plugin error.
29 Args:
30 path: Path on which the error occurred - used to find out which plugin it was
31 message: The original error message
32 """
33 self.path = path
34 self.message = message
36 def __str__(self):
37 """Returns the error message."""
38 return self.message # pragma: no cover
41class MixinImplementationError(ValueError):
42 """Error if mixin was implemented wrong in plugin.
44 Mostly raised if constant is missing
45 """
48class MixinNotImplementedError(NotImplementedError):
49 """Error if necessary mixin function was not overwritten."""
52def log_registry_error(error, reference: str = 'general'):
53 """Log an plugin error."""
54 from plugin.registry import registry
56 # make sure the registry is set up
57 if reference not in registry.errors:
58 registry.errors[reference] = []
60 # add error to stack
61 registry.errors[reference].append(error)
64def handle_error(error, do_raise: bool = True, do_log: bool = True, log_name: str = ''):
65 """Handles an error and casts it as an IntegrationPluginError."""
66 package_path = traceback.extract_tb(error.__traceback__)[-1].filename
67 install_path = sysconfig.get_paths()['purelib']
69 try:
70 package_name = pathlib.Path(package_path).relative_to(install_path).parts[0]
71 except ValueError:
72 # is file - loaded -> form a name for that
73 try:
74 path_obj = pathlib.Path(package_path).relative_to(settings.BASE_DIR)
75 path_parts = [*path_obj.parts]
76 path_parts[-1] = path_parts[-1].replace(
77 path_obj.suffix, ''
78 ) # remove suffix
80 # remove path prefixes
81 if path_parts[0] == 'plugin':
82 path_parts.remove('plugin')
83 path_parts.pop(0)
84 else:
85 path_parts.remove('plugins') # pragma: no cover
87 package_name = '.'.join(path_parts)
88 except Exception:
89 package_name = package_path
91 if do_log:
92 log_kwargs = {}
93 if log_name:
94 log_kwargs['reference'] = log_name
95 log_registry_error({package_name: str(error)}, **log_kwargs)
97 if do_raise:
98 # do a straight raise if we are playing with environment variables at execution time, ignore the broken sample
99 if (
100 settings.TESTING_ENV
101 and package_name != 'integration.broken_sample'
102 and isinstance(error, IntegrityError)
103 ):
104 raise error # pragma: no cover
106 raise IntegrationPluginError(package_name, str(error))
109def get_entrypoints():
110 """Returns list for entrypoints for InvenTree plugins."""
111 # on python before 3.12, we need to use importlib_metadata
112 if sys.version_info < (3, 12):
113 return entry_points().get('inventree_plugins', [])
114 return entry_points(group='inventree_plugins')
117# endregion
120# region git-helpers
121def get_git_log(path):
122 """Get dict with info of the last commit to file named in path."""
123 import datetime
125 from dulwich.errors import NotGitRepository
126 from dulwich.repo import Repo
128 from InvenTree.ready import isInTestMode
130 output = None
131 path = os.path.abspath(path)
133 if os.path.exists(path) and os.path.isfile(path): 133 ↛ 137line 133 didn't jump to line 137 because the condition on line 133 was always true
134 path = os.path.dirname(path)
136 # only do this if we are not in test mode
137 if not isInTestMode(): # pragma: no cover 137 ↛ 156line 137 didn't jump to line 156 because the condition on line 137 was always true
138 try:
139 repo = Repo(path)
140 head = repo.head()
141 commit = repo[head]
143 output = [
144 head.decode(),
145 commit.author.decode().split('<')[0][:-1],
146 commit.author.decode().split('<')[1][:-1],
147 datetime.datetime.fromtimestamp(commit.author_time).isoformat(),
148 commit.message.decode().split('\n')[0],
149 ]
150 repo.close()
151 except KeyError:
152 logger.debug('No HEAD tag found in git repo at path %s', path)
153 except NotGitRepository:
154 pass
156 if not output: 156 ↛ 159line 156 didn't jump to line 159 because the condition on line 156 was always true
157 output = 5 * [''] # pragma: no cover
159 return {
160 'hash': output[0],
161 'author': output[1],
162 'mail': output[2],
163 'date': output[3],
164 'message': output[4],
165 }
168# endregion
171# region plugin finders
172def get_modules(pkg, path=None):
173 """Get all modules in a package."""
174 context = {}
176 if path is None: 176 ↛ 178line 176 didn't jump to line 178 because the condition on line 176 was always true
177 path = pkg.__path__
178 elif type(path) is not list:
179 path = [path]
181 packages = pkgutil.walk_packages(path)
183 while True:
184 try:
185 finder, name, _ = next(packages)
186 except StopIteration:
187 break
188 except Exception as error:
189 log_registry_error({pkg.__name__: str(error)}, 'discovery')
190 continue
192 try:
193 if sys.version_info < (3, 12): 193 ↛ 194line 193 didn't jump to line 194 because the condition on line 193 was never true
194 module = finder.find_module(name).load_module(name)
195 else:
196 spec = finder.find_spec(name)
197 module = module_from_spec(spec)
198 sys.modules[name] = module
199 spec.loader.exec_module(module)
200 pkg_names = getattr(module, '__all__', None)
201 for k, v in vars(module).items():
202 if not k.startswith('_') and (pkg_names is None or k in pkg_names):
203 context[k] = v
204 context[name] = module
205 except AppRegistryNotReady: # pragma: no cover
206 pass
207 except Exception as error:
208 # this 'protects' against malformed plugin modules by more or less silently failing
210 # log to stack
211 log_registry_error({name: str(error)}, 'discovery')
213 return [v for k, v in context.items()]
216def get_classes(module) -> list:
217 """Get all classes in a given module."""
218 try:
219 return inspect.getmembers(module, inspect.isclass)
220 except Exception:
221 log_registry_error({module.__name__: 'Could not get classes'}, 'discovery')
222 return []
225def get_plugins(pkg, baseclass, path=None):
226 """Return a list of all modules under a given package.
228 - Modules must be a subclass of the provided 'baseclass'
229 - Modules must have a non-empty NAME parameter
230 """
231 plugins = []
233 modules = get_modules(pkg, path=path)
235 # Iterate through each module in the package
236 for mod in modules:
237 # Iterate through each class in the module
238 for item in get_classes(mod):
239 plugin = item[1]
240 if issubclass(plugin, baseclass) and plugin.NAME:
241 plugins.append(plugin)
243 return plugins
246# endregion