Skip to content

Commit 52cf741

Browse files
authored
Simplify plugin loading (#5887)
This PR centralises plugin loading logic inside `beets.plugins` module. - Removed intermediatery `_classes` variable by initialising plugins immediately. - Simplified listeners registration by defining listener variables in the base `BeetsPlugin` class, and making the `register_listener` method a `@classmethod`. - Simplified plugin test setup accordingly.
2 parents c2d1bc3 + 2059a3a commit 52cf741

File tree

11 files changed

+271
-356
lines changed

11 files changed

+271
-356
lines changed

.github/workflows/ci.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ jobs:
6464
poe docs
6565
poe test-with-coverage
6666
67+
- if: ${{ !cancelled() }}
68+
name: Upload test results to Codecov
69+
uses: codecov/test-results-action@v1
70+
with:
71+
token: ${{ secrets.CODECOV_TOKEN }}
72+
6773
- if: ${{ env.IS_MAIN_PYTHON == 'true' }}
6874
name: Store the coverage report
6975
uses: actions/upload-artifact@v4
@@ -86,7 +92,7 @@ jobs:
8692
name: coverage-report
8793

8894
- name: Upload code coverage
89-
uses: codecov/codecov-action@v4
95+
uses: codecov/codecov-action@v5
9096
with:
9197
files: ./coverage.xml
9298
use_oidc: ${{ !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork) }}

beets/event_types.py

Lines changed: 0 additions & 33 deletions
This file was deleted.

beets/plugins.py

Lines changed: 148 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -19,24 +19,22 @@
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
26-
from typing import TYPE_CHECKING, Any, Callable, Sequence, TypeVar
27+
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar
2728

2829
import mediafile
2930
from typing_extensions import ParamSpec
3031

3132
import beets
3233
from beets import logging
34+
from beets.util import unique_list
3335

3436
if TYPE_CHECKING:
35-
from beets.event_types import EventType
36-
37-
38-
if TYPE_CHECKING:
39-
from collections.abc import Iterable
37+
from collections.abc import Callable, Iterable, Sequence
4038

4139
from confuse import ConfigView
4240

@@ -58,7 +56,7 @@
5856

5957
P = ParamSpec("P")
6058
Ret = TypeVar("Ret", bound=Any)
61-
Listener = Callable[..., None]
59+
Listener = Callable[..., Any]
6260
IterF = Callable[P, Iterable[Ret]]
6361

6462

@@ -67,6 +65,37 @@
6765
# Plugins using the Last.fm API can share the same API key.
6866
LASTFM_KEY = "2dc3914abf35f0d9c92d97d8f8e42b43"
6967

68+
EventType = Literal[
69+
"after_write",
70+
"album_imported",
71+
"album_removed",
72+
"albuminfo_received",
73+
"before_choose_candidate",
74+
"before_item_moved",
75+
"cli_exit",
76+
"database_change",
77+
"import",
78+
"import_begin",
79+
"import_task_apply",
80+
"import_task_before_choice",
81+
"import_task_choice",
82+
"import_task_created",
83+
"import_task_files",
84+
"import_task_start",
85+
"item_copied",
86+
"item_hardlinked",
87+
"item_imported",
88+
"item_linked",
89+
"item_moved",
90+
"item_reflinked",
91+
"item_removed",
92+
"library_opened",
93+
"mb_album_extract",
94+
"mb_track_extract",
95+
"pluginload",
96+
"trackinfo_received",
97+
"write",
98+
]
7099
# Global logger.
71100
log = logging.getLogger("beets")
72101

@@ -79,6 +108,17 @@ class PluginConflictError(Exception):
79108
"""
80109

81110

111+
class PluginImportError(ImportError):
112+
"""Indicates that a plugin could not be imported.
113+
114+
This is a subclass of ImportError so that it can be caught separately
115+
from other errors.
116+
"""
117+
118+
def __init__(self, name: str):
119+
super().__init__(f"Could not import plugin {name}")
120+
121+
82122
class PluginLogFilter(logging.Filter):
83123
"""A logging filter that identifies the plugin that emitted a log
84124
message.
@@ -105,6 +145,14 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
105145
the abstract methods defined here.
106146
"""
107147

148+
_raw_listeners: ClassVar[dict[EventType, list[Listener]]] = defaultdict(
149+
list
150+
)
151+
listeners: ClassVar[dict[EventType, list[Listener]]] = defaultdict(list)
152+
template_funcs: TFuncMap[str] | None = None
153+
template_fields: TFuncMap[Item] | None = None
154+
album_template_fields: TFuncMap[Album] | None = None
155+
108156
name: str
109157
config: ConfigView
110158
early_import_stages: list[ImportStageFunc]
@@ -218,25 +266,13 @@ def add_media_field(
218266
mediafile.MediaFile.add_field(name, descriptor)
219267
library.Item._media_fields.add(name)
220268

221-
_raw_listeners: dict[str, list[Listener]] | None = None
222-
listeners: dict[str, list[Listener]] | None = None
223-
224-
def register_listener(self, event: "EventType", func: Listener):
269+
def register_listener(self, event: EventType, func: Listener) -> None:
225270
"""Add a function as a listener for the specified event."""
226-
wrapped_func = self._set_log_level_and_params(logging.WARNING, func)
227-
228-
cls = self.__class__
229-
230-
if cls.listeners is None or cls._raw_listeners is None:
231-
cls._raw_listeners = defaultdict(list)
232-
cls.listeners = defaultdict(list)
233-
if func not in cls._raw_listeners[event]:
234-
cls._raw_listeners[event].append(func)
235-
cls.listeners[event].append(wrapped_func)
236-
237-
template_funcs: TFuncMap[str] | None = None
238-
template_fields: TFuncMap[Item] | None = None
239-
album_template_fields: TFuncMap[Album] | None = None
271+
if func not in self._raw_listeners[event]:
272+
self._raw_listeners[event].append(func)
273+
self.listeners[event].append(
274+
self._set_log_level_and_params(logging.WARNING, func)
275+
)
240276

241277
@classmethod
242278
def template_func(cls, name: str) -> Callable[[TFunc[str]], TFunc[str]]:
@@ -270,69 +306,92 @@ def helper(func: TFunc[Item]) -> TFunc[Item]:
270306
return helper
271307

272308

273-
_classes: set[type[BeetsPlugin]] = set()
309+
def get_plugin_names() -> list[str]:
310+
"""Discover and return the set of plugin names to be loaded.
274311
275-
276-
def load_plugins(names: Sequence[str] = ()) -> None:
277-
"""Imports the modules for a sequence of plugin names. Each name
278-
must be the name of a Python module under the "beetsplug" namespace
279-
package in sys.path; the module indicated should contain the
280-
BeetsPlugin subclasses desired.
312+
Configures the plugin search paths and resolves the final set of plugins
313+
based on configuration settings, inclusion filters, and exclusion rules.
314+
Automatically includes the musicbrainz plugin when enabled in configuration.
315+
"""
316+
paths = [
317+
str(Path(p).expanduser().absolute())
318+
for p in beets.config["pluginpath"].as_str_seq(split=False)
319+
]
320+
log.debug("plugin paths: {}", paths)
321+
322+
# Extend the `beetsplug` package to include the plugin paths.
323+
import beetsplug
324+
325+
beetsplug.__path__ = paths + list(beetsplug.__path__)
326+
327+
# For backwards compatibility, also support plugin paths that
328+
# *contain* a `beetsplug` package.
329+
sys.path += paths
330+
plugins = unique_list(beets.config["plugins"].as_str_seq())
331+
# TODO: Remove in v3.0.0
332+
if (
333+
"musicbrainz" not in plugins
334+
and "musicbrainz" in beets.config
335+
and beets.config["musicbrainz"].get().get("enabled")
336+
):
337+
plugins.append("musicbrainz")
338+
339+
beets.config.add({"disabled_plugins": []})
340+
disabled_plugins = set(beets.config["disabled_plugins"].as_str_seq())
341+
return [p for p in plugins if p not in disabled_plugins]
342+
343+
344+
def _get_plugin(name: str) -> BeetsPlugin | None:
345+
"""Dynamically load and instantiate a plugin class by name.
346+
347+
Attempts to import the plugin module, locate the appropriate plugin class
348+
within it, and return an instance. Handles import failures gracefully and
349+
logs warnings for missing plugins or loading errors.
281350
"""
282-
for name in names:
283-
modname = f"{PLUGIN_NAMESPACE}.{name}"
351+
try:
284352
try:
285-
try:
286-
namespace = __import__(modname, None, None)
287-
except ImportError as exc:
288-
# Again, this is hacky:
289-
if exc.args[0].endswith(" " + name):
290-
log.warning("** plugin {0} not found", name)
291-
else:
292-
raise
293-
else:
294-
for obj in getattr(namespace, name).__dict__.values():
295-
if (
296-
inspect.isclass(obj)
297-
and not isinstance(
298-
obj, GenericAlias
299-
) # seems to be needed for python <= 3.9 only
300-
and issubclass(obj, BeetsPlugin)
301-
and obj != BeetsPlugin
302-
and not inspect.isabstract(obj)
303-
and obj not in _classes
304-
):
305-
_classes.add(obj)
306-
307-
except Exception:
308-
log.warning(
309-
"** error loading plugin {}:\n{}",
310-
name,
311-
traceback.format_exc(),
312-
)
353+
namespace = __import__(f"{PLUGIN_NAMESPACE}.{name}", None, None)
354+
except Exception as exc:
355+
raise PluginImportError(name) from exc
356+
357+
for obj in getattr(namespace, name).__dict__.values():
358+
if (
359+
inspect.isclass(obj)
360+
and not isinstance(
361+
obj, GenericAlias
362+
) # seems to be needed for python <= 3.9 only
363+
and issubclass(obj, BeetsPlugin)
364+
and obj != BeetsPlugin
365+
and not inspect.isabstract(obj)
366+
):
367+
return obj()
368+
369+
except Exception:
370+
log.warning("** error loading plugin {}", name, exc_info=True)
313371

372+
return None
314373

315-
_instances: dict[type[BeetsPlugin], BeetsPlugin] = {}
316374

375+
_instances: list[BeetsPlugin] = []
317376

318-
def find_plugins() -> list[BeetsPlugin]:
319-
"""Returns a list of BeetsPlugin subclass instances from all
320-
currently loaded beets plugins. Loads the default plugin set
321-
first.
377+
378+
def load_plugins() -> None:
379+
"""Initialize the plugin system by loading all configured plugins.
380+
381+
Performs one-time plugin discovery and instantiation, storing loaded plugin
382+
instances globally. Emits a pluginload event after successful initialization
383+
to notify other components.
322384
"""
323-
if _instances:
324-
# After the first call, use cached instances for performance reasons.
325-
# See https://github.com/beetbox/beets/pull/3810
326-
return list(_instances.values())
385+
if not _instances:
386+
names = get_plugin_names()
387+
log.info("Loading plugins: {}", ", ".join(sorted(names)))
388+
_instances.extend(filter(None, map(_get_plugin, names)))
389+
390+
send("pluginload")
391+
327392

328-
load_plugins()
329-
plugins = []
330-
for cls in _classes:
331-
# Only instantiate each plugin class once.
332-
if cls not in _instances:
333-
_instances[cls] = cls()
334-
plugins.append(_instances[cls])
335-
return plugins
393+
def find_plugins() -> Iterable[BeetsPlugin]:
394+
return _instances
336395

337396

338397
# Communication with plugins.
@@ -383,7 +442,9 @@ def named_queries(model_cls: type[AnyModel]) -> dict[str, FieldQueryType]:
383442
}
384443

385444

386-
def notify_info_yielded(event: str) -> Callable[[IterF[P, Ret]], IterF[P, Ret]]:
445+
def notify_info_yielded(
446+
event: EventType,
447+
) -> Callable[[IterF[P, Ret]], IterF[P, Ret]]:
387448
"""Makes a generator send the event 'event' every time it yields.
388449
This decorator is supposed to decorate a generator, but any function
389450
returning an iterable should work.
@@ -474,19 +535,7 @@ def album_field_getters() -> TFuncMap[Album]:
474535
# Event dispatch.
475536

476537

477-
def event_handlers() -> dict[str, list[Listener]]:
478-
"""Find all event handlers from plugins as a dictionary mapping
479-
event names to sequences of callables.
480-
"""
481-
all_handlers: dict[str, list[Listener]] = defaultdict(list)
482-
for plugin in find_plugins():
483-
if plugin.listeners:
484-
for event, handlers in plugin.listeners.items():
485-
all_handlers[event] += handlers
486-
return all_handlers
487-
488-
489-
def send(event: str, **arguments: Any) -> list[Any]:
538+
def send(event: EventType, **arguments: Any) -> list[Any]:
490539
"""Send an event to all assigned event listeners.
491540
492541
`event` is the name of the event to send, all other named arguments
@@ -495,12 +544,11 @@ def send(event: str, **arguments: Any) -> list[Any]:
495544
Return a list of non-None values returned from the handlers.
496545
"""
497546
log.debug("Sending event: {0}", event)
498-
results: list[Any] = []
499-
for handler in event_handlers()[event]:
500-
result = handler(**arguments)
501-
if result is not None:
502-
results.append(result)
503-
return results
547+
return [
548+
r
549+
for handler in BeetsPlugin.listeners[event]
550+
if (r := handler(**arguments)) is not None
551+
]
504552

505553

506554
def feat_tokens(for_artist: bool = True) -> str:

0 commit comments

Comments
 (0)