diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 4845df89fd..5952877b96 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -261,18 +261,22 @@ jobs: coverage run -m unittest discover -s test/ coverage: - name: Tests - DB [SQLite] + Coverage + name: Tests - DB [SQLite] + Coverage ${{ matrix.python_version }} runs-on: ubuntu-20.04 needs: ["pre-commit", "paths-filter"] if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true' continue-on-error: true # continue if a step fails so that coverage gets pushed + strategy: + matrix: + python_version: [3.9, 3.12] env: INVENTREE_DB_NAME: ./inventree.sqlite INVENTREE_DB_ENGINE: sqlite3 INVENTREE_PLUGINS_ENABLED: true GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + python_version: ${{ matrix.python_version }} steps: - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1 diff --git a/src/backend/InvenTree/InvenTree/unit_test.py b/src/backend/InvenTree/InvenTree/unit_test.py index c5f8804d23..ea7b559511 100644 --- a/src/backend/InvenTree/InvenTree/unit_test.py +++ b/src/backend/InvenTree/InvenTree/unit_test.py @@ -435,3 +435,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase): data.append(entry) return data + + def assertDictContainsSubset(self, a, b): + """Assert that dictionary 'a' is a subset of dictionary 'b'.""" + self.assertEqual(b, b | a) diff --git a/src/backend/InvenTree/plugin/helpers.py b/src/backend/InvenTree/plugin/helpers.py index bd287bcdd8..6e8425ad5a 100644 --- a/src/backend/InvenTree/plugin/helpers.py +++ b/src/backend/InvenTree/plugin/helpers.py @@ -5,14 +5,16 @@ import logging import os import pathlib import pkgutil +import sys import sysconfig import traceback from importlib.metadata import entry_points +from importlib.util import module_from_spec from django import template from django.conf import settings from django.core.exceptions import AppRegistryNotReady -from django.db.utils import IntegrityError, OperationalError, ProgrammingError +from django.db.utils import IntegrityError logger = logging.getLogger('inventree') @@ -110,7 +112,10 @@ def handle_error(error, do_raise: bool = True, do_log: bool = True, log_name: st def get_entrypoints(): """Returns list for entrypoints for InvenTree plugins.""" - return entry_points().get('inventree_plugins', []) + # on python before 3.12, we need to use importlib_metadata + if sys.version_info < (3, 12): + return entry_points().get('inventree_plugins', []) + return entry_points(group='inventree_plugins') # endregion @@ -121,7 +126,8 @@ def get_git_log(path): """Get dict with info of the last commit to file named in path.""" import datetime - from dulwich.repo import NotGitRepository, Repo + from dulwich.errors import NotGitRepository + from dulwich.repo import Repo from InvenTree.ready import isInTestMode @@ -175,9 +181,15 @@ def get_modules(pkg, path=None): elif type(path) is not list: path = [path] - for loader, name, _ in pkgutil.walk_packages(path): + for finder, name, _ in pkgutil.walk_packages(path): try: - module = loader.find_module(name).load_module(name) + if sys.version_info < (3, 12): + module = finder.find_module(name).load_module(name) + else: + spec = finder.find_spec(name) + module = module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) pkg_names = getattr(module, '__all__', None) for k, v in vars(module).items(): if not k.startswith('_') and (pkg_names is None or k in pkg_names): diff --git a/src/backend/InvenTree/plugin/registry.py b/src/backend/InvenTree/plugin/registry.py index d56ce885ed..15f01bd127 100644 --- a/src/backend/InvenTree/plugin/registry.py +++ b/src/backend/InvenTree/plugin/registry.py @@ -4,12 +4,15 @@ - Manages setup and teardown of plugin class instances """ -import imp import importlib +import importlib.machinery +import importlib.util import logging import os +import sys import time from collections import OrderedDict +from importlib.machinery import SourceFileLoader from pathlib import Path from threading import Lock from typing import Any @@ -411,9 +414,15 @@ class PluginsRegistry: # Gather Modules if parent_path: - raw_module = imp.load_source( - plugin, str(parent_obj.joinpath('__init__.py')) - ) + # On python 3.12 use new loader method + if sys.version_info < (3, 12): + raw_module = _load_source( + plugin, str(parent_obj.joinpath('__init__.py')) + ) + else: + raw_module = SourceFileLoader( + plugin, str(parent_obj.joinpath('__init__.py')) + ).load_module() else: raw_module = importlib.import_module(plugin) modules = get_plugins(raw_module, InvenTreePlugin, path=parent_path) @@ -830,3 +839,18 @@ registry: PluginsRegistry = PluginsRegistry() def call_function(plugin_name, function_name, *args, **kwargs): """Global helper function to call a specific member function of a plugin.""" return registry.call_plugin_function(plugin_name, function_name, *args, **kwargs) + + +def _load_source(modname, filename): + """Helper function to replace deprecated & removed imp.load_source. + + See https://docs.python.org/3/whatsnew/3.12.html#imp + """ + loader = importlib.machinery.SourceFileLoader(modname, filename) + spec = importlib.util.spec_from_file_location(modname, filename, loader=loader) + 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 + loader.exec_module(module) + return module