Fix plugin loading (#6039)

Fixes #6033

This PR addresses a bug where plugin loading failed when plugins
imported other `BeetsPlugin` classes, namely `chroma` and `bpsync`.

- Add module path filtering to ensure only classes from the target
plugin module are considered, preventing conflicts when plugins import
other `BeetsPlugin` classes
This commit is contained in:
Šarūnas Nejus 2025-09-29 11:47:16 +01:00 committed by GitHub
commit 689ec1022f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 49 additions and 29 deletions

View file

@ -22,6 +22,7 @@ import re
import sys import sys
from collections import defaultdict from collections import defaultdict
from functools import wraps from functools import wraps
from importlib import import_module
from pathlib import Path from pathlib import Path
from types import GenericAlias from types import GenericAlias
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar
@ -365,11 +366,11 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
""" """
try: try:
try: try:
namespace = __import__(f"{PLUGIN_NAMESPACE}.{name}", None, None) namespace = import_module(f"{PLUGIN_NAMESPACE}.{name}")
except Exception as exc: except Exception as exc:
raise PluginImportError(name) from exc raise PluginImportError(name) from exc
for obj in getattr(namespace, name).__dict__.values(): for obj in namespace.__dict__.values():
if ( if (
inspect.isclass(obj) inspect.isclass(obj)
and not isinstance( and not isinstance(
@ -378,6 +379,12 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
and issubclass(obj, BeetsPlugin) and issubclass(obj, BeetsPlugin)
and obj != BeetsPlugin and obj != BeetsPlugin
and not inspect.isabstract(obj) and not inspect.isabstract(obj)
# Only consider this plugin's module or submodules to avoid
# conflicts when plugins import other BeetsPlugin classes
and (
obj.__module__ == namespace.__name__
or obj.__module__.startswith(f"{namespace.__name__}.")
)
): ):
return obj() return obj()

View file

@ -32,6 +32,8 @@ Bug fixes:
extraartists field. extraartists field.
- :doc:`plugins/spotify` Fixed an issue where candidate lookup would not find - :doc:`plugins/spotify` Fixed an issue where candidate lookup would not find
matches due to query escaping (single vs double quotes). matches due to query escaping (single vs double quotes).
- :doc:`plugins/chroma` :doc:`plugins/bpsync` Fix plugin loading issue caused by
an import of another :class:`beets.plugins.BeetsPlugin` class. :bug:`6033`
For packagers: For packagers:

View file

@ -5,9 +5,10 @@ import sys
import threading import threading
import unittest import unittest
from io import StringIO from io import StringIO
from types import ModuleType
from unittest.mock import patch
import beets.logging as blog import beets.logging as blog
import beetsplug
from beets import plugins, ui from beets import plugins, ui
from beets.test import _common, helper from beets.test import _common, helper
from beets.test.helper import AsIsImporterMixin, ImportTestCase, PluginMixin from beets.test.helper import AsIsImporterMixin, ImportTestCase, PluginMixin
@ -47,36 +48,46 @@ class LoggingTest(unittest.TestCase):
assert stream.getvalue(), "foo oof baz" assert stream.getvalue(), "foo oof baz"
class DummyModule(ModuleType):
class DummyPlugin(plugins.BeetsPlugin):
def __init__(self):
plugins.BeetsPlugin.__init__(self, "dummy")
self.import_stages = [self.import_stage]
self.register_listener("dummy_event", self.listener)
def log_all(self, name):
self._log.debug("debug {}", name)
self._log.info("info {}", name)
self._log.warning("warning {}", name)
def commands(self):
cmd = ui.Subcommand("dummy")
cmd.func = lambda _, __, ___: self.log_all("cmd")
return (cmd,)
def import_stage(self, session, task):
self.log_all("import_stage")
def listener(self):
self.log_all("listener")
def __init__(self, *_, **__):
module_name = "beetsplug.dummy"
super().__init__(module_name)
self.DummyPlugin.__module__ = module_name
self.DummyPlugin = self.DummyPlugin
class LoggingLevelTest(AsIsImporterMixin, PluginMixin, ImportTestCase): class LoggingLevelTest(AsIsImporterMixin, PluginMixin, ImportTestCase):
plugin = "dummy" plugin = "dummy"
class DummyModule: @classmethod
class DummyPlugin(plugins.BeetsPlugin): def setUpClass(cls):
def __init__(self): patcher = patch.dict(sys.modules, {"beetsplug.dummy": DummyModule()})
plugins.BeetsPlugin.__init__(self, "dummy") patcher.start()
self.import_stages = [self.import_stage] cls.addClassCleanup(patcher.stop)
self.register_listener("dummy_event", self.listener)
def log_all(self, name): super().setUpClass()
self._log.debug("debug {}", name)
self._log.info("info {}", name)
self._log.warning("warning {}", name)
def commands(self):
cmd = ui.Subcommand("dummy")
cmd.func = lambda _, __, ___: self.log_all("cmd")
return (cmd,)
def import_stage(self, session, task):
self.log_all("import_stage")
def listener(self):
self.log_all("listener")
def setUp(self):
sys.modules["beetsplug.dummy"] = self.DummyModule
beetsplug.dummy = self.DummyModule
super().setUp()
def test_command_level0(self): def test_command_level0(self):
self.config["verbose"] = 0 self.config["verbose"] = 0