Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions beets/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import inspect
import re
import sys
import warnings
from collections import defaultdict
from functools import wraps
from importlib import import_module
Expand Down Expand Up @@ -370,7 +371,15 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
except Exception as exc:
raise PluginImportError(name) from exc

for obj in namespace.__dict__.values():
# we prefer __all__ here if it is defined
# this follow common module export rules
exports = getattr(namespace, "__all__", namespace.__dict__)
members = [getattr(namespace, key) for key in exports]

# Determine all classes that extend `BeetsPlugin`
plugin_classes = [
obj
for obj in members
if (
inspect.isclass(obj)
and not isinstance(
Expand All @@ -385,8 +394,21 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
obj.__module__ == namespace.__name__
or obj.__module__.startswith(f"{namespace.__name__}.")
)
):
return obj()
)
]

if len(plugin_classes) > 1:
warnings.warn(
f"Plugin '{name}' defines multiple plugin classes; "
f"using the first one found ({plugin_classes[0].__name__}). "
f"This will become an error in beets 3.0.0. Consider exporting "
f"the desired plugin class explicitly using `__all__`.",
DeprecationWarning,
stacklevel=2,
)

if len(plugin_classes) != 0:
return plugin_classes[0]()

except Exception:
log.warning("** error loading plugin {}", name, exc_info=True)
Expand Down
4 changes: 4 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ For developers and plugin authors:
- Typing improvements in ``beets/logging.py``: ``getLogger`` now returns
``BeetsLogger`` when called with a name, or ``RootLogger`` when called without
a name.
- Deprecation: Exporting multiple plugins from a single plugin namespace is no
longer supported. This was never an intended use case, though it could occur
unintentionally. The system now raises a warning when this happens and
provides guidance on how to resolve it.

2.4.0 (September 13, 2025)
--------------------------
Expand Down
38 changes: 38 additions & 0 deletions test/test_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import os
import pkgutil
import sys
from types import ModuleType
from unittest.mock import ANY, Mock, patch

import pytest
Expand Down Expand Up @@ -523,3 +524,40 @@ def test_import_plugin(self, caplog, plugin_name):
assert "PluginImportError" not in caplog.text, (
f"Plugin '{plugin_name}' has issues during import."
)


class MultiPluginModule(ModuleType):
class DummyPlugin1(plugins.BeetsPlugin):
pass

class DummyPlugin2(plugins.BeetsPlugin):
pass

def __init__(self, *_, **__):
module_name = "beetsplug.multi_export"
super().__init__(module_name)
self.DummyPlugin1.__module__ = module_name
self.DummyPlugin1 = self.DummyPlugin1
self.DummyPlugin2 = self.DummyPlugin2
self.DummyPlugin2.__module__ = module_name


class TestMultiPluginExport(PluginMixin):
"""Test that exporting multiple plugins from a single namespace
raises a warning.

TODO: Change to raises an error on migration to 3.0.0
"""

plugin = "multi_export"

@pytest.fixture(autouse=True, scope="class")
def setup(self):
with patch.dict(
sys.modules, {"beetsplug.multi_export": MultiPluginModule()}
):
yield

def test_multi_plugin_export(self):
with pytest.deprecated_call():
self.load_plugins("multi_export")