Skip to content

Commit 52bdb58

Browse files
committed
Simplify plugin loading mechanism
Centralise plugin loading in `beets.plugins` and refactor the plugin loading system to be more straightforward and eliminate complex mocking in tests. Replace the two-stage class collection and instantiation process with direct instance creation and storage. Add plugins.PluginImportError and adjust plugin import tests to only complain about plugin import issues.
1 parent 788e31b commit 52bdb58

File tree

5 files changed

+134
-175
lines changed

5 files changed

+134
-175
lines changed

beets/plugins.py

Lines changed: 88 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@
1919
import abc
2020
import inspect
2121
import re
22-
import traceback
22+
import sys
2323
from collections import defaultdict
2424
from functools import wraps
25+
from pathlib import Path
2526
from types import GenericAlias
2627
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar
2728

@@ -76,6 +77,17 @@ class PluginConflictError(Exception):
7677
"""
7778

7879

80+
class PluginImportError(ImportError):
81+
"""Indicates that a plugin could not be imported.
82+
83+
This is a subclass of ImportError so that it can be caught separately
84+
from other errors.
85+
"""
86+
87+
def __init__(self, name: str):
88+
super().__init__(f"Could not import plugin {name}")
89+
90+
7991
class PluginLogFilter(logging.Filter):
8092
"""A logging filter that identifies the plugin that emitted a log
8193
message.
@@ -263,69 +275,90 @@ def helper(func: TFunc[Item]) -> TFunc[Item]:
263275
return helper
264276

265277

266-
_classes: set[type[BeetsPlugin]] = set()
278+
def get_plugin_names(
279+
include: set[str] | None = None, exclude: set[str] | None = None
280+
) -> set[str]:
281+
"""Discover and return the set of plugin names to be loaded.
282+
283+
Configures the plugin search paths and resolves the final set of plugins
284+
based on configuration settings, inclusion filters, and exclusion rules.
285+
Automatically includes the musicbrainz plugin when enabled in configuration.
286+
"""
287+
paths = [
288+
str(Path(p).expanduser().absolute())
289+
for p in beets.config["pluginpath"].as_str_seq(split=False)
290+
]
291+
log.debug("plugin paths: {}", paths)
292+
293+
# Extend the `beetsplug` package to include the plugin paths.
294+
import beetsplug
295+
296+
beetsplug.__path__ = paths + list(beetsplug.__path__)
297+
298+
# For backwards compatibility, also support plugin paths that
299+
# *contain* a `beetsplug` package.
300+
sys.path += paths
301+
plugins = include or set(beets.config["plugins"].as_str_seq())
302+
# TODO: Remove in v3.0.0
303+
if "musicbrainz" in beets.config and beets.config["musicbrainz"].get().get(
304+
"enabled"
305+
):
306+
plugins.add("musicbrainz")
267307

308+
return plugins - (exclude or set())
268309

269-
def load_plugins(names: Sequence[str] = ()) -> None:
270-
"""Imports the modules for a sequence of plugin names. Each name
271-
must be the name of a Python module under the "beetsplug" namespace
272-
package in sys.path; the module indicated should contain the
273-
BeetsPlugin subclasses desired.
310+
311+
def _get_plugin(name: str) -> BeetsPlugin | None:
312+
"""Dynamically load and instantiate a plugin class by name.
313+
314+
Attempts to import the plugin module, locate the appropriate plugin class
315+
within it, and return an instance. Handles import failures gracefully and
316+
logs warnings for missing plugins or loading errors.
274317
"""
275-
for name in names:
276-
modname = f"{PLUGIN_NAMESPACE}.{name}"
318+
try:
277319
try:
278-
try:
279-
namespace = __import__(modname, None, None)
280-
except ImportError as exc:
281-
# Again, this is hacky:
282-
if exc.args[0].endswith(" " + name):
283-
log.warning("** plugin {0} not found", name)
284-
else:
285-
raise
286-
else:
287-
for obj in getattr(namespace, name).__dict__.values():
288-
if (
289-
inspect.isclass(obj)
290-
and not isinstance(
291-
obj, GenericAlias
292-
) # seems to be needed for python <= 3.9 only
293-
and issubclass(obj, BeetsPlugin)
294-
and obj != BeetsPlugin
295-
and not inspect.isabstract(obj)
296-
and obj not in _classes
297-
):
298-
_classes.add(obj)
299-
300-
except Exception:
301-
log.warning(
302-
"** error loading plugin {}:\n{}",
303-
name,
304-
traceback.format_exc(),
305-
)
320+
namespace = __import__(f"{PLUGIN_NAMESPACE}.{name}", None, None)
321+
except Exception as exc:
322+
raise PluginImportError(name) from exc
323+
324+
for obj in getattr(namespace, name).__dict__.values():
325+
if (
326+
inspect.isclass(obj)
327+
and not isinstance(
328+
obj, GenericAlias
329+
) # seems to be needed for python <= 3.9 only
330+
and issubclass(obj, BeetsPlugin)
331+
and obj != BeetsPlugin
332+
and not inspect.isabstract(obj)
333+
):
334+
return obj()
335+
336+
except Exception:
337+
log.warning("** error loading plugin {}", name, exc_info=True)
306338

339+
return None
307340

308-
_instances: dict[type[BeetsPlugin], BeetsPlugin] = {}
309341

342+
_instances: list[BeetsPlugin] = []
310343

311-
def find_plugins() -> list[BeetsPlugin]:
312-
"""Returns a list of BeetsPlugin subclass instances from all
313-
currently loaded beets plugins. Loads the default plugin set
314-
first.
344+
345+
def load_plugins(*args, **kwargs) -> None:
346+
"""Initialize the plugin system by loading all configured plugins.
347+
348+
Performs one-time plugin discovery and instantiation, storing loaded plugin
349+
instances globally. Emits a pluginload event after successful initialization
350+
to notify other components.
315351
"""
316-
if _instances:
317-
# After the first call, use cached instances for performance reasons.
318-
# See https://github.com/beetbox/beets/pull/3810
319-
return list(_instances.values())
320-
321-
load_plugins()
322-
plugins = []
323-
for cls in _classes:
324-
# Only instantiate each plugin class once.
325-
if cls not in _instances:
326-
_instances[cls] = cls()
327-
plugins.append(_instances[cls])
328-
return plugins
352+
if not _instances:
353+
names = get_plugin_names(*args, **kwargs)
354+
log.info("Loading plugins: {}", ", ".join(sorted(names)))
355+
_instances.extend(filter(None, map(_get_plugin, names)))
356+
357+
send("pluginload")
358+
359+
360+
def find_plugins() -> Iterable[BeetsPlugin]:
361+
return _instances
329362

330363

331364
# Communication with plugins.

beets/test/helper.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,11 @@ def teardown_beets(self):
481481
super().teardown_beets()
482482
self.unload_plugins()
483483

484+
def register_plugin(
485+
self, plugin_class: type[beets.plugins.BeetsPlugin]
486+
) -> None:
487+
beets.plugins._instances.append(plugin_class())
488+
484489
def load_plugins(self, *plugins: str) -> None:
485490
"""Load and initialize plugins by names.
486491
@@ -491,18 +496,15 @@ def load_plugins(self, *plugins: str) -> None:
491496
plugins = (self.plugin,) if hasattr(self, "plugin") else plugins
492497
self.config["plugins"] = plugins
493498
cached_classproperty.cache.clear()
494-
beets.plugins.load_plugins(plugins)
495-
beets.plugins.send("pluginload")
496-
beets.plugins.find_plugins()
499+
beets.plugins.load_plugins()
497500

498501
def unload_plugins(self) -> None:
499502
"""Unload all plugins and remove them from the configuration."""
500503
# FIXME this should eventually be handled by a plugin manager
501504
beets.plugins.BeetsPlugin.listeners.clear()
502505
beets.plugins.BeetsPlugin._raw_listeners.clear()
503506
self.config["plugins"] = []
504-
beets.plugins._classes = set()
505-
beets.plugins._instances = {}
507+
beets.plugins._instances.clear()
506508

507509
@contextmanager
508510
def configure_plugin(self, config: Any):

beets/ui/__init__.py

Lines changed: 14 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
import traceback
3131
import warnings
3232
from difflib import SequenceMatcher
33-
from typing import TYPE_CHECKING, Any, Callable
33+
from typing import Any, Callable
3434

3535
import confuse
3636

@@ -40,9 +40,6 @@
4040
from beets.util import as_string
4141
from beets.util.functemplate import template
4242

43-
if TYPE_CHECKING:
44-
from types import ModuleType
45-
4643
# On Windows platforms, use colorama to support "ANSI" terminal colors.
4744
if sys.platform == "win32":
4845
try:
@@ -1573,59 +1570,22 @@ def parse_subcommand(self, args):
15731570
# The main entry point and bootstrapping.
15741571

15751572

1576-
def _load_plugins(
1577-
options: optparse.Values, config: confuse.LazyConfig
1578-
) -> ModuleType:
1579-
"""Load the plugins specified on the command line or in the configuration."""
1580-
paths = config["pluginpath"].as_str_seq(split=False)
1581-
paths = [util.normpath(p) for p in paths]
1582-
log.debug("plugin paths: {0}", util.displayable_path(paths))
1583-
1584-
# On Python 3, the search paths need to be unicode.
1585-
paths = [os.fsdecode(p) for p in paths]
1586-
1587-
# Extend the `beetsplug` package to include the plugin paths.
1588-
import beetsplug
1589-
1590-
beetsplug.__path__ = paths + list(beetsplug.__path__)
1591-
1592-
# For backwards compatibility, also support plugin paths that
1593-
# *contain* a `beetsplug` package.
1594-
sys.path += paths
1595-
1596-
# If we were given any plugins on the command line, use those.
1597-
if options.plugins is not None:
1598-
plugin_list = (
1599-
options.plugins.split(",") if len(options.plugins) > 0 else []
1600-
)
1601-
else:
1602-
plugin_list = config["plugins"].as_str_seq()
1603-
# TODO: Remove in v2.4 or v3
1604-
if "musicbrainz" in config and config["musicbrainz"].get().get(
1605-
"enabled"
1606-
):
1607-
plugin_list.append("musicbrainz")
1608-
1609-
# Exclude any plugins that were specified on the command line
1610-
if options.exclude is not None:
1611-
plugin_list = [
1612-
p for p in plugin_list if p not in options.exclude.split(",")
1613-
]
1614-
1615-
plugins.load_plugins(plugin_list)
1616-
return plugins
1617-
1618-
1619-
def _setup(options, lib=None):
1573+
def _setup(
1574+
options: optparse.Values, lib: library.Library | None
1575+
) -> tuple[list[Subcommand], library.Library]:
16201576
"""Prepare and global state and updates it with command line options.
16211577
16221578
Returns a list of subcommands, a list of plugins, and a library instance.
16231579
"""
16241580
config = _configure(options)
16251581

1626-
plugins = _load_plugins(options, config)
1582+
def _parse_list(option: str | None) -> set[str]:
1583+
return set((option or "").split(",")) - {""}
16271584

1628-
plugins.send("pluginload")
1585+
plugins.load_plugins(
1586+
include=_parse_list(options.plugins),
1587+
exclude=_parse_list(options.exclude),
1588+
)
16291589

16301590
# Get the default subcommands.
16311591
from beets.ui.commands import default_commands
@@ -1637,7 +1597,7 @@ def _setup(options, lib=None):
16371597
lib = _open_library(config)
16381598
plugins.send("library_opened", lib=lib)
16391599

1640-
return subcommands, plugins, lib
1600+
return subcommands, lib
16411601

16421602

16431603
def _configure(options):
@@ -1691,7 +1651,7 @@ def _ensure_db_directory_exists(path):
16911651
os.makedirs(newpath)
16921652

16931653

1694-
def _open_library(config):
1654+
def _open_library(config: confuse.LazyConfig) -> library.Library:
16951655
"""Create a new library instance from the configuration."""
16961656
dbpath = util.bytestring_path(config["library"].as_filename())
16971657
_ensure_db_directory_exists(dbpath)
@@ -1718,7 +1678,7 @@ def _open_library(config):
17181678
return lib
17191679

17201680

1721-
def _raw_main(args, lib=None):
1681+
def _raw_main(args: list[str], lib=None) -> None:
17221682
"""A helper function for `main` without top-level exception
17231683
handling.
17241684
"""
@@ -1785,7 +1745,7 @@ def _raw_main(args, lib=None):
17851745
return config_edit()
17861746

17871747
test_lib = bool(lib)
1788-
subcommands, plugins, lib = _setup(options, lib)
1748+
subcommands, lib = _setup(options, lib)
17891749
parser.add_subcommand(*subcommands)
17901750

17911751
subcommand, suboptions, subargs = parser.parse_subcommand(subargs)

docs/changelog.rst

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,11 @@ For plugin developers:
9292

9393
Old imports are now deprecated and will be removed in version ``3.0.0``.
9494
* ``beets.ui.decargs`` is deprecated and will be removed in version ``3.0.0``.
95-
* Beets is now pep 561 compliant, which means that it provides type hints
95+
* Beets is now PEP 561 compliant, which means that it provides type hints
9696
for all public APIs. This allows IDEs to provide better autocompletion and
9797
type checking for downstream users of the beets API.
98-
98+
* ``plugins.find_plugins`` function does not anymore load plugins. You need to
99+
explicitly call ``plugins.load_plugins()`` to load them.
99100

100101
Other changes:
101102

0 commit comments

Comments
 (0)