diff --git a/beets/test/helper.py b/beets/test/helper.py index 85adc0825c..19ba97b7e0 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -28,11 +28,14 @@ from __future__ import annotations +import importlib import os import os.path +import pkgutil import shutil import subprocess import sys +import types import unittest from contextlib import contextmanager from dataclasses import dataclass @@ -44,6 +47,7 @@ from typing import Any, ClassVar from unittest.mock import patch +import pytest import responses from mediafile import Image, MediaFile @@ -53,6 +57,7 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.importer import ImportSession from beets.library import Item, Library +from beets.plugins import PLUGIN_NAMESPACE from beets.test import _common from beets.ui.commands import TerminalImportSession from beets.util import ( @@ -468,11 +473,78 @@ def setUp(self): self.i = _common.item(self.lib) -class PluginMixin(ConfigMixin): - plugin: ClassVar[str] +class PluginMixin(TestHelper): + """A mixing that handles plugin loading and unloading and registration. + + Usage: + ------ + Subclass this mixin and define a setup fixture + or call load/unload manually. + + .. code-block:: python + + class MyPluginTest(PluginMixin): + plugin = 'myplugin' + + # Using fixtures: + @pytest.fixture(autouse=True) + def setup(self): + self.setup_beets() + yield + self.teardown_beets() + + def test_something(self): + ... + + This will load the plugin named `myplugin` before each test and unload it + afterwards. This requires a `myplugin.py` file in the `beetsplug` namespace. + + If you need to register a plugin class directly, you can call + `register_plugin()` before `load_plugins()`. Or set the plugin_type class + variable to the plugin class you want to register. + + .. code-block:: python + + class MyPluginTest(PluginMixin): + plugin = 'myplugin' + plugin_type = MyPluginClass + + @pytest.fixture(autouse=True) + def setup(self): + self.setup_beets() + yield + self.teardown_beets() + + def test_something(self): + ... + + This will register `MyPluginClass` as `myplugin` in the `beetsplug` + namespace before loading it. This is useful if you want to test core plugin + functions. + + You can also manually call `register_plugin()`, `load_plugins()` and + `unload_plugins()` in your test methods if you need more control. + + .. code-block:: python + + class MyPluginTest(PluginMixin): + plugin = 'myplugin' + plugin_type = MyPluginClass + + def test_something(self): + self.register_plugin(self.plugin_type, self.plugin) + self.load_plugins(self.plugin) + ... + self.unload_plugins() + """ + + plugin: ClassVar[str | None] = None + plugin_type: ClassVar[type[beets.plugins.BeetsPlugin] | None] = None preload_plugin: ClassVar[bool] = True def setup_beets(self): + if self.plugin_type is not None: + self.register_plugin(self.plugin_type, self.plugin) super().setup_beets() if self.preload_plugin: self.load_plugins() @@ -482,9 +554,49 @@ def teardown_beets(self): self.unload_plugins() def register_plugin( - self, plugin_class: type[beets.plugins.BeetsPlugin] + self, + plugin_class: type[beets.plugins.BeetsPlugin], + name: str | None = None, ) -> None: - beets.plugins._instances.append(plugin_class()) + """Register a plugin class in the `beetsplug` namespace.""" + name = ( + name + or self.plugin + or plugin_class.__name__.lower().replace("plugin", "") + ) + full_namespace = f"{PLUGIN_NAMESPACE}.{name}" + + # 1. Ensure the beetsplug package exists + if PLUGIN_NAMESPACE not in sys.modules: + beetsplug_pkg = types.ModuleType(PLUGIN_NAMESPACE) + beetsplug_pkg.__path__ = [] # This is crucial! + sys.modules[PLUGIN_NAMESPACE] = beetsplug_pkg + + # 2. Create the plugin module + module = types.ModuleType(full_namespace) + module.__file__ = f"<{full_namespace}>" + module.__package__ = PLUGIN_NAMESPACE + setattr(module, plugin_class.__name__, plugin_class) + + # 3. Register in sys.modules AND as attribute of parent package + sys.modules[full_namespace] = module + setattr(sys.modules[PLUGIN_NAMESPACE], name, module) + + def unregister_plugin(self, name: str | None = None) -> None: + """Unregister a plugin class in the `beetsplug` namespace.""" + name = name or self.plugin + if not name: + return + + full_namespace = f"{PLUGIN_NAMESPACE}.{name}" + + if full_namespace in sys.modules: + del sys.modules[full_namespace] + + if PLUGIN_NAMESPACE in sys.modules: + parent_pkg = sys.modules[PLUGIN_NAMESPACE] + if hasattr(parent_pkg, name): + delattr(parent_pkg, name) def load_plugins(self, *plugins: str) -> None: """Load and initialize plugins by names. @@ -493,8 +605,12 @@ def load_plugins(self, *plugins: str) -> None: sure you call ``unload_plugins()`` afterwards. """ # FIXME this should eventually be handled by a plugin manager - plugins = (self.plugin,) if hasattr(self, "plugin") else plugins - self.config["plugins"] = plugins + if plugins: + self.config["plugins"] = plugins + elif self.plugin: + self.config["plugins"] = (self.plugin,) + else: + self.config["plugins"] = tuple() cached_classproperty.cache.clear() beets.plugins.load_plugins() @@ -506,17 +622,77 @@ def unload_plugins(self) -> None: self.config["plugins"] = [] beets.plugins._instances.clear() + def get_plugin_instance( + self, name: str | None = None + ) -> beets.plugins.BeetsPlugin: + """Get the plugin instance for a registered and loaded plugin.""" + name = name or self.plugin + for plugin in beets.plugins._instances: + if plugin.name == name or ( + self.plugin_type and isinstance(plugin, self.plugin_type) + ): + return plugin + raise ValueError(f"No plugin found with name {name}") + + @contextmanager + def plugins( + self, *plugins: tuple[str, type[beets.plugins.BeetsPlugin]] | str + ): + """Context manager to register and load multiple plugins.""" + self.unload_plugins() + + names = [] + for plug in plugins: + if isinstance(plug, str): + names.append(plug) + else: + names.append(plug[0]) + self.register_plugin(plug[1], plug[0]) + self.load_plugins(*names) + + yield + + self.unload_plugins() + for name in names: + self.unregister_plugin(name) + @contextmanager def configure_plugin(self, config: Any): self.config[self.plugin].set(config) - self.load_plugins(self.plugin) + if self.plugin_type is not None: + self.register_plugin(self.plugin_type, self.plugin) + if self.plugin: + self.load_plugins(self.plugin) yield self.unload_plugins() + if self.plugin_type is not None: + self.unregister_plugin(self.plugin) + + +def get_available_plugins(): + """Get all available plugins in the beetsplug namespace.""" + namespace_pkg = importlib.import_module("beetsplug") + + return [ + m.name + for m in pkgutil.iter_modules(namespace_pkg.__path__) + if not m.name.startswith("_") + ] + + +class PluginTestCase(PluginMixin, TestHelper): + @pytest.fixture(autouse=True) + def _setup_teardown(self): + self.setup_beets() + yield + self.teardown_beets() + +class PluginUnitTestCase(PluginMixin, BeetsTestCase): + """DEPRECATED: Use PluginTestCase instead for new code using pytest!""" -class PluginTestCase(PluginMixin, BeetsTestCase): pass diff --git a/test/plugins/test_advancedrewrite.py b/test/plugins/test_advancedrewrite.py index d2be1fa6c9..c2727d20b0 100644 --- a/test/plugins/test_advancedrewrite.py +++ b/test/plugins/test_advancedrewrite.py @@ -16,13 +16,13 @@ import pytest -from beets.test.helper import PluginTestCase +from beets.test.helper import PluginUnitTestCase from beets.ui import UserError PLUGIN_NAME = "advancedrewrite" -class AdvancedRewritePluginTest(PluginTestCase): +class AdvancedRewritePluginTest(PluginUnitTestCase): plugin = "advancedrewrite" preload_plugin = False diff --git a/test/plugins/test_albumtypes.py b/test/plugins/test_albumtypes.py index 0a9d533490..38a686486c 100644 --- a/test/plugins/test_albumtypes.py +++ b/test/plugins/test_albumtypes.py @@ -16,12 +16,12 @@ from collections.abc import Sequence -from beets.test.helper import PluginTestCase +from beets.test.helper import PluginUnitTestCase from beetsplug.albumtypes import AlbumTypesPlugin from beetsplug.musicbrainz import VARIOUS_ARTISTS_ID -class AlbumTypesPluginTest(PluginTestCase): +class AlbumTypesPluginTest(PluginUnitTestCase): """Tests for albumtypes plugin.""" plugin = "albumtypes" diff --git a/test/plugins/test_bareasc.py b/test/plugins/test_bareasc.py index e699a3dcf0..aff6e3dfed 100644 --- a/test/plugins/test_bareasc.py +++ b/test/plugins/test_bareasc.py @@ -4,10 +4,10 @@ """Tests for the 'bareasc' plugin.""" from beets import logging -from beets.test.helper import PluginTestCase, capture_stdout +from beets.test.helper import PluginUnitTestCase, capture_stdout -class BareascPluginTest(PluginTestCase): +class BareascPluginTest(PluginUnitTestCase): """Test bare ASCII query matching.""" plugin = "bareasc" diff --git a/test/plugins/test_bpd.py b/test/plugins/test_bpd.py index 16e424d7e3..7dbc96f9bd 100644 --- a/test/plugins/test_bpd.py +++ b/test/plugins/test_bpd.py @@ -28,7 +28,7 @@ import pytest import yaml -from beets.test.helper import PluginTestCase +from beets.test.helper import PluginUnitTestCase from beets.util import bluelet bpd = pytest.importorskip("beetsplug.bpd") @@ -238,7 +238,7 @@ def listener_wrap(host, port): beets.ui.main(args) -class BPDTestHelper(PluginTestCase): +class BPDTestHelper(PluginUnitTestCase): db_on_disk = True plugin = "bpd" diff --git a/test/plugins/test_convert.py b/test/plugins/test_convert.py index 1452686a7d..128b9a9a04 100644 --- a/test/plugins/test_convert.py +++ b/test/plugins/test_convert.py @@ -29,7 +29,7 @@ from beets.test.helper import ( AsIsImporterMixin, ImportHelper, - PluginTestCase, + PluginUnitTestCase, capture_log, control_stdin, ) @@ -63,7 +63,7 @@ def file_endswith(self, path: Path, tag: str): return path.read_bytes().endswith(tag.encode("utf-8")) -class ConvertTestCase(ConvertMixin, PluginTestCase): +class ConvertTestCase(ConvertMixin, PluginUnitTestCase): db_on_disk = True plugin = "convert" diff --git a/test/plugins/test_embyupdate.py b/test/plugins/test_embyupdate.py index 9c7104371b..7870596032 100644 --- a/test/plugins/test_embyupdate.py +++ b/test/plugins/test_embyupdate.py @@ -1,10 +1,10 @@ import responses -from beets.test.helper import PluginTestCase +from beets.test.helper import PluginUnitTestCase from beetsplug import embyupdate -class EmbyUpdateTest(PluginTestCase): +class EmbyUpdateTest(PluginUnitTestCase): plugin = "embyupdate" def setUp(self): diff --git a/test/plugins/test_export.py b/test/plugins/test_export.py index f37a0d2a79..1a4cf3f194 100644 --- a/test/plugins/test_export.py +++ b/test/plugins/test_export.py @@ -19,10 +19,10 @@ from xml.etree import ElementTree from xml.etree.ElementTree import Element -from beets.test.helper import PluginTestCase +from beets.test.helper import PluginUnitTestCase -class ExportPluginTest(PluginTestCase): +class ExportPluginTest(PluginUnitTestCase): plugin = "export" def setUp(self): diff --git a/test/plugins/test_fetchart.py b/test/plugins/test_fetchart.py index 853820d92d..5a0304939e 100644 --- a/test/plugins/test_fetchart.py +++ b/test/plugins/test_fetchart.py @@ -18,10 +18,10 @@ import sys from beets import util -from beets.test.helper import PluginTestCase +from beets.test.helper import PluginUnitTestCase -class FetchartCliTest(PluginTestCase): +class FetchartCliTest(PluginUnitTestCase): plugin = "fetchart" def setUp(self): diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index 572431b45b..ec6bc636bc 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -16,11 +16,11 @@ import unittest -from beets.test.helper import PluginTestCase +from beets.test.helper import PluginUnitTestCase from beetsplug import ftintitle -class FtInTitlePluginFunctional(PluginTestCase): +class FtInTitlePluginFunctional(PluginUnitTestCase): plugin = "ftintitle" def _ft_add_item(self, path, artist, title, aartist): diff --git a/test/plugins/test_hook.py b/test/plugins/test_hook.py index 3392d68817..0f9d59a862 100644 --- a/test/plugins/test_hook.py +++ b/test/plugins/test_hook.py @@ -22,13 +22,13 @@ from typing import TYPE_CHECKING, Callable from beets import plugins -from beets.test.helper import PluginTestCase, capture_log +from beets.test.helper import PluginUnitTestCase, capture_log if TYPE_CHECKING: from collections.abc import Iterator -class HookTestCase(PluginTestCase): +class HookTestCase(PluginUnitTestCase): plugin = "hook" preload_plugin = False diff --git a/test/plugins/test_info.py b/test/plugins/test_info.py index c1b3fc941a..d0520224a1 100644 --- a/test/plugins/test_info.py +++ b/test/plugins/test_info.py @@ -15,11 +15,11 @@ from mediafile import MediaFile -from beets.test.helper import PluginTestCase +from beets.test.helper import PluginUnitTestCase from beets.util import displayable_path -class InfoTest(PluginTestCase): +class InfoTest(PluginUnitTestCase): plugin = "info" def test_path(self): diff --git a/test/plugins/test_ipfs.py b/test/plugins/test_ipfs.py index b94bd551bf..f28fa2a5eb 100644 --- a/test/plugins/test_ipfs.py +++ b/test/plugins/test_ipfs.py @@ -16,13 +16,13 @@ from unittest.mock import Mock, patch from beets.test import _common -from beets.test.helper import PluginTestCase +from beets.test.helper import PluginUnitTestCase from beets.util import bytestring_path from beetsplug.ipfs import IPFSPlugin @patch("beets.util.command_output", Mock()) -class IPFSPluginTest(PluginTestCase): +class IPFSPluginTest(PluginUnitTestCase): plugin = "ipfs" def test_stored_hashes(self): diff --git a/test/plugins/test_limit.py b/test/plugins/test_limit.py index d77e47ca84..9fa12bec73 100644 --- a/test/plugins/test_limit.py +++ b/test/plugins/test_limit.py @@ -13,10 +13,10 @@ """Tests for the 'limit' plugin.""" -from beets.test.helper import PluginTestCase +from beets.test.helper import PluginUnitTestCase -class LimitPluginTest(PluginTestCase): +class LimitPluginTest(PluginUnitTestCase): """Unit tests for LimitPlugin Note: query prefix tests do not work correctly with `run_with_output`. diff --git a/test/plugins/test_mbsync.py b/test/plugins/test_mbsync.py index bb88e5e631..cf651622db 100644 --- a/test/plugins/test_mbsync.py +++ b/test/plugins/test_mbsync.py @@ -16,10 +16,10 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.library import Item -from beets.test.helper import PluginTestCase, capture_log +from beets.test.helper import PluginUnitTestCase, capture_log -class MbsyncCliTest(PluginTestCase): +class MbsyncCliTest(PluginUnitTestCase): plugin = "mbsync" @patch( diff --git a/test/plugins/test_mpdstats.py b/test/plugins/test_mpdstats.py index 6f5d3f3cee..70e4f7932f 100644 --- a/test/plugins/test_mpdstats.py +++ b/test/plugins/test_mpdstats.py @@ -13,15 +13,15 @@ # included in all copies or substantial portions of the Software. -from unittest.mock import ANY, Mock, call, patch +from unittest.mock import ANY, Mock, call from beets import util from beets.library import Item from beets.test.helper import PluginTestCase -from beetsplug.mpdstats import MPDStats +from beetsplug.mpdstats import MPDClientWrapper, MPDStats -class MPDStatsTest(PluginTestCase): +class TestMPDStats(PluginTestCase): plugin = "mpdstats" def test_update_rating(self): @@ -53,24 +53,34 @@ def test_get_item(self): {"state": "play", "songid": 1, "time": "0:1"}, {"state": "stop"}, ] + EVENTS = [["player"]] * (len(STATUSES) - 1) + [KeyboardInterrupt] item_path = util.normpath("/foo/bar.flac") songid = 1 - @patch( - "beetsplug.mpdstats.MPDClientWrapper", - return_value=Mock( - **{ - "events.side_effect": EVENTS, - "status.side_effect": STATUSES, - "currentsong.return_value": (item_path, songid), - } - ), - ) - def test_run_mpdstats(self, mpd_mock): + def test_run_mpdstats(self, monkeypatch): item = Item(title="title", path=self.item_path, id=1) item.add(self.lib) + statuses = iter(self.STATUSES) + events = iter(self.EVENTS) + + def iter_event_or_raise(*args): + i = next(events) + if i is KeyboardInterrupt: + raise i + return i + + monkeypatch.setattr( + MPDClientWrapper, "status", lambda _: next(statuses) + ) + monkeypatch.setattr( + MPDClientWrapper, + "currentsong", + lambda x: (self.item_path, self.songid), + ) + monkeypatch.setattr(MPDClientWrapper, "events", iter_event_or_raise) + monkeypatch.setattr(MPDClientWrapper, "connect", lambda *_: None) log = Mock() try: MPDStats(self.lib, log).run() diff --git a/test/plugins/test_parentwork.py b/test/plugins/test_parentwork.py index 1abe257090..a378698d95 100644 --- a/test/plugins/test_parentwork.py +++ b/test/plugins/test_parentwork.py @@ -19,7 +19,7 @@ import pytest from beets.library import Item -from beets.test.helper import PluginTestCase +from beets.test.helper import PluginUnitTestCase from beetsplug import parentwork work = { @@ -85,7 +85,7 @@ def mock_workid_response(mbid, includes): @pytest.mark.integration_test -class ParentWorkIntegrationTest(PluginTestCase): +class ParentWorkIntegrationTest(PluginUnitTestCase): plugin = "parentwork" # test how it works with real musicbrainz data @@ -149,7 +149,7 @@ def test_direct_parent_work_real(self): ) -class ParentWorkTest(PluginTestCase): +class ParentWorkTest(PluginUnitTestCase): plugin = "parentwork" def setUp(self): diff --git a/test/plugins/test_play.py b/test/plugins/test_play.py index 293a50a20c..57f162b6ea 100644 --- a/test/plugins/test_play.py +++ b/test/plugins/test_play.py @@ -21,14 +21,18 @@ import pytest -from beets.test.helper import CleanupModulesMixin, PluginTestCase, control_stdin +from beets.test.helper import ( + CleanupModulesMixin, + PluginUnitTestCase, + control_stdin, +) from beets.ui import UserError from beets.util import open_anything from beetsplug.play import PlayPlugin @patch("beetsplug.play.util.interactive_open") -class PlayPluginTest(CleanupModulesMixin, PluginTestCase): +class PlayPluginTest(CleanupModulesMixin, PluginUnitTestCase): modules = (PlayPlugin.__module__,) plugin = "play" diff --git a/test/plugins/test_playlist.py b/test/plugins/test_playlist.py index a8c145696a..13431bce33 100644 --- a/test/plugins/test_playlist.py +++ b/test/plugins/test_playlist.py @@ -18,10 +18,10 @@ import beets from beets.test import _common -from beets.test.helper import PluginTestCase +from beets.test.helper import PluginUnitTestCase -class PlaylistTestCase(PluginTestCase): +class PlaylistTestCase(PluginUnitTestCase): plugin = "playlist" preload_plugin = False diff --git a/test/plugins/test_plexupdate.py b/test/plugins/test_plexupdate.py index ab53d8c2e7..4cedb04801 100644 --- a/test/plugins/test_plexupdate.py +++ b/test/plugins/test_plexupdate.py @@ -1,10 +1,10 @@ import responses -from beets.test.helper import PluginTestCase +from beets.test.helper import PluginUnitTestCase from beetsplug.plexupdate import get_music_section, update_plex -class PlexUpdateTest(PluginTestCase): +class PlexUpdateTest(PluginUnitTestCase): plugin = "plexupdate" def add_response_get_music_section(self, section_name="Music"): diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py index d3569d8362..7d4f3474eb 100644 --- a/test/plugins/test_smartplaylist.py +++ b/test/plugins/test_smartplaylist.py @@ -25,7 +25,7 @@ from beets.dbcore import OrQuery from beets.dbcore.query import FixedFieldSort, MultipleSort, NullSort from beets.library import Album, Item, parse_query_string -from beets.test.helper import BeetsTestCase, PluginTestCase +from beets.test.helper import BeetsTestCase, PluginUnitTestCase from beets.ui import UserError from beets.util import CHAR_REPLACE, syspath from beetsplug.smartplaylist import SmartPlaylistPlugin @@ -328,7 +328,7 @@ def test_playlist_update_uri_format(self): assert content == b"http://beets:8337/item/3/file\n" -class SmartPlaylistCLITest(PluginTestCase): +class SmartPlaylistCLITest(PluginUnitTestCase): plugin = "smartplaylist" def setUp(self): diff --git a/test/plugins/test_spotify.py b/test/plugins/test_spotify.py index 86b5651b95..dc05567aeb 100644 --- a/test/plugins/test_spotify.py +++ b/test/plugins/test_spotify.py @@ -7,7 +7,7 @@ from beets.library import Item from beets.test import _common -from beets.test.helper import PluginTestCase +from beets.test.helper import PluginUnitTestCase from beetsplug import spotify @@ -23,7 +23,7 @@ def _params(url): return parse_qs(urlparse(url).query) -class SpotifyPluginTest(PluginTestCase): +class SpotifyPluginTest(PluginUnitTestCase): plugin = "spotify" @responses.activate diff --git a/test/plugins/test_substitute.py b/test/plugins/test_substitute.py index fc3789c0b7..b5f9f1ab7a 100644 --- a/test/plugins/test_substitute.py +++ b/test/plugins/test_substitute.py @@ -14,11 +14,11 @@ """Test the substitute plugin regex functionality.""" -from beets.test.helper import PluginTestCase +from beets.test.helper import PluginUnitTestCase from beetsplug.substitute import Substitute -class SubstitutePluginTest(PluginTestCase): +class SubstitutePluginTest(PluginUnitTestCase): plugin = "substitute" preload_plugin = False diff --git a/test/plugins/test_types_plugin.py b/test/plugins/test_types_plugin.py index 41807b80d3..4e857292cd 100644 --- a/test/plugins/test_types_plugin.py +++ b/test/plugins/test_types_plugin.py @@ -19,10 +19,10 @@ import pytest from confuse import ConfigValueError -from beets.test.helper import PluginTestCase +from beets.test.helper import PluginUnitTestCase -class TypesPluginTest(PluginTestCase): +class TypesPluginTest(PluginUnitTestCase): plugin = "types" def test_integer_modify_and_query(self): diff --git a/test/plugins/test_zero.py b/test/plugins/test_zero.py index 51913c8e05..44fbb5e1ed 100644 --- a/test/plugins/test_zero.py +++ b/test/plugins/test_zero.py @@ -3,12 +3,12 @@ from mediafile import MediaFile from beets.library import Item -from beets.test.helper import PluginTestCase, control_stdin +from beets.test.helper import PluginUnitTestCase, control_stdin from beets.util import syspath from beetsplug.zero import ZeroPlugin -class ZeroPluginTest(PluginTestCase): +class ZeroPluginTest(PluginUnitTestCase): plugin = "zero" preload_plugin = False diff --git a/test/test_metasync.py b/test/test_metasync.py index 13c003a1c3..2ff65877c2 100644 --- a/test/test_metasync.py +++ b/test/test_metasync.py @@ -20,7 +20,7 @@ from beets.library import Item from beets.test import _common -from beets.test.helper import PluginTestCase +from beets.test.helper import PluginUnitTestCase def _parsetime(s): @@ -31,7 +31,7 @@ def _is_windows(): return platform.system() == "Windows" -class MetaSyncTest(PluginTestCase): +class MetaSyncTest(PluginUnitTestCase): plugin = "metasync" itunes_library_unix = os.path.join(_common.RSRC, b"itunes_library_unix.xml") itunes_library_windows = os.path.join( diff --git a/test/test_plugins.py b/test/test_plugins.py index df338f9243..1b0f7b4a82 100644 --- a/test/test_plugins.py +++ b/test/test_plugins.py @@ -12,27 +12,23 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. +from __future__ import annotations import importlib -import itertools import logging import os import pkgutil import sys -from unittest.mock import ANY, Mock, patch +from typing import Any +from unittest.mock import ANY, patch import pytest from mediafile import MediaFile from beets import config, plugins, ui from beets.dbcore import types -from beets.importer import ( - Action, - ArchiveImportTask, - SentinelImportTask, - SingletonImportTask, -) -from beets.library import Item +from beets.importer import Action +from beets.library import Album, Item from beets.test import helper from beets.test.helper import ( AutotagStub, @@ -41,232 +37,228 @@ PluginTestCase, TerminalImportMixin, ) -from beets.util import displayable_path, syspath +from beets.util import syspath class TestPluginRegistration(PluginTestCase): - class RatingPlugin(plugins.BeetsPlugin): - item_types = { - "rating": types.Float(), - "multi_value": types.MULTI_VALUE_DSV, - } - - def __init__(self): - super().__init__() - self.register_listener("write", self.on_write) - - @staticmethod - def on_write(item=None, path=None, tags=None): - if tags["artist"] == "XXX": - tags["artist"] = "YYY" - - def setUp(self): - super().setUp() + """Ensure that we can dynamically add a plugin without creating + actual files on disk. - self.register_plugin(self.RatingPlugin) + This is a meta test that ensures that our dynamic registration + mechanism works as intended. - def test_field_type_registered(self): - assert isinstance(Item._types.get("rating"), types.Float) - - def test_duplicate_type(self): - class DuplicateTypePlugin(plugins.BeetsPlugin): - item_types = {"rating": types.INTEGER} + TODO: Add a test for template functions, template fields and album template fields + """ - self.register_plugin(DuplicateTypePlugin) - with pytest.raises( - plugins.PluginConflictError, match="already been defined" - ): - Item._types + class DummyPlugin(plugins.BeetsPlugin): + item_types = { + "foo": types.Float(), + "bar": types.MULTI_VALUE_DSV, + } + album_types = { + "baz": types.INTEGER, + } - def test_listener_registered(self): - self.RatingPlugin() - item = self.add_item_fixture(artist="XXX") + plugin = "dummy" + plugin_type = DummyPlugin - item.write() + def test_get_plugin(self): + """Test that get_plugin returns the correct plugin class.""" + plugin = plugins._get_plugin(self.plugin) + assert plugin is not None + assert isinstance(plugin, self.DummyPlugin) - assert MediaFile(syspath(item.path)).artist == "YYY" + def test_field_type_registered(self): + """Test that the field types are registered on the Item class.""" + assert isinstance(Item._types.get("foo"), types.Float) + assert Item._types.get("bar") is types.MULTI_VALUE_DSV + assert Album._types.get("baz") is types.INTEGER def test_multi_value_flex_field_type(self): item = Item(path="apath", artist="aaa") - item.multi_value = ["one", "two", "three"] + item.bar = ["one", "two", "three"] item.add(self.lib) - out = self.run_with_output("ls", "-f", "$multi_value") + out = self.run_with_output("ls", "-f", "$bar") delimiter = types.MULTI_VALUE_DSV.delimiter assert out == f"one{delimiter}two{delimiter}three\n" - -class PluginImportTestCase(ImportHelper, PluginTestCase): - def setUp(self): - super().setUp() - self.prepare_album_for_import(2) + def test_duplicate_field_typ(self): + """Test that if another plugin tries to register the same type, + a PluginConflictError is raised. + """ + + class DuplicateDummyPlugin(plugins.BeetsPlugin): + album_types = {"baz": types.Float()} + + with ( + self.plugins( + ("dummy", self.DummyPlugin), ("duplicate", DuplicateDummyPlugin) + ), + pytest.raises( + plugins.PluginConflictError, match="already been defined" + ), + ): + Album._types -class EventsTest(PluginImportTestCase): - def test_import_task_created(self): - self.importer = self.setup_importer(pretend=True) +class TestPluginListeners(PluginTestCase, ImportHelper): + """Test that plugin listeners are registered and called correctly.""" - with helper.capture_log() as logs: - self.importer.run() + class DummyPlugin(plugins.BeetsPlugin): + records: list[Any] = [] - # Exactly one event should have been imported (for the album). - # Sentinels do not get emitted. - assert logs.count("Sending event: import_task_created") == 1 + def __init__(self): + super().__init__() + self.register_listener("cli_exit", self.on_cli_exit) + self.register_listener("write", self.on_write) + self.register_listener( + "import_task_created", self.on_import_task_created + ) - logs = [line for line in logs if not line.startswith("Sending event:")] - assert logs == [ - f"Album: {displayable_path(os.path.join(self.import_dir, b'album'))}", - f" {displayable_path(self.import_media[0].path)}", - f" {displayable_path(self.import_media[1].path)}", - ] + def on_cli_exit(self, **kwargs): + self.records.append(("cli_exit", kwargs)) - def test_import_task_created_with_plugin(self): - class ToSingletonPlugin(plugins.BeetsPlugin): - def __init__(self): - super().__init__() + def on_write( + self, item=None, path=None, tags: dict[Any, Any] | None = None + ): + self.records.append(("write", item, path, tags)) + if tags and tags["artist"] == "XXX": + tags["artist"] = "YYY" - self.register_listener( - "import_task_created", self.import_task_created_event - ) + def on_import_task_created(self, **kwargs): + self.records.append(("import_task_created", kwargs)) - def import_task_created_event(self, session, task): - if ( - isinstance(task, SingletonImportTask) - or isinstance(task, SentinelImportTask) - or isinstance(task, ArchiveImportTask) - ): - return task + plugin_type = DummyPlugin + plugin = "dummy" - new_tasks = [] - for item in task.items: - new_tasks.append(SingletonImportTask(task.toppath, item)) + @pytest.fixture(autouse=True) + def clear_records(self): + plug = self.get_plugin_instance() + assert isinstance(plug, self.DummyPlugin) + plug.records.clear() + + def get_records(self): + plug = self.get_plugin_instance() + assert isinstance(plug, self.DummyPlugin) + return plug.records + + @pytest.mark.parametrize( + "event", + [ + ("cli_exit"), + ("write"), + ("import_task_created"), + ], + ) + def test_on_cli_exit(self, event): + """Generic test for all events triggered vis plugins.send.""" + plugins.send(event) + records = self.get_records() + assert len(records) == 1 + assert records[0][0] == event + + def test_on_write(self): + # Additionally test that tags are modified correctly. + item = self.add_item_fixture(artist="XXX") + item.write() + assert MediaFile(syspath(item.path)).artist == "YYY" - return new_tasks + def test_on_import_task_created(self, caplog): + """Test that the import_task_created event is triggered + when an import task is created.""" - to_singleton_plugin = ToSingletonPlugin - self.register_plugin(to_singleton_plugin) + # Fixme: unittest ImportHelper in pytest setup + self.import_media = [] + self.prepare_album_for_import(2) self.importer = self.setup_importer(pretend=True) + self.importer.run() - with helper.capture_log() as logs: - self.importer.run() + assert self.get_records()[0][0] == "import_task_created" - # Exactly one event should have been imported (for the album). - # Sentinels do not get emitted. - assert logs.count("Sending event: import_task_created") == 1 - logs = [line for line in logs if not line.startswith("Sending event:")] - assert logs == [ - f"Singleton: {displayable_path(self.import_media[0].path)}", - f"Singleton: {displayable_path(self.import_media[1].path)}", - ] +class TestPluginListenersParams(PluginMixin): + """Test that plugin listeners are called with correct parameters. + Also check that invalid parameters raise TypeErrors. + """ -class ListenersTest(PluginTestCase): - def test_register(self): - class DummyPlugin(plugins.BeetsPlugin): - def __init__(self): - super().__init__() - self.register_listener("cli_exit", self.dummy) - self.register_listener("cli_exit", self.dummy) + def dummy1(self, foo): + assert foo == 5 - def dummy(self): - pass + def dummy2(self, foo=None): + assert foo == 5 - d = DummyPlugin() - assert DummyPlugin._raw_listeners["cli_exit"] == [d.dummy] + def dummy3(self): + # argument cut off + pass - d2 = DummyPlugin() - assert DummyPlugin._raw_listeners["cli_exit"] == [d.dummy, d2.dummy] + def dummy4(self, bar=None): + # argument cut off + pass - d.register_listener("cli_exit", d2.dummy) - assert DummyPlugin._raw_listeners["cli_exit"] == [d.dummy, d2.dummy] + def dummy5(self, bar): + assert not True - def test_events_called(self): - class DummyPlugin(plugins.BeetsPlugin): - def __init__(self): - super().__init__() - self.foo = Mock(__name__="foo") - self.register_listener("event_foo", self.foo) - self.bar = Mock(__name__="bar") - self.register_listener("event_bar", self.bar) + # more complex examples + + def dummy6(self, foo, bar=None): + assert foo == 5 + assert bar is None - d = DummyPlugin() + def dummy7(self, foo, **kwargs): + assert foo == 5 + assert kwargs == {} - plugins.send("event") - d.foo.assert_has_calls([]) - d.bar.assert_has_calls([]) + def dummy8(self, foo, bar, **kwargs): + assert not True - plugins.send("event_foo", var="tagada") - d.foo.assert_called_once_with(var="tagada") - d.bar.assert_has_calls([]) + def dummy9(self, **kwargs): + assert kwargs == {"foo": 5} + + @pytest.mark.parametrize( + "func, raises", + [ + ("dummy1", False), + ("dummy2", False), + ("dummy3", False), + ("dummy4", False), + ("dummy5", True), + ("dummy6", False), + ("dummy7", False), + ("dummy8", True), + ("dummy9", False), + ], + ) + def test_listener_params(self, func, raises): + func_obj = getattr(self, func) - def test_listener_params(self): class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() - for i in itertools.count(1): - try: - meth = getattr(self, f"dummy{i}") - except AttributeError: - break - self.register_listener(f"event{i}", meth) - - def dummy1(self, foo): - assert foo == 5 - - def dummy2(self, foo=None): - assert foo == 5 - - def dummy3(self): - # argument cut off - pass - - def dummy4(self, bar=None): - # argument cut off - pass - - def dummy5(self, bar): - assert not True - - # more complex examples - - def dummy6(self, foo, bar=None): - assert foo == 5 - assert bar is None - - def dummy7(self, foo, **kwargs): - assert foo == 5 - assert kwargs == {} - - def dummy8(self, foo, bar, **kwargs): - assert not True - - def dummy9(self, **kwargs): - assert kwargs == {"foo": 5} - - DummyPlugin() - - plugins.send("event1", foo=5) - plugins.send("event2", foo=5) - plugins.send("event3", foo=5) - plugins.send("event4", foo=5) - - with pytest.raises(TypeError): - plugins.send("event5", foo=5) - - plugins.send("event6", foo=5) - plugins.send("event7", foo=5) + self.register_listener("exit_cli", func_obj) - with pytest.raises(TypeError): - plugins.send("event8", foo=5) + with self.plugins(("dummy", DummyPlugin)): + if raises: + with pytest.raises(TypeError): + plugins.send("exit_cli", foo=5) + else: + plugins.send("exit_cli", foo=5) - plugins.send("event9", foo=5) +class PromptChoicesTest(TerminalImportMixin, ImportHelper, PluginMixin): + @pytest.fixture(autouse=True) + def setup_teardown(self): + # Run old unitest setup/teardown methods + self.setUp() + yield + self.tearDown() -class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): def setUp(self): super().setUp() + self.prepare_album_for_import(2) + self.setup_importer() self.matcher = AutotagStub(AutotagStub.IDENT).install() self.addCleanup(self.matcher.restore) @@ -296,25 +288,25 @@ def return_choices(self, session, task): ui.commands.PromptChoice("r", "baR", None), ] - self.register_plugin(DummyPlugin) - # Default options + extra choices by the plugin ('Foo', 'Bar') - opts = ( - "Apply", - "More candidates", - "Skip", - "Use as-is", - "as Tracks", - "Group albums", - "Enter search", - "enter Id", - "aBort", - ) + ("Foo", "baR") - - self.importer.add_choice(Action.SKIP) - self.importer.run() - self.mock_input_options.assert_called_once_with( - opts, default="a", require=ANY - ) + with self.plugins(("dummy", DummyPlugin)): + # Default options + extra choices by the plugin ('Foo', 'Bar') + opts = ( + "Apply", + "More candidates", + "Skip", + "Use as-is", + "as Tracks", + "Group albums", + "Enter search", + "enter Id", + "aBort", + ) + ("Foo", "baR") + + self.importer.add_choice(Action.SKIP) + self.importer.run() + self.mock_input_options.assert_called_once_with( + opts, default="a", require=ANY + ) def test_plugin_choices_in_ui_input_options_singleton(self): """Test the presence of plugin choices on the prompt (singleton).""" @@ -332,24 +324,24 @@ def return_choices(self, session, task): ui.commands.PromptChoice("r", "baR", None), ] - self.register_plugin(DummyPlugin) - # Default options + extra choices by the plugin ('Foo', 'Bar') - opts = ( - "Apply", - "More candidates", - "Skip", - "Use as-is", - "Enter search", - "enter Id", - "aBort", - ) + ("Foo", "baR") - - config["import"]["singletons"] = True - self.importer.add_choice(Action.SKIP) - self.importer.run() - self.mock_input_options.assert_called_with( - opts, default="a", require=ANY - ) + with self.plugins(("dummy", DummyPlugin)): + # Default options + extra choices by the plugin ('Foo', 'Bar') + opts = ( + "Apply", + "More candidates", + "Skip", + "Use as-is", + "Enter search", + "enter Id", + "aBort", + ) + ("Foo", "baR") + + config["import"]["singletons"] = True + self.importer.add_choice(Action.SKIP) + self.importer.run() + self.mock_input_options.assert_called_with( + opts, default="a", require=ANY + ) def test_choices_conflicts(self): """Test the short letter conflict solving.""" @@ -369,24 +361,24 @@ def return_choices(self, session, task): ui.commands.PromptChoice("z", "Zoo", None), ] # dupe - self.register_plugin(DummyPlugin) - # Default options + not dupe extra choices by the plugin ('baZ') - opts = ( - "Apply", - "More candidates", - "Skip", - "Use as-is", - "as Tracks", - "Group albums", - "Enter search", - "enter Id", - "aBort", - ) + ("baZ",) - self.importer.add_choice(Action.SKIP) - self.importer.run() - self.mock_input_options.assert_called_once_with( - opts, default="a", require=ANY - ) + with self.plugins(("dummy", DummyPlugin)): + # Default options + not dupe extra choices by the plugin ('baZ') + opts = ( + "Apply", + "More candidates", + "Skip", + "Use as-is", + "as Tracks", + "Group albums", + "Enter search", + "enter Id", + "aBort", + ) + ("baZ",) + self.importer.add_choice(Action.SKIP) + self.importer.run() + self.mock_input_options.assert_called_once_with( + opts, default="a", require=ANY + ) def test_plugin_callback(self): """Test that plugin callbacks are being called upon user choice.""" @@ -404,31 +396,31 @@ def return_choices(self, session, task): def foo(self, session, task): pass - self.register_plugin(DummyPlugin) - # Default options + extra choices by the plugin ('Foo', 'Bar') - opts = ( - "Apply", - "More candidates", - "Skip", - "Use as-is", - "as Tracks", - "Group albums", - "Enter search", - "enter Id", - "aBort", - ) + ("Foo",) - - # DummyPlugin.foo() should be called once - with patch.object(DummyPlugin, "foo", autospec=True) as mock_foo: - with helper.control_stdin("\n".join(["f", "s"])): - self.importer.run() - assert mock_foo.call_count == 1 - - # input_options should be called twice, as foo() returns None - assert self.mock_input_options.call_count == 2 - self.mock_input_options.assert_called_with( - opts, default="a", require=ANY - ) + with self.plugins(("dummy", DummyPlugin)): + # Default options + extra choices by the plugin ('Foo', 'Bar') + opts = ( + "Apply", + "More candidates", + "Skip", + "Use as-is", + "as Tracks", + "Group albums", + "Enter search", + "enter Id", + "aBort", + ) + ("Foo",) + + # DummyPlugin.foo() should be called once + with patch.object(DummyPlugin, "foo", autospec=True) as mock_foo: + with helper.control_stdin("\n".join(["f", "s"])): + self.importer.run() + assert mock_foo.call_count == 1 + + # input_options should be called twice, as foo() returns None + assert self.mock_input_options.call_count == 2 + self.mock_input_options.assert_called_with( + opts, default="a", require=ANY + ) def test_plugin_callback_return(self): """Test that plugin callbacks that return a value exit the loop.""" @@ -446,28 +438,28 @@ def return_choices(self, session, task): def foo(self, session, task): return Action.SKIP - self.register_plugin(DummyPlugin) - # Default options + extra choices by the plugin ('Foo', 'Bar') - opts = ( - "Apply", - "More candidates", - "Skip", - "Use as-is", - "as Tracks", - "Group albums", - "Enter search", - "enter Id", - "aBort", - ) + ("Foo",) - - # DummyPlugin.foo() should be called once - with helper.control_stdin("f\n"): - self.importer.run() + with self.plugins(("dummy", DummyPlugin)): + # Default options + extra choices by the plugin ('Foo', 'Bar') + opts = ( + "Apply", + "More candidates", + "Skip", + "Use as-is", + "as Tracks", + "Group albums", + "Enter search", + "enter Id", + "aBort", + ) + ("Foo",) + + # DummyPlugin.foo() should be called once + with helper.control_stdin("f\n"): + self.importer.run() - # input_options should be called once, as foo() returns SKIP - self.mock_input_options.assert_called_once_with( - opts, default="a", require=ANY - ) + # input_options should be called once, as foo() returns SKIP + self.mock_input_options.assert_called_once_with( + opts, default="a", require=ANY + ) def get_available_plugins(): @@ -482,6 +474,8 @@ def get_available_plugins(): class TestImportPlugin(PluginMixin): + """Test that all available plugins can be imported without error.""" + @pytest.fixture(params=get_available_plugins()) def plugin_name(self, request): """Fixture to provide the name of each available plugin.""" @@ -494,13 +488,6 @@ def plugin_name(self, request): return name - def unload_plugins(self): - """Unimport plugins before each test to avoid conflicts.""" - super().unload_plugins() - for mod in list(sys.modules): - if mod.startswith("beetsplug."): - del sys.modules[mod] - @pytest.fixture(autouse=True) def cleanup(self): """Ensure plugins are unimported before and after each test.""" @@ -518,8 +505,12 @@ def cleanup(self): def test_import_plugin(self, caplog, plugin_name): """Test that a plugin is importable without an error.""" caplog.set_level(logging.WARNING) - self.load_plugins(plugin_name) - - assert "PluginImportError" not in caplog.text, ( - f"Plugin '{plugin_name}' has issues during import." - ) + with self.plugins(plugin_name): + assert "PluginImportError" not in caplog.text, ( + f"Plugin '{plugin_name}' has issues during import." + ) + + def test_import_error(self, caplog): + """Test that an invalid plugin raises PluginImportError.""" + self.load_plugins("this_does_not_exist") + assert "PluginImportError" in caplog.text diff --git a/test/test_ui.py b/test/test_ui.py index 534d0e4665..abe95cba23 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -34,7 +34,7 @@ from beets.test.helper import ( BeetsTestCase, IOMixin, - PluginTestCase, + PluginUnitTestCase, capture_stdout, control_stdin, has_program, @@ -812,7 +812,7 @@ def test_parse_paths_from_logfile(self): @_common.slow_test() -class TestPluginTestCase(PluginTestCase): +class TestPluginTestCase(PluginUnitTestCase): plugin = "test" def setUp(self):