From 478ac8cb639a9dd9a1fc9c8294004ea3db1cd9c4 Mon Sep 17 00:00:00 2001 From: Blake Hakkila Date: Mon, 15 Jun 2026 03:37:12 -0700 Subject: [PATCH 1/2] lyrics: add rest_directory configuration option --- beetsplug/lyrics.py | 6 +- docs/changelog.rst | 3 + docs/plugins/lyrics.rst | 5 ++ test/plugins/test_lyrics.py | 120 ++++++++++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 2 deletions(-) diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 0a34fa60e8..0e7c986cb8 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -31,6 +31,7 @@ import requests from bs4 import BeautifulSoup +from confuse import Optional from unidecode import unidecode from beets import plugins, ui @@ -1052,6 +1053,7 @@ def __init__(self): "keep_synced": False, "local": False, "print": False, + "rest_directory": None, "synced": False, # Musixmatch and Tekstowo are disabled by default as they # currently block requests with the beets user agent. @@ -1084,7 +1086,7 @@ def commands(self): "--write-rest", dest="rest_directory", action="store", - default=None, + default=self.config["rest_directory"].get(Optional(str)), metavar="dir", help="write lyrics to given directory as ReST files", ) @@ -1122,7 +1124,7 @@ def func(lib: Library, opts, args) -> None: if opts.rest_directory and ( items := [i for i in items if i.lyrics] ): - RestFiles(Path(opts.rest_directory)).write(items) + RestFiles(Path(opts.rest_directory).expanduser()).write(items) cmd.func = func return [cmd] diff --git a/docs/changelog.rst b/docs/changelog.rst index 23812a7b7d..5d84417b2f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -27,6 +27,9 @@ New features :conf:`plugins.musicbrainz:aliases_as_credits` to make aliases-as-artist-credit optional. - :doc:`plugins/badfiles`: Added settings for auto error and warning actions. +- :doc:`plugins/lyrics`: Added a ``rest_directory`` configuration option for + specifying a reStructuredText output directory, semantically equivalent to + ``-r, --write-rest``. :bug:`2806` Bug fixes ~~~~~~~~~ diff --git a/docs/plugins/lyrics.rst b/docs/plugins/lyrics.rst index ae83ffdc14..8fa2b4c1db 100644 --- a/docs/plugins/lyrics.rst +++ b/docs/plugins/lyrics.rst @@ -59,6 +59,7 @@ Default configuration: google_API_key: null google_engine_ID: 009217259823014548361:lndtuqkycfu print: no + rest_directory: null sources: [lrclib, google, genius] synced: no @@ -107,6 +108,8 @@ The available options are: custom search engine`_, which gathers an updated list of sources known to be scrapeable. - **print**: Print lyrics to the console. +- **rest_directory**: The directory to which reStructuredText_ (ReST) rendered + lyric documents will be output. See :ref:`rendering-lyrics`. - **sources**: List of sources to search for lyrics. An asterisk ``*`` expands to all available sources. The ``google`` source will be automatically deactivated if no ``google_API_key`` is setup. By default, ``musixmatch`` and @@ -147,6 +150,8 @@ lyrics without touching tracks that already have a synced version. Inversely, the ``-l, --local`` option restricts operations to lyrics that are locally available, which show lyrics faster without using the network at all. +.. _rendering-lyrics: + Rendering Lyrics into Other Formats ----------------------------------- diff --git a/test/plugins/test_lyrics.py b/test/plugins/test_lyrics.py index 285a8ad7ff..f73587b9f2 100644 --- a/test/plugins/test_lyrics.py +++ b/test/plugins/test_lyrics.py @@ -25,6 +25,7 @@ import pytest import requests +from confuse.exceptions import ConfigTypeError from beets.library import Item from beets.test.helper import PluginMixin, TestHelper @@ -841,6 +842,125 @@ def test_write(self, rest_dir: Path, rest_files): ) +class TestLyricsRestDirectory(LyricsPluginMixin): + @pytest.fixture + def lib(self, helper): + return helper.lib + + @pytest.fixture + def backend_name(self): + return "lrclib" + + def _setup_config(self, tmp_path, rest_directory): + config_file = tmp_path / "config.yaml" + with config_file.open("w") as f: + f.write(f"lyrics:\n rest_directory: {rest_directory}") + self.config.set_file(config_file) + self.config.read(False, False) + + def _test_helper( + self, tmp_path, lyrics_plugin, lib, config_path, arg_path, output_path + ): + if config_path: + self._setup_config(tmp_path, config_path) + + cmd = lyrics_plugin.commands()[0] + cmd_args = [] if arg_path is None else ["-r", arg_path] + opts, args = cmd.parser.parse_args(cmd_args) + cmd.func(lib, opts, args) + + if output_path is None: + for item in tmp_path.rglob("*"): + assert item.name != "index.rst" + assert item.name != "conf.py" + else: + assert (output_path / "index.rst").exists() + assert (output_path / "conf.py").exists() + + @pytest.mark.parametrize( + "config_path, arg_path, output_dir", + [ + pytest.param( + "test/config", "test/cmd", "test/cmd", id="config and cmd arg" + ), + pytest.param("test/config", None, "test/config", id="config only"), + pytest.param(None, "test/cmd", "test/cmd", id="cmd arg only"), + pytest.param(None, None, None, id="no output"), + ], + ) + def test_rest_config( + self, + monkeypatch, + tmp_path, + lyrics_plugin, + lib, + config_path, + arg_path, + output_dir, + ): + monkeypatch.chdir(tmp_path) + if output_dir: + output_dir = tmp_path / output_dir + + self._test_helper( + tmp_path, lyrics_plugin, lib, config_path, arg_path, output_dir + ) + + def test_config_path_types(self, monkeypatch, tmp_path, lyrics_plugin, lib): + + output_dir = "test" + absolute_dir = tmp_path / "absolute" + absolute_dir.mkdir() + relative_dir = tmp_path / "relative" + relative_dir.mkdir() + home_dir = tmp_path / "home" + home_dir.mkdir() + monkeypatch.chdir(relative_dir) + monkeypatch.setenv("HOME", str(home_dir)) + monkeypatch.setenv("USERPROFILE", str(home_dir)) + + self._test_helper( + tmp_path, + lyrics_plugin, + lib, + str((absolute_dir / output_dir).absolute()), + None, + absolute_dir / output_dir, + ) + self._test_helper( + tmp_path, + lyrics_plugin, + lib, + output_dir, + None, + relative_dir / output_dir, + ) + self._test_helper( + tmp_path, + lyrics_plugin, + lib, + f"~/{output_dir}", + None, + home_dir / output_dir, + ) + + @pytest.mark.parametrize( + "bad_config", + [ + pytest.param(42), + pytest.param(3.14), + pytest.param(False), + pytest.param({}), + pytest.param([]), + ], + ) + def test_bad_config(self, lyrics_plugin, bad_config): + lyrics_plugin.config["rest_directory"].set(bad_config) + + with pytest.raises(ConfigTypeError): + lyrics_plugin.commands() + + class TestLyricsSyltProperty: """Unit tests for the Lyrics.sylt timestamp-to-millisecond converter.""" From a7aeb6db924a932ff18e90d10660b4c499688113 Mon Sep 17 00:00:00 2001 From: Blake Hakkila Date: Fri, 19 Jun 2026 15:29:00 -0700 Subject: [PATCH 2/2] Fixed tests to remove confuse testing and add mock for RestFiles --- test/plugins/test_lyrics.py | 172 ++++++++++++++---------------------- 1 file changed, 68 insertions(+), 104 deletions(-) diff --git a/test/plugins/test_lyrics.py b/test/plugins/test_lyrics.py index 0c546c0e5f..36c128d2ce 100644 --- a/test/plugins/test_lyrics.py +++ b/test/plugins/test_lyrics.py @@ -20,23 +20,21 @@ import textwrap from functools import partial from http import HTTPStatus +from pathlib import Path from types import SimpleNamespace from typing import TYPE_CHECKING import pytest import requests -from confuse.exceptions import ConfigTypeError from beets.library import Item -from beets.test.helper import PluginMixin +from beets.test.helper import PluginMixin, PluginTestHelper from beets.util.lyrics import Lyrics from beetsplug import lyrics from .lyrics_pages import lyrics_pages if TYPE_CHECKING: - from pathlib import Path - from .lyrics_pages import LyricsPage PHRASE_BY_TITLE = { @@ -844,123 +842,89 @@ def test_write(self, rest_dir: Path, rest_files): ) -class TestLyricsRestDirectory(LyricsPluginMixin): +class TestLyricsRestDirectory(PluginTestHelper): + plugin = "lyrics" + @pytest.fixture def lib(self, helper): return helper.lib - @pytest.fixture - def backend_name(self): - return "lrclib" - - def _setup_config(self, tmp_path, rest_directory): - config_file = tmp_path / "config.yaml" - with config_file.open("w") as f: - f.write(f"lyrics:\n rest_directory: {rest_directory}") - self.config.set_file(config_file) - self.config.read(False, False) - - def _test_helper( - self, tmp_path, lyrics_plugin, lib, config_path, arg_path, output_path - ): - if config_path: - self._setup_config(tmp_path, config_path) - - cmd = lyrics_plugin.commands()[0] - cmd_args = [] if arg_path is None else ["-r", arg_path] - opts, args = cmd.parser.parse_args(cmd_args) - cmd.func(lib, opts, args) - - if output_path is None: - for item in tmp_path.rglob("*"): - assert item.name != "index.rst" - assert item.name != "conf.py" - else: - assert (output_path / "index.rst").exists() - assert (output_path / "conf.py").exists() - @pytest.mark.parametrize( - "config_path, arg_path, output_dir", + "config_path, arg_path, output_path", [ pytest.param( - "test/config", "test/cmd", "test/cmd", id="config and cmd arg" + "test/config", + "test/cmd", + "test/cmd", + id="config and cmd arg, relative path", + ), + pytest.param( + "test/config", + None, + "test/config", + id="config only, relative path", + ), + pytest.param( + None, "test/cmd", "test/cmd", id="cmd arg only, relative path" + ), + pytest.param( + "/test/config", + "/test/cmd", + "/test/cmd", + id="config and cmd arg, absolute path", + ), + pytest.param( + "/test/config", + None, + "/test/config", + id="config only, absolute path", + ), + pytest.param( + None, "/test/cmd", "/test/cmd", id="cmd arg only, absolute path" + ), + pytest.param( + "~/test/config", + "~/test/cmd", + "~/test/cmd", + id="config and cmd arg, home path", + ), + pytest.param( + "~/test/config", + None, + "~/test/config", + id="config only, home path", + ), + pytest.param( + None, "~/test/cmd", "~/test/cmd", id="cmd arg only, home path" ), - pytest.param("test/config", None, "test/config", id="config only"), - pytest.param(None, "test/cmd", "test/cmd", id="cmd arg only"), pytest.param(None, None, None, id="no output"), ], ) def test_rest_config( - self, - monkeypatch, - tmp_path, - lyrics_plugin, - lib, - config_path, - arg_path, - output_dir, + self, monkeypatch, lib, config_path, arg_path, output_path ): - monkeypatch.chdir(tmp_path) - if output_dir: - output_dir = tmp_path / output_dir + test_capture = {} - self._test_helper( - tmp_path, lyrics_plugin, lib, config_path, arg_path, output_dir - ) + class MockRestFiles: + def __init__(self, directory): + test_capture["directory"] = directory - def test_config_path_types(self, monkeypatch, tmp_path, lyrics_plugin, lib): - - output_dir = "test" - absolute_dir = tmp_path / "absolute" - absolute_dir.mkdir() - relative_dir = tmp_path / "relative" - relative_dir.mkdir() - home_dir = tmp_path / "home" - home_dir.mkdir() - monkeypatch.chdir(relative_dir) - monkeypatch.setenv("HOME", str(home_dir)) - monkeypatch.setenv("USERPROFILE", str(home_dir)) - - self._test_helper( - tmp_path, - lyrics_plugin, - lib, - str((absolute_dir / output_dir).absolute()), - None, - absolute_dir / output_dir, - ) - self._test_helper( - tmp_path, - lyrics_plugin, - lib, - output_dir, - None, - relative_dir / output_dir, - ) - self._test_helper( - tmp_path, - lyrics_plugin, - lib, - f"~/{output_dir}", - None, - home_dir / output_dir, - ) + def write(self, items): + test_capture["items"] = items - @pytest.mark.parametrize( - "bad_config", - [ - pytest.param(42), - pytest.param(3.14), - pytest.param(False), - pytest.param({}), - pytest.param([]), - ], - ) - def test_bad_config(self, lyrics_plugin, bad_config): - lyrics_plugin.config["rest_directory"].set(bad_config) + monkeypatch.setattr(lyrics, "RestFiles", MockRestFiles) - with pytest.raises(ConfigTypeError): - lyrics_plugin.commands() + if config_path: + self.config["lyrics"]["rest_directory"] = config_path + + cmd_args = [] if arg_path is None else ["-r", arg_path] + self.run_command("lyrics", *cmd_args, lib=lib) + + test_output = test_capture.get("directory") + if output_path is None: + assert test_output is None + else: + assert test_output == Path(output_path).expanduser() class TestLyricsSyltProperty: