From 109a0977340e66e503f2fac2d25d4831008255f4 Mon Sep 17 00:00:00 2001 From: Henry Date: Tue, 21 Oct 2025 21:43:21 -0700 Subject: [PATCH] titlecase plugin nearly complete, one typecheck error to resolve. --- beetsplug/titlecase.py | 223 +++++++++++++++++++++------------ test/plugins/test_titlecase.py | 158 ++++++++++++++++------- 2 files changed, 260 insertions(+), 121 deletions(-) diff --git a/beetsplug/titlecase.py b/beetsplug/titlecase.py index 29b648837..a3f64d4b2 100644 --- a/beetsplug/titlecase.py +++ b/beetsplug/titlecase.py @@ -13,15 +13,15 @@ # included in all copies or substantial portions of the Software. """Apply NYT manual of style title case rules, to paths and tag text. - Title case logic is derived from the python-titlecase library.""" +Title case logic is derived from the python-titlecase library.""" -from beets.plugins import BeetsPlugin -from beets.dbcore import types -from beets import ui from titlecase import titlecase -from typing import TYPE_CHECKING -from beets.library import Item + +from beets import ui +from beets.dbcore import types from beets.importer import ImportSession, ImportTask +from beets.library import Item +from beets.plugins import BeetsPlugin __author__ = "henryoberholtzer@gmail.com" __version__ = "1.0" @@ -29,131 +29,196 @@ __version__ = "1.0" # These fields are excluded to avoid modifying anything # that may be case sensistive, or important to database # function -EXCLUDED_INFO_FIELDS = set([ - 'id', - 'mb_workid', - 'mb_trackid', - 'mb_albumid', - 'mb_artistid', - 'mb_albumartistid', - 'mb_albumartistids', - 'mb_releasetrackid', - 'acoustid_fingerprint', - 'acoustid_id', - 'mb_releasegroupid', - 'asin', - 'isrc', - 'bitrate_mode', - 'encoder_info', - 'encoder_settings' - ]) +EXCLUDED_INFO_FIELDS = set( + [ + "id", + "mb_workid", + "mb_trackid", + "mb_albumid", + "mb_artistid", + "mb_albumartistid", + "mb_albumartistids", + "mb_releasetrackid", + "acoustid_fingerprint", + "acoustid_id", + "mb_releasegroupid", + "asin", + "isrc", + "format", + "bitrate_mode", + "encoder_info", + "encoder_settings", + ] +) + class TitlecasePlugin(BeetsPlugin): preserve: dict[str, str] = {} - fields_to_process: set[str] = [] + force_lowercase: bool = True + fields_to_process: set[str] = set([]) + def __init__(self) -> None: super().__init__() self.config.add( { - "auto": True, + "auto": False, "preserve": [], + "include": [], + "exclude": [], + "force_lowercase": True, "small_first_last": True, - "titlecase_metadata": True, - "include_fields": [], - "exclude_fields": [] } ) - """ - auto - automatically apply to new imports - preserve - provide a list of words/acronyms with specific case requirements - small_first_last - if small characters should be title cased at beginning - titlecase_metadata - if metadata fields should have title case applied - include_fields - fields to apply titlecase to, default is all, except select excluded fields - exclude_fields - fields to exclude from titlecase to, default is none - NOTE: titlecase will not interact with possibly case sensitive fields like id, - path or album_id. Paths are best modified in path config. + """ - # Register template function - self.template_funcs["titlecase"] = self.titlecase + auto - Automatically apply titlecase to new import metadata. + preserve - Provide a list of words/acronyms with specific case requirements. + include - Fields to apply titlecase to, default is all. + exclude - Fields to exclude from titlecase to, default is none. + force_lowercase - Lowercases the string before titlecasing. + small_first_last - If small characters should be cased at the start of strings. + NOTE: Titlecase will not interact with possibly case sensitive fields. + """ + # Register UI subcommands self._command = ui.Subcommand( - "titlecase", - help="apply NYT manual of style titlecase rules" + "titlecase", + help="Apply titlecasing to metadata following the NYT manual of style.", ) self._command.parser.add_option( - "-p", - "--preserve", - help="preserve the case of the given word, in addition to those configured." - ) + "-f", + "--force-off", + dest="force_lowercase", + action="store_false", + help="Turn off forcing lowercase first.", + ) self._command.parser.add_option( - "-f", - "--field", - help="apply to the following fields." - ) + "-p", + "--preserve", + dest="preserve", + action="store", + help="Preserve the case of the given words.", + ) - for word in self.config["preserve"].as_str_seq(): - self.preserve[word.upper()] = word - self.__init_field_list__() + self._command.parser.add_option( + "-i", + "--include", + dest="include", + action="store", + help="""Metadata fields to titlecase to, default is all. + Always ignores case sensitive fields.""", + ) - self.import_stages = [self.imported] + self._command.parser.add_option( + "-e", + "--exclude", + dest="exclude", + action="store", + help="""Metadata fields to skip, default is none. + Always ignores case sensitive fields.""", + ) + self.__get_config_file__() + if self.config["auto"]: + self.import_stages = [self.imported] + # Register template function + self.template_funcs["titlecase"] = self.titlecase - def __init_field_list__(self) -> None: - """ Creates the set for fields to process in tagging. + + def __get_config_file__(self): + self.force_lowercase = self.config["force_lowercase"].get(bool) + self.__preserve_words__(self.config["preserve"].as_str_seq()) + self.__init_field_list__( + self.config["include"].as_str_seq(), + self.config["exclude"].as_str_seq(), + ) + + def __init_field_list__( + self, include: list[str], exclude: list[str] + ) -> None: + """Creates the set for fields to process in tagging. If we have include_fields from config, the shared fields will be used. - Then, any fields specified to be excluded will be removed. + Then, any fields specified to be excluded will be removed. This will result in exclude_fields overriding include_fields. Last, the EXCLUDED_INFO_FIELDS are removed to prevent unitentional modification. """ - initial_field_list = set([ - k for k, v in Item()._fields.items() if - isinstance(v, types.String) or - isinstance(v, types.DelimitedString) - ]) - if (incl := self.config["include_fields"].as_str_seq()): - initial_field_list = initial_field_list.intersection(set(incl)) - if (excl := self.config["exclude_fields"].as_str_seq()): - initial_field_list -= set(excl) + initial_field_list = set( + [ + k + for k, v in Item()._fields.items() + if isinstance(v, types.String) + or isinstance(v, types.DelimitedString) + ] + ) + if include: + initial_field_list = initial_field_list.intersection(set(include)) + if exclude: + initial_field_list -= set(exclude) initial_field_list -= set(EXCLUDED_INFO_FIELDS) self.fields_to_process = initial_field_list + def __preserve_words__(self, preserve: list[str]) -> None: + for word in preserve: + self.preserve[word.upper()] = word + def __preserved__(self, word, **kwargs) -> str | None: - """ Callback function for words to preserve case of.""" - if (preserved_word := self.preserve.get(word.upper(), "")): + """Callback function for words to preserve case of.""" + if preserved_word := self.preserve.get(word.upper(), ""): return preserved_word return None def commands(self) -> list[ui.Subcommand]: + def split_if_exists(string: str): + return string.split() if string else [] + def func(lib, opts, args): - self.config.set_args(opts) + opts = opts.__dict__ + preserve = split_if_exists(opts["preserve"]) + excl = split_if_exists(opts["exclude"]) + incl = split_if_exists(opts["include"]) + if opts["force_lowercase"] is not None: + self.force_lowercase = False + self.__preserve_words__(preserve) + self.__init_field_list__(incl, excl) + write = ui.should_write() + + for item in lib.items(args): + self._log.info(f"titlecasing {item.title}:") + self.titlecase_fields(item) + item.store() + if write: + item.try_write() + + self._command.func = func + return [self._command] def titlecase_fields(self, item: Item): - """ Applies titlecase to fields, except + """Applies titlecase to fields, except those excluded by the default exclusions and the - set exclude lists. + set exclude lists. """ for field in self.fields_to_process: init_str = getattr(item, field, "") if init_str: - cased = self.titlecase(old_str) + cased = self.titlecase(init_str) self._log.info(f"{field}: {init_str} -> {cased}") setattr(item, field, cased) else: self._log.info(f"{field}: no string present") def titlecase(self, text: str) -> str: - """ Titlecase the given text. """ - return titlecase(text, + """Titlecase the given text.""" + return titlecase( + text.lower() if self.force_lowercase else text, small_first_last=self.config["small_first_last"], - callback=self.__preserved__) + callback=self.__preserved__, + ) def imported(self, session: ImportSession, task: ImportTask) -> None: - """ Import hook for titlecasing on import. """ - for item in task.imported_items(): + """Import hook for titlecasing on import.""" + for item in task.imported_items(): self._log.info(f"titlecasing {item.title}:") self.titlecase_fields(item) item.store() - - diff --git a/test/plugins/test_titlecase.py b/test/plugins/test_titlecase.py index ccff8fcd3..d16527afd 100644 --- a/test/plugins/test_titlecase.py +++ b/test/plugins/test_titlecase.py @@ -12,63 +12,58 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -""" Tests for the 'titlecase' plugin""" +"""Tests for the 'titlecase' plugin""" import pytest from beets import config from beets.test.helper import PluginTestCase -from beetsplug.titlecase import TitlecasePlugin, EXCLUDED_INFO_FIELDS +from beetsplug.titlecase import EXCLUDED_INFO_FIELDS, TitlecasePlugin -@pytest.mark.parametrize("given, expected", - [("a", "A"), - ("PENDULUM", "Pendulum"), - ("Aaron-carl", "Aaron-Carl"), - ("LTJ bukem", "LTJ Bukem"), - ("Freaky chakra Vs. Single Cell orchestra", - "Freaky Chakra vs. Single Cell Orchestra"), - ("(original mix)", "(Original Mix)"), - ("ALL CAPS TITLE", "All Caps Title") - ]) + +@pytest.mark.parametrize( + "given, expected", + [ + ("a", "A"), + ("PENDULUM", "Pendulum"), + ("Aaron-carl", "Aaron-Carl"), + ("LTJ bukem", "LTJ Bukem"), + ( + "Freaky chakra Vs. Single Cell orchestra", + "Freaky Chakra vs. Single Cell Orchestra", + ), + ("(original mix)", "(Original Mix)"), + ("ALL CAPS TITLE", "All Caps Title"), + ], +) def test_basic_titlecase(given, expected): - """ Assert that general behavior is as expected. """ + """Assert that general behavior is as expected.""" assert TitlecasePlugin().titlecase(given) == expected class TitlecasePluginTest(PluginTestCase): plugin = "titlecase" preload_plugin = False - + def test_preserved_case(self): - """ Test using given strings to preserve case """ + """Test using given strings to preserve case""" names_to_preserve = ["easyFun", "A.D.O.R.", "D.R.", "ABBA", "LaTeX"] - with self.configure_plugin({ - "preserve": names_to_preserve}): + with self.configure_plugin({"preserve": names_to_preserve}): config["titlecase"]["preserve"] = names_to_preserve for name in names_to_preserve: - assert TitlecasePlugin().titlecase( - name.lower()) == name + assert TitlecasePlugin().titlecase(name.lower()) == name def test_small_first_last(self): - with self.configure_plugin({ - "small_first_last": False}): - assert TitlecasePlugin().titlecase( - "A Simple Trial") == "a Simple Trial" - with self.configure_plugin({ - "small_first_last": True}): - assert TitlecasePlugin().titlecase( - "A simple Trial") == "A Simple Trial" - - def test_ui_command(self): - assert 1 == 3 - - def test_imported(self): - item = self.add_item( - artist="A poorly cased artist", - albumartist="not vEry good tItle caSE", - mb_artistid="case sensitive field") - assert item.artist == "A Poorly Cased Artist" - assert item.albumartist == "Not Very Good Title Case" + with self.configure_plugin({"small_first_last": False}): + assert ( + TitlecasePlugin().titlecase("A Simple Trial") + == "a Simple Trial" + ) + with self.configure_plugin({"small_first_last": True}): + assert ( + TitlecasePlugin().titlecase("A simple Trial") + == "A Simple Trial" + ) def test_field_list_default_excluded(self): excluded = list(EXCLUDED_INFO_FIELDS) @@ -77,15 +72,94 @@ class TitlecasePluginTest(PluginTestCase): for field in excluded: assert field not in t.fields_to_process + def test_ui_commands(self): + self.load_plugins("titlecase") + tests = [ + ( + { + "title": "poorLy cased Title", + "artist": "Bad CaSE", + "album": "the album", + }, + { + "title": "Poorly Cased Title", + "artist": "Bad Case", + "album": "The Album", + }, + "", + ), + ( + { + "title": "poorLy cased Title", + "artist": "Bad CaSE", + "album": "the album", + }, + { + "title": "poorLy cased Title", + "artist": "Bad Case", + "album": "the album", + }, + "-e title album", + ), + ( + { + "title": "poorLy cased Title", + "artist": "Bad CaSE", + "album": "the album", + }, + { + "title": "poorLy cased Title", + "artist": "Bad Case", + "album": "the album", + }, + "-i artist", + ), + ( + { + "title": "poorLy cased Title", + "artist": "Bad CaSE", + "album": "the album", + }, + { + "title": "poorLy Cased Title", + "artist": "Bad CaSE", + "album": "The Album", + }, + "-p CaSE poorLy", + ), + ( + { + "title": "poorLy cased Title", + "artist": "Bad CaSE", + "album": "the album", + }, + { + "title": "poorLy Cased Title", + "artist": "Bad CaSE", + "album": "The Album", + }, + "-f", + ), + ] + for test in tests: + i, o, opts = test + self.add_item( + artist=i["artist"], album=i["album"], title=i["title"] + ) + self.run_command("titlecase", opts) + output = self.run_with_output("ls") + assert output == f"{o['artist']} - {o['album']} - {o['title']}\n" + self.run_command("rm", o["title"], "-f") + def test_field_list_included(self): - config["titlecase"]["include_fields"] = ["album", "albumartist"] + include_fields = ["album", "albumartist"] + config["titlecase"]["include"] = include_fields t = TitlecasePlugin() - t.fields_to_process == ["album", "albumartist"] + assert t.fields_to_process == set(include_fields) def test_field_list_exclude(self): excluded = ["album", "albumartist"] - config["titlecase"]["exclude_fields"] = excluded + config["titlecase"]["exclude"] = excluded t = TitlecasePlugin() for field in excluded: assert field not in t.fields_to_process -