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
from collections import defaultdict
from functools import wraps
from importlib import import_module
from pathlib import Path
from types import GenericAlias
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar
@ -365,11 +366,11 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
"""
try:
try:
namespace = __import__(f"{PLUGIN_NAMESPACE}.{name}", None, None)
namespace = import_module(f"{PLUGIN_NAMESPACE}.{name}")
except Exception as exc:
raise PluginImportError(name) from exc
for obj in getattr(namespace, name).__dict__.values():
for obj in namespace.__dict__.values():
if (
inspect.isclass(obj)
and not isinstance(
@ -378,6 +379,12 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
and issubclass(obj, BeetsPlugin)
and obj != BeetsPlugin
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()

View file

@ -32,6 +32,8 @@ Bug fixes:
extraartists field.
- :doc:`plugins/spotify` Fixed an issue where candidate lookup would not find
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:

View file

@ -5,9 +5,10 @@ import sys
import threading
import unittest
from io import StringIO
from types import ModuleType
from unittest.mock import patch
import beets.logging as blog
import beetsplug
from beets import plugins, ui
from beets.test import _common, helper
from beets.test.helper import AsIsImporterMixin, ImportTestCase, PluginMixin
@ -47,36 +48,46 @@ class LoggingTest(unittest.TestCase):
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):
plugin = "dummy"
class DummyModule:
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)
@classmethod
def setUpClass(cls):
patcher = patch.dict(sys.modules, {"beetsplug.dummy": DummyModule()})
patcher.start()
cls.addClassCleanup(patcher.stop)
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 setUp(self):
sys.modules["beetsplug.dummy"] = self.DummyModule
beetsplug.dummy = self.DummyModule
super().setUp()
super().setUpClass()
def test_command_level0(self):
self.config["verbose"] = 0