titlecase plugin nearly complete, one typecheck error to resolve.

This commit is contained in:
Henry 2025-10-21 21:43:21 -07:00
parent a9f7ee8d1e
commit 109a097734
2 changed files with 260 additions and 121 deletions

View file

@ -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()

View file

@ -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