Slightly simplify listener registration

This commit is contained in:
Šarūnas Nejus 2025-07-19 15:28:23 +01:00
parent c2d1bc3aaf
commit 788e31b619
No known key found for this signature in database
GPG key ID: DD28F6704DBE3435
3 changed files with 32 additions and 61 deletions

View file

@ -23,7 +23,7 @@ import traceback
from collections import defaultdict from collections import defaultdict
from functools import wraps from functools import wraps
from types import GenericAlias from types import GenericAlias
from typing import TYPE_CHECKING, Any, Callable, Sequence, TypeVar from typing import TYPE_CHECKING, Any, ClassVar, TypeVar
import mediafile import mediafile
from typing_extensions import ParamSpec from typing_extensions import ParamSpec
@ -32,17 +32,14 @@ import beets
from beets import logging from beets import logging
if TYPE_CHECKING: if TYPE_CHECKING:
from beets.event_types import EventType from collections.abc import Callable, Iterable, Sequence
if TYPE_CHECKING:
from collections.abc import Iterable
from confuse import ConfigView from confuse import ConfigView
from beets.dbcore import Query from beets.dbcore import Query
from beets.dbcore.db import FieldQueryType from beets.dbcore.db import FieldQueryType
from beets.dbcore.types import Type from beets.dbcore.types import Type
from beets.event_types import EventType
from beets.importer import ImportSession, ImportTask from beets.importer import ImportSession, ImportTask
from beets.library import Album, Item, Library from beets.library import Album, Item, Library
from beets.ui import Subcommand from beets.ui import Subcommand
@ -58,7 +55,7 @@ if TYPE_CHECKING:
P = ParamSpec("P") P = ParamSpec("P")
Ret = TypeVar("Ret", bound=Any) Ret = TypeVar("Ret", bound=Any)
Listener = Callable[..., None] Listener = Callable[..., Any]
IterF = Callable[P, Iterable[Ret]] IterF = Callable[P, Iterable[Ret]]
@ -105,6 +102,14 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
the abstract methods defined here. the abstract methods defined here.
""" """
_raw_listeners: ClassVar[dict[EventType, list[Listener]]] = defaultdict(
list
)
listeners: ClassVar[dict[EventType, list[Listener]]] = defaultdict(list)
template_funcs: TFuncMap[str] | None = None
template_fields: TFuncMap[Item] | None = None
album_template_fields: TFuncMap[Album] | None = None
name: str name: str
config: ConfigView config: ConfigView
early_import_stages: list[ImportStageFunc] early_import_stages: list[ImportStageFunc]
@ -218,25 +223,13 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
mediafile.MediaFile.add_field(name, descriptor) mediafile.MediaFile.add_field(name, descriptor)
library.Item._media_fields.add(name) library.Item._media_fields.add(name)
_raw_listeners: dict[str, list[Listener]] | None = None def register_listener(self, event: EventType, func: Listener) -> None:
listeners: dict[str, list[Listener]] | None = None
def register_listener(self, event: "EventType", func: Listener):
"""Add a function as a listener for the specified event.""" """Add a function as a listener for the specified event."""
wrapped_func = self._set_log_level_and_params(logging.WARNING, func) if func not in self._raw_listeners[event]:
self._raw_listeners[event].append(func)
cls = self.__class__ self.listeners[event].append(
self._set_log_level_and_params(logging.WARNING, func)
if cls.listeners is None or cls._raw_listeners is None: )
cls._raw_listeners = defaultdict(list)
cls.listeners = defaultdict(list)
if func not in cls._raw_listeners[event]:
cls._raw_listeners[event].append(func)
cls.listeners[event].append(wrapped_func)
template_funcs: TFuncMap[str] | None = None
template_fields: TFuncMap[Item] | None = None
album_template_fields: TFuncMap[Album] | None = None
@classmethod @classmethod
def template_func(cls, name: str) -> Callable[[TFunc[str]], TFunc[str]]: def template_func(cls, name: str) -> Callable[[TFunc[str]], TFunc[str]]:
@ -383,7 +376,9 @@ def named_queries(model_cls: type[AnyModel]) -> dict[str, FieldQueryType]:
} }
def notify_info_yielded(event: str) -> Callable[[IterF[P, Ret]], IterF[P, Ret]]: def notify_info_yielded(
event: EventType,
) -> Callable[[IterF[P, Ret]], IterF[P, Ret]]:
"""Makes a generator send the event 'event' every time it yields. """Makes a generator send the event 'event' every time it yields.
This decorator is supposed to decorate a generator, but any function This decorator is supposed to decorate a generator, but any function
returning an iterable should work. returning an iterable should work.
@ -474,19 +469,7 @@ def album_field_getters() -> TFuncMap[Album]:
# Event dispatch. # Event dispatch.
def event_handlers() -> dict[str, list[Listener]]: def send(event: EventType, **arguments: Any) -> list[Any]:
"""Find all event handlers from plugins as a dictionary mapping
event names to sequences of callables.
"""
all_handlers: dict[str, list[Listener]] = defaultdict(list)
for plugin in find_plugins():
if plugin.listeners:
for event, handlers in plugin.listeners.items():
all_handlers[event] += handlers
return all_handlers
def send(event: str, **arguments: Any) -> list[Any]:
"""Send an event to all assigned event listeners. """Send an event to all assigned event listeners.
`event` is the name of the event to send, all other named arguments `event` is the name of the event to send, all other named arguments
@ -495,12 +478,11 @@ def send(event: str, **arguments: Any) -> list[Any]:
Return a list of non-None values returned from the handlers. Return a list of non-None values returned from the handlers.
""" """
log.debug("Sending event: {0}", event) log.debug("Sending event: {0}", event)
results: list[Any] = [] return [
for handler in event_handlers()[event]: r
result = handler(**arguments) for handler in BeetsPlugin.listeners[event]
if result is not None: if (r := handler(**arguments)) is not None
results.append(result) ]
return results
def feat_tokens(for_artist: bool = True) -> str: def feat_tokens(for_artist: bool = True) -> str:

View file

@ -498,8 +498,8 @@ class PluginMixin(ConfigMixin):
def unload_plugins(self) -> None: def unload_plugins(self) -> None:
"""Unload all plugins and remove them from the configuration.""" """Unload all plugins and remove them from the configuration."""
# FIXME this should eventually be handled by a plugin manager # FIXME this should eventually be handled by a plugin manager
for plugin_class in beets.plugins._instances: beets.plugins.BeetsPlugin.listeners.clear()
plugin_class.listeners = None beets.plugins.BeetsPlugin._raw_listeners.clear()
self.config["plugins"] = [] self.config["plugins"] = []
beets.plugins._classes = set() beets.plugins._classes = set()
beets.plugins._instances = {} beets.plugins._instances = {}

View file

@ -243,15 +243,7 @@ class ListenersTest(PluginLoaderTestCase):
d.register_listener("cli_exit", d2.dummy) d.register_listener("cli_exit", d2.dummy)
assert DummyPlugin._raw_listeners["cli_exit"] == [d.dummy, d2.dummy] assert DummyPlugin._raw_listeners["cli_exit"] == [d.dummy, d2.dummy]
@patch("beets.plugins.find_plugins") def test_events_called(self):
@patch("inspect.getfullargspec")
def test_events_called(self, mock_gfa, mock_find_plugins):
mock_gfa.return_value = Mock(
args=(),
varargs="args",
varkw="kwargs",
)
class DummyPlugin(plugins.BeetsPlugin): class DummyPlugin(plugins.BeetsPlugin):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@ -261,7 +253,6 @@ class ListenersTest(PluginLoaderTestCase):
self.register_listener("event_bar", self.bar) self.register_listener("event_bar", self.bar)
d = DummyPlugin() d = DummyPlugin()
mock_find_plugins.return_value = (d,)
plugins.send("event") plugins.send("event")
d.foo.assert_has_calls([]) d.foo.assert_has_calls([])
@ -271,8 +262,7 @@ class ListenersTest(PluginLoaderTestCase):
d.foo.assert_called_once_with(var="tagada") d.foo.assert_called_once_with(var="tagada")
d.bar.assert_has_calls([]) d.bar.assert_has_calls([])
@patch("beets.plugins.find_plugins") def test_listener_params(self):
def test_listener_params(self, mock_find_plugins):
class DummyPlugin(plugins.BeetsPlugin): class DummyPlugin(plugins.BeetsPlugin):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@ -316,8 +306,7 @@ class ListenersTest(PluginLoaderTestCase):
def dummy9(self, **kwargs): def dummy9(self, **kwargs):
assert kwargs == {"foo": 5} assert kwargs == {"foo": 5}
d = DummyPlugin() DummyPlugin()
mock_find_plugins.return_value = (d,)
plugins.send("event1", foo=5) plugins.send("event1", foo=5)
plugins.send("event2", foo=5) plugins.send("event2", foo=5)