diff --git a/beets/plugins.py b/beets/plugins.py index c0dd12e5b..6526c65ca 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -20,6 +20,7 @@ import abc import inspect import re import sys +import warnings from collections import defaultdict from functools import wraps from importlib import import_module @@ -370,23 +371,45 @@ def _get_plugin(name: str) -> BeetsPlugin | None: except Exception as exc: raise PluginImportError(name) from exc - for obj in namespace.__dict__.values(): - if ( - inspect.isclass(obj) - and not isinstance( - obj, GenericAlias - ) # seems to be needed for python <= 3.9 only - 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() + # 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 = list( + filter( + lambda obj: ( + inspect.isclass(obj) + and not isinstance( + obj, GenericAlias + ) # seems to be needed for python <= 3.9 only + 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__}.") + ) + ), + members, + ) + ) + + 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) == 1: + return plugin_classes[0]() except Exception: log.warning("** error loading plugin {}", name, exc_info=True) diff --git a/beetsplug/bpsync.py b/beetsplug/bpsync.py index 9ae6d47d5..0712da1f3 100644 --- a/beetsplug/bpsync.py +++ b/beetsplug/bpsync.py @@ -179,3 +179,6 @@ class BPSyncPlugin(BeetsPlugin): if move and lib.directory in util.ancestry(items[0].path): self._log.debug("moving album {}", album) album.move() + + +__all__ = ["BPSyncPlugin"] diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index 192310fb8..1243cdf45 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -363,3 +363,6 @@ def fingerprint_item(log, item, write=False): return item.acoustid_fingerprint except acoustid.FingerprintGenerationError as exc: log.info("fingerprint generation failed: {}", exc) + + +__all__ = ["AcoustidPlugin"] diff --git a/docs/changelog.rst b/docs/changelog.rst index 38037955e..8bf08cbb1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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) --------------------------