Skip to content

Commit 689ec10

Browse files
authored
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
2 parents 4e865a6 + 7954671 commit 689ec10

File tree

3 files changed

+50
-30
lines changed

3 files changed

+50
-30
lines changed

beets/plugins.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import sys
2323
from collections import defaultdict
2424
from functools import wraps
25+
from importlib import import_module
2526
from pathlib import Path
2627
from types import GenericAlias
2728
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar
@@ -365,11 +366,11 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
365366
"""
366367
try:
367368
try:
368-
namespace = __import__(f"{PLUGIN_NAMESPACE}.{name}", None, None)
369+
namespace = import_module(f"{PLUGIN_NAMESPACE}.{name}")
369370
except Exception as exc:
370371
raise PluginImportError(name) from exc
371372

372-
for obj in getattr(namespace, name).__dict__.values():
373+
for obj in namespace.__dict__.values():
373374
if (
374375
inspect.isclass(obj)
375376
and not isinstance(
@@ -378,6 +379,12 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
378379
and issubclass(obj, BeetsPlugin)
379380
and obj != BeetsPlugin
380381
and not inspect.isabstract(obj)
382+
# Only consider this plugin's module or submodules to avoid
383+
# conflicts when plugins import other BeetsPlugin classes
384+
and (
385+
obj.__module__ == namespace.__name__
386+
or obj.__module__.startswith(f"{namespace.__name__}.")
387+
)
381388
):
382389
return obj()
383390

docs/changelog.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ Bug fixes:
3232
extraartists field.
3333
- :doc:`plugins/spotify` Fixed an issue where candidate lookup would not find
3434
matches due to query escaping (single vs double quotes).
35+
- :doc:`plugins/chroma` :doc:`plugins/bpsync` Fix plugin loading issue caused by
36+
an import of another :class:`beets.plugins.BeetsPlugin` class. :bug:`6033`
3537

3638
For packagers:
3739

test/test_logging.py

Lines changed: 39 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
import threading
66
import unittest
77
from io import StringIO
8+
from types import ModuleType
9+
from unittest.mock import patch
810

911
import beets.logging as blog
10-
import beetsplug
1112
from beets import plugins, ui
1213
from beets.test import _common, helper
1314
from beets.test.helper import AsIsImporterMixin, ImportTestCase, PluginMixin
@@ -47,36 +48,46 @@ def test_str_format_logging(self):
4748
assert stream.getvalue(), "foo oof baz"
4849

4950

51+
class DummyModule(ModuleType):
52+
class DummyPlugin(plugins.BeetsPlugin):
53+
def __init__(self):
54+
plugins.BeetsPlugin.__init__(self, "dummy")
55+
self.import_stages = [self.import_stage]
56+
self.register_listener("dummy_event", self.listener)
57+
58+
def log_all(self, name):
59+
self._log.debug("debug {}", name)
60+
self._log.info("info {}", name)
61+
self._log.warning("warning {}", name)
62+
63+
def commands(self):
64+
cmd = ui.Subcommand("dummy")
65+
cmd.func = lambda _, __, ___: self.log_all("cmd")
66+
return (cmd,)
67+
68+
def import_stage(self, session, task):
69+
self.log_all("import_stage")
70+
71+
def listener(self):
72+
self.log_all("listener")
73+
74+
def __init__(self, *_, **__):
75+
module_name = "beetsplug.dummy"
76+
super().__init__(module_name)
77+
self.DummyPlugin.__module__ = module_name
78+
self.DummyPlugin = self.DummyPlugin
79+
80+
5081
class LoggingLevelTest(AsIsImporterMixin, PluginMixin, ImportTestCase):
5182
plugin = "dummy"
5283

53-
class DummyModule:
54-
class DummyPlugin(plugins.BeetsPlugin):
55-
def __init__(self):
56-
plugins.BeetsPlugin.__init__(self, "dummy")
57-
self.import_stages = [self.import_stage]
58-
self.register_listener("dummy_event", self.listener)
59-
60-
def log_all(self, name):
61-
self._log.debug("debug {}", name)
62-
self._log.info("info {}", name)
63-
self._log.warning("warning {}", name)
64-
65-
def commands(self):
66-
cmd = ui.Subcommand("dummy")
67-
cmd.func = lambda _, __, ___: self.log_all("cmd")
68-
return (cmd,)
69-
70-
def import_stage(self, session, task):
71-
self.log_all("import_stage")
72-
73-
def listener(self):
74-
self.log_all("listener")
75-
76-
def setUp(self):
77-
sys.modules["beetsplug.dummy"] = self.DummyModule
78-
beetsplug.dummy = self.DummyModule
79-
super().setUp()
84+
@classmethod
85+
def setUpClass(cls):
86+
patcher = patch.dict(sys.modules, {"beetsplug.dummy": DummyModule()})
87+
patcher.start()
88+
cls.addClassCleanup(patcher.stop)
89+
90+
super().setUpClass()
8091

8192
def test_command_level0(self):
8293
self.config["verbose"] = 0

0 commit comments

Comments
 (0)