19
19
import abc
20
20
import inspect
21
21
import re
22
- import traceback
22
+ import sys
23
23
from collections import defaultdict
24
24
from functools import wraps
25
+ from pathlib import Path
25
26
from types import GenericAlias
26
- from typing import TYPE_CHECKING , Any , Callable , Sequence , TypeVar
27
+ from typing import TYPE_CHECKING , Any , ClassVar , Literal , TypeVar
27
28
28
29
import mediafile
29
30
from typing_extensions import ParamSpec
30
31
31
32
import beets
32
33
from beets import logging
34
+ from beets .util import unique_list
33
35
34
36
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
40
38
41
39
from confuse import ConfigView
42
40
58
56
59
57
P = ParamSpec ("P" )
60
58
Ret = TypeVar ("Ret" , bound = Any )
61
- Listener = Callable [..., None ]
59
+ Listener = Callable [..., Any ]
62
60
IterF = Callable [P , Iterable [Ret ]]
63
61
64
62
67
65
# Plugins using the Last.fm API can share the same API key.
68
66
LASTFM_KEY = "2dc3914abf35f0d9c92d97d8f8e42b43"
69
67
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
+ ]
70
99
# Global logger.
71
100
log = logging .getLogger ("beets" )
72
101
@@ -79,6 +108,17 @@ class PluginConflictError(Exception):
79
108
"""
80
109
81
110
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
+
82
122
class PluginLogFilter (logging .Filter ):
83
123
"""A logging filter that identifies the plugin that emitted a log
84
124
message.
@@ -105,6 +145,14 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
105
145
the abstract methods defined here.
106
146
"""
107
147
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
+
108
156
name : str
109
157
config : ConfigView
110
158
early_import_stages : list [ImportStageFunc ]
@@ -218,25 +266,13 @@ def add_media_field(
218
266
mediafile .MediaFile .add_field (name , descriptor )
219
267
library .Item ._media_fields .add (name )
220
268
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 :
225
270
"""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
+ )
240
276
241
277
@classmethod
242
278
def template_func (cls , name : str ) -> Callable [[TFunc [str ]], TFunc [str ]]:
@@ -270,69 +306,92 @@ def helper(func: TFunc[Item]) -> TFunc[Item]:
270
306
return helper
271
307
272
308
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.
274
311
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.
281
350
"""
282
- for name in names :
283
- modname = f"{ PLUGIN_NAMESPACE } .{ name } "
351
+ try :
284
352
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 )
313
371
372
+ return None
314
373
315
- _instances : dict [type [BeetsPlugin ], BeetsPlugin ] = {}
316
374
375
+ _instances : list [BeetsPlugin ] = []
317
376
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.
322
384
"""
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
+
327
392
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
336
395
337
396
338
397
# Communication with plugins.
@@ -383,7 +442,9 @@ def named_queries(model_cls: type[AnyModel]) -> dict[str, FieldQueryType]:
383
442
}
384
443
385
444
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 ]]:
387
448
"""Makes a generator send the event 'event' every time it yields.
388
449
This decorator is supposed to decorate a generator, but any function
389
450
returning an iterable should work.
@@ -474,19 +535,7 @@ def album_field_getters() -> TFuncMap[Album]:
474
535
# Event dispatch.
475
536
476
537
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 ]:
490
539
"""Send an event to all assigned event listeners.
491
540
492
541
`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]:
495
544
Return a list of non-None values returned from the handlers.
496
545
"""
497
546
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
+ ]
504
552
505
553
506
554
def feat_tokens (for_artist : bool = True ) -> str :
0 commit comments