diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d014b925b..fe4ce3378 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,4 +3,5 @@ # Specific ownerships: /beets/metadata_plugins.py @semohr -/beetsplug/mbpseudo.py @asardaes \ No newline at end of file +/beetsplug/titlecase.py @henry-oberholtzer +/beetsplug/mbpseudo.py @asardaes diff --git a/README.rst b/README.rst index 3d5a84712..9e42eec30 100644 --- a/README.rst +++ b/README.rst @@ -85,7 +85,7 @@ simple if you know a little Python. .. _transcode audio: https://beets.readthedocs.org/page/plugins/convert.html -.. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins.html +.. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins/index.html Install ------- diff --git a/README_kr.rst b/README_kr.rst index 2233c379d..803229425 100644 --- a/README_kr.rst +++ b/README_kr.rst @@ -79,7 +79,7 @@ Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들 .. _transcode audio: https://beets.readthedocs.org/page/plugins/convert.html -.. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins.html +.. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins/index.html 설치 ------- diff --git a/beets/__init__.py b/beets/__init__.py index d448d8c49..2c6069b29 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -17,7 +17,7 @@ from sys import stderr import confuse -from .util import deprecate_imports +from .util.deprecation import deprecate_imports __version__ = "2.5.1" __author__ = "Adrian Sampson " @@ -26,13 +26,9 @@ __author__ = "Adrian Sampson " def __getattr__(name: str): """Handle deprecated imports.""" return deprecate_imports( - old_module=__name__, - new_module_by_name={ - "art": "beetsplug._utils", - "vfs": "beetsplug._utils", - }, - name=name, - version="3.0.0", + __name__, + {"art": "beetsplug._utils", "vfs": "beetsplug._utils"}, + name, ) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 8fa5a6864..beaf4341c 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -16,7 +16,6 @@ from __future__ import annotations -import warnings from importlib import import_module from typing import TYPE_CHECKING @@ -24,8 +23,8 @@ from beets import config, logging # Parts of external interface. from beets.util import unique_list +from beets.util.deprecation import deprecate_for_maintainers, deprecate_imports -from ..util import deprecate_imports from .hooks import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch from .match import Proposal, Recommendation, tag_album, tag_item @@ -37,18 +36,13 @@ if TYPE_CHECKING: def __getattr__(name: str): if name == "current_metadata": - warnings.warn( - ( - f"'beets.autotag.{name}' is deprecated and will be removed in" - " 3.0.0. Use 'beets.util.get_most_common_tags' instead." - ), - DeprecationWarning, - stacklevel=2, + deprecate_for_maintainers( + f"'beets.autotag.{name}'", "'beets.util.get_most_common_tags'" ) return import_module("beets.util").get_most_common_tags return deprecate_imports( - __name__, {"Distance": "beets.autotag.distance"}, name, "3.0.0" + __name__, {"Distance": "beets.autotag.distance"}, name ) diff --git a/beets/library/__init__.py b/beets/library/__init__.py index b38381438..22416ecb5 100644 --- a/beets/library/__init__.py +++ b/beets/library/__init__.py @@ -1,4 +1,4 @@ -from beets.util import deprecate_imports +from beets.util.deprecation import deprecate_imports from .exceptions import FileOperationError, ReadError, WriteError from .library import Library @@ -13,7 +13,7 @@ NEW_MODULE_BY_NAME = dict.fromkeys( def __getattr__(name: str): - return deprecate_imports(__name__, NEW_MODULE_BY_NAME, name, "3.0.0") + return deprecate_imports(__name__, NEW_MODULE_BY_NAME, name) __all__ = [ diff --git a/beets/logging.py b/beets/logging.py index 8dab1cea6..5a837cd80 100644 --- a/beets/logging.py +++ b/beets/logging.py @@ -22,6 +22,7 @@ calls (`debug`, `info`, etc). from __future__ import annotations +import re import threading from copy import copy from logging import ( @@ -68,6 +69,15 @@ if TYPE_CHECKING: _ArgsType = Union[tuple[object, ...], Mapping[str, object]] +# Regular expression to match: +# - C0 control characters (0x00-0x1F) except useful whitespace (\t, \n, \r) +# - DEL control character (0x7f) +# - C1 control characters (0x80-0x9F) +# Used to sanitize log messages that could disrupt terminal output +_CONTROL_CHAR_REGEX = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\x80-\x9f]") +_UNICODE_REPLACEMENT_CHARACTER = "\ufffd" + + def _logsafe(val: T) -> str | T: """Coerce `bytes` to `str` to avoid crashes solely due to logging. @@ -82,6 +92,10 @@ def _logsafe(val: T) -> str | T: # type, and (b) warn the developer if they do this for other # bytestrings. return val.decode("utf-8", "replace") + if isinstance(val, str): + # Sanitize log messages by replacing control characters that can disrupt + # terminals. + return _CONTROL_CHAR_REGEX.sub(_UNICODE_REPLACEMENT_CHARACTER, val) # Other objects are used as-is so field access, etc., still works in # the format string. Relies on a working __str__ implementation. diff --git a/beets/mediafile.py b/beets/mediafile.py index 8bde9274c..df735afff 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -13,17 +13,11 @@ # included in all copies or substantial portions of the Software. -import warnings - import mediafile -warnings.warn( - "beets.mediafile is deprecated; use mediafile instead", - # Show the location of the `import mediafile` statement as the warning's - # source, rather than this file, such that the offending module can be - # identified easily. - stacklevel=2, -) +from .util.deprecation import deprecate_for_maintainers + +deprecate_for_maintainers("'beets.mediafile'", "'mediafile'", stacklevel=2) # Import everything from the mediafile module into this module. for key, value in mediafile.__dict__.items(): @@ -31,4 +25,4 @@ for key, value in mediafile.__dict__.items(): globals()[key] = value # Cleanup namespace. -del key, value, warnings, mediafile +del key, value, mediafile diff --git a/beets/plugins.py b/beets/plugins.py index 0c7bae234..0dc2754b9 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -20,7 +20,6 @@ import abc import inspect import re import sys -import warnings from collections import defaultdict from functools import cached_property, wraps from importlib import import_module @@ -33,6 +32,7 @@ from typing_extensions import ParamSpec import beets from beets import logging from beets.util import unique_list +from beets.util.deprecation import deprecate_for_maintainers, deprecate_for_user if TYPE_CHECKING: from collections.abc import Callable, Iterable, Sequence @@ -184,11 +184,12 @@ class BeetsPlugin(metaclass=abc.ABCMeta): ): return - warnings.warn( - f"{cls.__name__} is used as a legacy metadata source. " - "It should extend MetadataSourcePlugin instead of BeetsPlugin. " - "Support for this will be removed in the v3.0.0 release!", - DeprecationWarning, + deprecate_for_maintainers( + ( + f"'{cls.__name__}' is used as a legacy metadata source since it" + " inherits 'beets.plugins.BeetsPlugin'. Support for this" + ), + "'beets.metadata_plugins.MetadataSourcePlugin'", stacklevel=3, ) @@ -256,16 +257,19 @@ class BeetsPlugin(metaclass=abc.ABCMeta): ): return - message = ( - "'source_weight' configuration option is deprecated and will be" - " removed in v3.0.0. Use 'data_source_mismatch_penalty' instead" - ) for source in self.config.root().sources: if "source_weight" in (source.get(self.name) or {}): if source.filename: # user config - self._log.warning(message) + deprecate_for_user( + self._log, + f"'{self.name}.source_weight' configuration option", + f"'{self.name}.data_source_mismatch_penalty'", + ) else: # 3rd-party plugin config - warnings.warn(message, DeprecationWarning, stacklevel=0) + deprecate_for_maintainers( + "'source_weight' configuration option", + "'data_source_mismatch_penalty'", + ) def commands(self) -> Sequence[Subcommand]: """Should return a list of beets.ui.Subcommand objects for @@ -410,16 +414,22 @@ def get_plugin_names() -> list[str]: # *contain* a `beetsplug` package. sys.path += paths plugins = unique_list(beets.config["plugins"].as_str_seq()) - # TODO: Remove in v3.0.0 - if ( - "musicbrainz" not in plugins - and "musicbrainz" in beets.config - and beets.config["musicbrainz"].get().get("enabled") - ): - plugins.append("musicbrainz") - beets.config.add({"disabled_plugins": []}) disabled_plugins = set(beets.config["disabled_plugins"].as_str_seq()) + # TODO: Remove in v3.0.0 + mb_enabled = beets.config["musicbrainz"].flatten().get("enabled") + if mb_enabled: + deprecate_for_user( + log, + "'musicbrainz.enabled' configuration option", + "'plugins' configuration to explicitly add 'musicbrainz'", + ) + if "musicbrainz" not in plugins: + plugins.append("musicbrainz") + elif mb_enabled is False: + deprecate_for_user(log, "'musicbrainz.enabled' configuration option") + disabled_plugins.add("musicbrainz") + return [p for p in plugins if p not in disabled_plugins] diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 12eb6d005..cfd8b6bd7 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -28,7 +28,6 @@ import sqlite3 import sys import textwrap import traceback -import warnings from difflib import SequenceMatcher from functools import cache from itertools import chain @@ -40,6 +39,7 @@ from beets import config, library, logging, plugins, util from beets.dbcore import db from beets.dbcore import query as db_query from beets.util import as_string +from beets.util.deprecation import deprecate_for_maintainers from beets.util.functemplate import template if TYPE_CHECKING: @@ -114,11 +114,7 @@ def decargs(arglist): .. deprecated:: 2.4.0 This function will be removed in 3.0.0. """ - warnings.warn( - "decargs() is deprecated and will be removed in version 3.0.0.", - DeprecationWarning, - stacklevel=2, - ) + deprecate_for_maintainers("'beets.ui.decargs'") return arglist diff --git a/beets/ui/commands/__init__.py b/beets/ui/commands/__init__.py index 214bcfbd0..e1d0389a3 100644 --- a/beets/ui/commands/__init__.py +++ b/beets/ui/commands/__init__.py @@ -16,7 +16,7 @@ interface. """ -from beets.util import deprecate_imports +from beets.util.deprecation import deprecate_imports from .completion import completion_cmd from .config import config_cmd @@ -36,14 +36,12 @@ from .write import write_cmd def __getattr__(name: str): """Handle deprecated imports.""" return deprecate_imports( - old_module=__name__, - new_module_by_name={ + __name__, + { "TerminalImportSession": "beets.ui.commands.import_.session", - "PromptChoice": "beets.ui.commands.import_.session", - # TODO: We might want to add more deprecated imports here + "PromptChoice": "beets.util", }, - name=name, - version="3.0.0", + name, ) diff --git a/beets/ui/commands/import_/session.py b/beets/ui/commands/import_/session.py index 27562664e..dcc80b793 100644 --- a/beets/ui/commands/import_/session.py +++ b/beets/ui/commands/import_/session.py @@ -1,10 +1,9 @@ from collections import Counter from itertools import chain -from typing import Any, NamedTuple from beets import autotag, config, importer, logging, plugins, ui from beets.autotag import Recommendation -from beets.util import displayable_path +from beets.util import PromptChoice, displayable_path from beets.util.units import human_bytes, human_seconds_short from .display import ( @@ -368,12 +367,6 @@ def _summary_judgment(rec): return action -class PromptChoice(NamedTuple): - short: str - long: str - callback: Any - - def choose_candidate( candidates, singleton, diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 2592f612a..517e076de 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -27,7 +27,6 @@ import subprocess import sys import tempfile import traceback -import warnings from collections import Counter from collections.abc import Callable, Sequence from contextlib import suppress @@ -168,6 +167,12 @@ class MoveOperation(Enum): REFLINK_AUTO = 5 +class PromptChoice(NamedTuple): + short: str + long: str + callback: Any + + def normpath(path: PathLike) -> bytes: """Provide the canonical form of the path suitable for storing in the database. @@ -1195,26 +1200,3 @@ def get_temp_filename( def unique_list(elements: Iterable[T]) -> list[T]: """Return a list with unique elements in the original order.""" return list(dict.fromkeys(elements)) - - -def deprecate_imports( - old_module: str, new_module_by_name: dict[str, str], name: str, version: str -) -> Any: - """Handle deprecated module imports by redirecting to new locations. - - Facilitates gradual migration of module structure by intercepting import - attempts for relocated functionality. Issues deprecation warnings while - transparently providing access to the moved implementation, allowing - existing code to continue working during transition periods. - """ - if new_module := new_module_by_name.get(name): - warnings.warn( - ( - f"'{old_module}.{name}' is deprecated and will be removed" - f" in {version}. Use '{new_module}.{name}' instead." - ), - DeprecationWarning, - stacklevel=2, - ) - return getattr(import_module(new_module), name) - raise AttributeError(f"module '{old_module}' has no attribute '{name}'") diff --git a/beets/util/deprecation.py b/beets/util/deprecation.py new file mode 100644 index 000000000..b9ffeae82 --- /dev/null +++ b/beets/util/deprecation.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import warnings +from importlib import import_module +from typing import TYPE_CHECKING, Any + +from packaging.version import Version + +import beets + +if TYPE_CHECKING: + from logging import Logger + + +def _format_message(old: str, new: str | None = None) -> str: + next_major = f"{Version(beets.__version__).major + 1}.0.0" + msg = f"{old} is deprecated and will be removed in version {next_major}." + if new: + msg += f" Use {new} instead." + + return msg + + +def deprecate_for_user( + logger: Logger, old: str, new: str | None = None +) -> None: + logger.warning(_format_message(old, new)) + + +def deprecate_for_maintainers( + old: str, new: str | None = None, stacklevel: int = 1 +) -> None: + """Issue a deprecation warning visible to maintainers during development. + + Emits a DeprecationWarning that alerts developers about deprecated code + patterns. Unlike user-facing warnings, these are primarily for internal + code maintenance and appear during test runs or with warnings enabled. + """ + warnings.warn( + _format_message(old, new), DeprecationWarning, stacklevel=stacklevel + 1 + ) + + +def deprecate_imports( + old_module: str, new_module_by_name: dict[str, str], name: str +) -> Any: + """Handle deprecated module imports by redirecting to new locations. + + Facilitates gradual migration of module structure by intercepting import + attempts for relocated functionality. Issues deprecation warnings while + transparently providing access to the moved implementation, allowing + existing code to continue working during transition periods. + """ + if new_module := new_module_by_name.get(name): + deprecate_for_maintainers( + f"'{old_module}.{name}'", f"'{new_module}.{name}'", stacklevel=2 + ) + + return getattr(import_module(new_module), name) + raise AttributeError(f"module '{old_module}' has no attribute '{name}'") diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 188afed1f..7ed465cfe 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -25,8 +25,8 @@ import yaml from beets import plugins, ui, util from beets.dbcore import types from beets.importer import Action -from beets.ui.commands.import_.session import PromptChoice from beets.ui.commands.utils import do_query +from beets.util import PromptChoice # These "safe" types can avoid the format/parse cycle that most fields go # through: they are safe to edit with native YAML types. diff --git a/beetsplug/gmusic.py b/beetsplug/gmusic.py deleted file mode 100644 index 5dda3a2e5..000000000 --- a/beetsplug/gmusic.py +++ /dev/null @@ -1,27 +0,0 @@ -# This file is part of beets. -# -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -"""Deprecation warning for the removed gmusic plugin.""" - -from beets.plugins import BeetsPlugin - - -class Gmusic(BeetsPlugin): - def __init__(self): - super().__init__() - - self._log.warning( - "The 'gmusic' plugin has been removed following the" - " shutdown of Google Play Music. Remove the plugin" - " from your configuration to silence this warning." - ) diff --git a/beetsplug/inline.py b/beetsplug/inline.py index e9a94ac38..860a205ee 100644 --- a/beetsplug/inline.py +++ b/beetsplug/inline.py @@ -61,18 +61,18 @@ class InlinePlugin(BeetsPlugin): config["item_fields"].items(), config["pathfields"].items() ): self._log.debug("adding item field {}", key) - func = self.compile_inline(view.as_str(), False) + func = self.compile_inline(view.as_str(), False, key) if func is not None: self.template_fields[key] = func # Album fields. for key, view in config["album_fields"].items(): self._log.debug("adding album field {}", key) - func = self.compile_inline(view.as_str(), True) + func = self.compile_inline(view.as_str(), True, key) if func is not None: self.album_template_fields[key] = func - def compile_inline(self, python_code, album): + def compile_inline(self, python_code, album, field_name): """Given a Python expression or function body, compile it as a path field function. The returned function takes a single argument, an Item, and returns a Unicode string. If the expression cannot be @@ -97,7 +97,12 @@ class InlinePlugin(BeetsPlugin): is_expr = True def _dict_for(obj): - out = dict(obj) + out = {} + for key in obj.keys(computed=False): + if key == field_name: + continue + out[key] = obj._get(key) + if album: out["items"] = list(obj.items()) return out diff --git a/beetsplug/mbsubmit.py b/beetsplug/mbsubmit.py index 93e88dc9e..f6d197256 100644 --- a/beetsplug/mbsubmit.py +++ b/beetsplug/mbsubmit.py @@ -26,8 +26,7 @@ import subprocess from beets import ui from beets.autotag import Recommendation from beets.plugins import BeetsPlugin -from beets.ui.commands import PromptChoice -from beets.util import displayable_path +from beets.util import PromptChoice, displayable_path from beetsplug.info import print_data diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 2b9d5e9c2..231a045b7 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -31,6 +31,7 @@ import beets import beets.autotag.hooks from beets import config, plugins, util from beets.metadata_plugins import MetadataSourcePlugin +from beets.util.deprecation import deprecate_for_user from beets.util.id_extractors import extract_release_id if TYPE_CHECKING: @@ -403,9 +404,10 @@ class MusicBrainzPlugin(MetadataSourcePlugin): self.config["search_limit"] = self.config["match"][ "searchlimit" ].get() - self._log.warning( - "'musicbrainz.searchlimit' option is deprecated and will be " - "removed in 3.0.0. Use 'musicbrainz.search_limit' instead." + deprecate_for_user( + self._log, + "'musicbrainz.searchlimit' configuration option", + "'musicbrainz.search_limit'", ) hostname = self.config["host"].as_str() https = self.config["https"].get(bool) diff --git a/beetsplug/play.py b/beetsplug/play.py index 8fb146213..0d96ee97f 100644 --- a/beetsplug/play.py +++ b/beetsplug/play.py @@ -21,8 +21,7 @@ from os.path import relpath from beets import config, ui, util from beets.plugins import BeetsPlugin from beets.ui import Subcommand -from beets.ui.commands import PromptChoice -from beets.util import get_temp_filename +from beets.util import PromptChoice, get_temp_filename # Indicate where arguments should be inserted into the command string. # If this is missing, they're placed at the end. diff --git a/beetsplug/titlecase.py b/beetsplug/titlecase.py new file mode 100644 index 000000000..2482e1c34 --- /dev/null +++ b/beetsplug/titlecase.py @@ -0,0 +1,236 @@ +# This file is part of beets. +# Copyright 2025, Henry Oberholtzer +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Apply NYT manual of style title case rules, to text. +Title case logic is derived from the python-titlecase library. +Provides a template function and a tag modification function.""" + +import re +from functools import cached_property +from typing import TypedDict + +from titlecase import titlecase + +from beets import ui +from beets.autotag.hooks import AlbumInfo, Info +from beets.importer import ImportSession, ImportTask +from beets.library import Item +from beets.plugins import BeetsPlugin + +__author__ = "henryoberholtzer@gmail.com" +__version__ = "1.0" + + +class PreservedText(TypedDict): + words: dict[str, str] + phrases: dict[str, re.Pattern[str]] + + +class TitlecasePlugin(BeetsPlugin): + def __init__(self) -> None: + super().__init__() + + self.config.add( + { + "auto": True, + "preserve": [], + "fields": [], + "replace": [], + "seperators": [], + "force_lowercase": False, + "small_first_last": True, + "the_artist": True, + "after_choice": False, + } + ) + + """ + auto - Automatically apply titlecase to new import metadata. + preserve - Provide a list of strings with specific case requirements. + fields - Fields to apply titlecase to. + replace - List of pairs, first is the target, second is the replacement + seperators - Other characters to treat like periods. + force_lowercase - Lowercases the string before titlecasing. + small_first_last - If small characters should be cased at the start of strings. + the_artist - If the plugin infers the field to be an artist field + (e.g. the field contains "artist") + It will capitalize a lowercase The, helpful for the artist names + that start with 'The', like 'The Who' or 'The Talking Heads' when + they are not at the start of a string. Superceded by preserved phrases. + """ + # Register template function + self.template_funcs["titlecase"] = self.titlecase + + # Register UI subcommands + self._command = ui.Subcommand( + "titlecase", + help="Apply titlecasing to metadata specified in config.", + ) + + if self.config["auto"].get(bool): + if self.config["after_choice"].get(bool): + self.import_stages = [self.imported] + else: + self.register_listener( + "trackinfo_received", self.received_info_handler + ) + self.register_listener( + "albuminfo_received", self.received_info_handler + ) + + @cached_property + def force_lowercase(self) -> bool: + return self.config["force_lowercase"].get(bool) + + @cached_property + def replace(self) -> list[tuple[str, str]]: + return self.config["replace"].as_pairs() + + @cached_property + def the_artist(self) -> bool: + return self.config["the_artist"].get(bool) + + @cached_property + def fields_to_process(self) -> set[str]: + fields = set(self.config["fields"].as_str_seq()) + self._log.debug(f"fields: {', '.join(fields)}") + return fields + + @cached_property + def preserve(self) -> PreservedText: + strings = self.config["preserve"].as_str_seq() + preserved: PreservedText = {"words": {}, "phrases": {}} + for s in strings: + if " " in s: + preserved["phrases"][s] = re.compile( + rf"\b{re.escape(s)}\b", re.IGNORECASE + ) + else: + preserved["words"][s.upper()] = s + return preserved + + @cached_property + def seperators(self) -> re.Pattern[str] | None: + if seperators := "".join( + dict.fromkeys(self.config["seperators"].as_str_seq()) + ): + return re.compile(rf"(.*?[{re.escape(seperators)}]+)(\s*)(?=.)") + return None + + @cached_property + def small_first_last(self) -> bool: + return self.config["small_first_last"].get(bool) + + @cached_property + def the_artist_regexp(self) -> re.Pattern[str]: + return re.compile(r"\bthe\b") + + def titlecase_callback(self, word, **kwargs) -> str | None: + """Callback function for words to preserve case of.""" + if preserved_word := self.preserve["words"].get(word.upper(), ""): + return preserved_word + return None + + def received_info_handler(self, info: Info): + """Calls titlecase fields for AlbumInfo or TrackInfo + Processes the tracks field for AlbumInfo + """ + self.titlecase_fields(info) + if isinstance(info, AlbumInfo): + for track in info.tracks: + self.titlecase_fields(track) + + def commands(self) -> list[ui.Subcommand]: + def func(lib, opts, args): + 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 | Info) -> None: + """Applies titlecase to fields, except + those excluded by the default exclusions and the + set exclude lists. + """ + for field in self.fields_to_process: + init_field = getattr(item, field, "") + if init_field: + if isinstance(init_field, list) and isinstance( + init_field[0], str + ): + cased_list: list[str] = [ + self.titlecase(i, field) for i in init_field + ] + if cased_list != init_field: + setattr(item, field, cased_list) + self._log.info( + f"{field}: {', '.join(init_field)} ->", + f"{', '.join(cased_list)}", + ) + elif isinstance(init_field, str): + cased: str = self.titlecase(init_field, field) + if cased != init_field: + setattr(item, field, cased) + self._log.info(f"{field}: {init_field} -> {cased}") + else: + self._log.debug(f"{field}: no string present") + else: + self._log.debug(f"{field}: does not exist on {type(item)}") + + def titlecase(self, text: str, field: str = "") -> str: + """Titlecase the given text.""" + # Check we should split this into two substrings. + if self.seperators: + if len(splits := self.seperators.findall(text)): + split_cased = "".join( + [self.titlecase(s[0], field) + s[1] for s in splits] + ) + # Add on the remaining portion + return split_cased + self.titlecase( + text[len(split_cased) :], field + ) + # Any necessary replacements go first, mainly punctuation. + titlecased = text.lower() if self.force_lowercase else text + for pair in self.replace: + target, replacement = pair + titlecased = titlecased.replace(target, replacement) + # General titlecase operation + titlecased = titlecase( + titlecased, + small_first_last=self.small_first_last, + callback=self.titlecase_callback, + ) + # Apply "The Artist" feature + if self.the_artist and "artist" in field: + titlecased = self.the_artist_regexp.sub("The", titlecased) + # More complicated phrase replacements. + for phrase, regexp in self.preserve["phrases"].items(): + titlecased = regexp.sub(phrase, titlecased) + return titlecased + + def imported(self, session: ImportSession, task: ImportTask) -> None: + """Import hook for titlecasing on import.""" + for item in task.imported_items(): + try: + self._log.debug(f"titlecasing {item.title}:") + self.titlecase_fields(item) + item.store() + except Exception as e: + self._log.debug(f"titlecasing exception {e}") diff --git a/docs/changelog.rst b/docs/changelog.rst index 8f2d108d2..76951a541 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -27,10 +27,15 @@ New features: - :doc:`plugins/mbpseudo`: Add a new `mbpseudo` plugin to proactively receive MusicBrainz pseudo-releases as recommendations during import. - Added support for Python 3.13. -- Added album-level `$media` field derived from items’ media metadata. +- :doc:`plugins/titlecase`: Add the `titlecase` plugin to allow users to + resolve differences in metadata source styles. Bug fixes: +- :doc:`plugins/inline`: Fix recursion error when an inline field definition + shadows a built-in item field (e.g., redefining ``track_no``). Inline + expressions now skip self-references during evaluation to avoid infinite + recursion. :bug:`6115` - When hardlinking from a symlink (e.g. importing a symlink with hardlinking enabled), dereference the symlink then hardlink, rather than creating a new (potentially broken) symlink :bug:`5676` @@ -49,6 +54,8 @@ Bug fixes: endpoints. Previously, due to single-quotes (ie. string literal) in the SQL query, the query eg. `GET /item/values/albumartist` would return the literal "albumartist" instead of a list of unique album artists. +- Sanitize log messages by removing control characters preventing terminal + rendering issues. For plugin developers: @@ -70,6 +77,8 @@ Other changes: maintainability. - :doc:`plugins/bpd`: Raise ImportError instead of ValueError when GStreamer is unavailable, enabling ``importorskip`` usage in pytest setup. +- Finally removed gmusic plugin and all related code/docs as the Google Play + Music service was shut down in 2020. 2.5.1 (October 14, 2025) ------------------------ @@ -1354,9 +1363,9 @@ There are some fixes in this release: - Fix a regression in the last release that made the image resizer fail to detect older versions of ImageMagick. :bug:`3269` -- :doc:`/plugins/gmusic`: The ``oauth_file`` config option now supports more +- ``/plugins/gmusic``: The ``oauth_file`` config option now supports more flexible path values, including ``~`` for the home directory. :bug:`3270` -- :doc:`/plugins/gmusic`: Fix a crash when using version 12.0.0 or later of the +- ``/plugins/gmusic``: Fix a crash when using version 12.0.0 or later of the ``gmusicapi`` module. :bug:`3270` - Fix an incompatibility with Python 3.8's AST changes. :bug:`3278` @@ -1407,7 +1416,7 @@ And many improvements to existing plugins: singletons. :bug:`3220` :bug:`3219` - :doc:`/plugins/play`: The plugin can now emit a UTF-8 BOM, fixing some issues with foobar2000 and Winamp. Thanks to :user:`mz2212`. :bug:`2944` -- :doc:`/plugins/gmusic`: +- ``/plugins/gmusic``: - Add a new option to automatically upload to Google Play Music library on track import. Thanks to :user:`shuaiscott`. @@ -1846,7 +1855,7 @@ Here are the new features: - :ref:`Date queries ` can also be *relative*. You can say ``added:-1w..`` to match music added in the last week, for example. Thanks to :user:`euri10`. :bug:`2598` -- A new :doc:`/plugins/gmusic` lets you interact with your Google Play Music +- A new ``/plugins/gmusic`` lets you interact with your Google Play Music library. Thanks to :user:`tigranl`. :bug:`2553` :bug:`2586` - :doc:`/plugins/replaygain`: We now keep R128 data in separate tags from classic ReplayGain data for formats that need it (namely, Ogg Opus). A new diff --git a/docs/dev/plugins/other/prompts.rst b/docs/dev/plugins/other/prompts.rst index f734f0de3..29720b922 100644 --- a/docs/dev/plugins/other/prompts.rst +++ b/docs/dev/plugins/other/prompts.rst @@ -13,7 +13,7 @@ shall expose to the user: .. code-block:: python from beets.plugins import BeetsPlugin - from beets.ui.commands import PromptChoice + from beets.util import PromptChoice class ExamplePlugin(BeetsPlugin): diff --git a/docs/plugins/gmusic.rst b/docs/plugins/gmusic.rst deleted file mode 100644 index 76697ea31..000000000 --- a/docs/plugins/gmusic.rst +++ /dev/null @@ -1,5 +0,0 @@ -Gmusic Plugin -============= - -The ``gmusic`` plugin interfaced beets to Google Play Music. It has been removed -after the shutdown of this service. diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index c211616e4..a1114976e 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -84,7 +84,6 @@ databases. They share the following configuration options: fromfilename ftintitle fuzzy - gmusic hook ihate importadded @@ -128,6 +127,7 @@ databases. They share the following configuration options: substitute the thumbnails + titlecase types unimported web diff --git a/docs/plugins/titlecase.rst b/docs/plugins/titlecase.rst new file mode 100644 index 000000000..c35bc10a4 --- /dev/null +++ b/docs/plugins/titlecase.rst @@ -0,0 +1,200 @@ +Titlecase Plugin +================ + +The ``titlecase`` plugin lets you format tags and paths in accordance with the +titlecase guidelines in the `New York Times Manual of Style`_ and uses the +`python titlecase library`_. + +Motivation for this plugin comes from a desire to resolve differences in style +between databases sources. For example, `MusicBrainz style`_ follows standard +title case rules, except in the case of terms that are deemed generic, like +"mix" and "remix". On the other hand, `Discogs guidelines`_ recommend +capitalizing the first letter of each word, even for small words like "of" and +"a". This plugin aims to achieve a middle ground between disparate approaches to +casing, and bring more consistency to titles in your library. + +.. _discogs guidelines: https://support.discogs.com/hc/en-us/articles/360005006334-Database-Guidelines-1-General-Rules#Capitalization_And_Grammar + +.. _musicbrainz style: https://musicbrainz.org/doc/Style + +.. _new york times manual of style: https://search.worldcat.org/en/title/946964415 + +.. _python titlecase library: https://pypi.org/project/titlecase/ + +Installation +------------ + +To use the ``titlecase`` plugin, first enable it in your configuration (see +:ref:`using-plugins`). Then, install ``beets`` with ``titlecase`` extra: + +.. code-block:: bash + + pip install "beets[titlecase]" + +If you'd like to just use the path format expression, call ``%titlecase`` in +your path formatter, and set ``auto`` to ``no`` in the configuration. + +:: + + paths: + default: %titlecase($albumartist)/$titlecase($albumtitle)/$track $title + +You can now configure ``titlecase`` to your preference. + +Configuration +------------- + +This plugin offers several configuration options to tune its function to your +preference. + +Default +~~~~~~~ + +.. code-block:: yaml + + titlecase: + auto: yes + fields: [] + preserve: [] + replace: [] + seperators: [] + force_lowercase: no + small_first_last: yes + the_artist: yes + after_choice: no + +.. conf:: auto + :default: yes + + Whether to automatically apply titlecase to new imports. + +.. conf:: fields + :default: [] + + A list of fields to apply the titlecase logic to. You must specify the fields + you want to have modified in order for titlecase to apply changes to metadata. + + A good starting point is below, which will titlecase album titles, track titles, and all artist fields. + +.. code-block:: yaml + + titlecase: + fields: + - album + - title + - albumartist + - albumartist_credit + - albumartist_sort + - albumartists + - albumartists_credit + - albumartists_sort + - artist + - artist_credit + - artist_sort + - artists + - artists_credit + - artists_sort + +.. conf:: preserve + :default: [] + + List of words and phrases to preserve the case of. Without specifying ``DJ`` on + the list, titlecase will format it as ``Dj``, or specify ``The Beatles`` to make sure + ``With The Beatles`` is not capitalized as ``With the Beatles``. + +.. conf:: replace + :default: [] + + The replace function takes place before any titlecasing occurs, and is intended to + help normalize differences in puncuation styles. It accepts a list of tuples, with + the first being the target, and the second being the replacement. + + An example configuration that enforces one style of quotation mark is below. + +.. code-block:: yaml + + titlecase: + replace: + - "’": "'" + - "‘": "'" + - "“": '"' + - "”": '"' + +.. conf:: seperators + :default: [] + + A list of characters to treat as markers of new sentences. Helpful for split titles + that might otherwise have a lowercase letter at the start of the second string. + +.. conf:: force_lowercase + :default: no + + Force all strings to lowercase before applying titlecase, but can cause + problems with all caps acronyms titlecase would otherwise recognize. + +.. conf:: small_first_last + :default: yes + + An option from the base titlecase library. Controls capitalizing small words at the start + of a sentence. With this turned off ``a`` and similar words will not be capitalized + under any circumstance. + +.. conf:: the_artist + :default: yes + + If a field name contains ``artist``, then any lowercase ``the`` will be + capitalized. Useful for bands with `The` as part of the proper name, + like ``Amyl and The Sniffers``. + +.. conf:: after_choice + :default: no + + By default, titlecase runs on the candidates that are received, adjusting them before + you make your selection and creating different weight calculations. If you'd rather + see the data as recieved from the database, set this to true to run after you make + your tag choice. + +Dangerous Fields +~~~~~~~~~~~~~~~~ + +``titlecase`` only ever modifies string fields, however, this doesn't prevent +you from selecting a case sensitive field that another plugin or feature may +rely on. + +In particular, including any of the following in your configuration could lead +to unintended behavior: + +.. code-block:: bash + + acoustid_fingerprint + acoustid_id + artists_ids + asin + deezer_track_id + format + id + isrc + mb_workid + mb_trackid + mb_albumid + mb_artistid + mb_artistids + mb_albumartistid + mb_albumartistids + mb_releasetrackid + mb_releasegroupid + bitrate_mode + encoder_info + encoder_settings + +Running Manually +---------------- + +From the command line, type: + +:: + + $ beet titlecase [QUERY] + +Configuration is drawn from the config file. Without a query the operation will +be applied to the entire collection. diff --git a/poetry.lock b/poetry.lock index 9426ad659..ba16420c2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2471,6 +2471,8 @@ files = [ {file = "pycairo-1.28.0-cp313-cp313-win32.whl", hash = "sha256:d13352429d8a08a1cb3607767d23d2fb32e4c4f9faa642155383980ec1478c24"}, {file = "pycairo-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:082aef6b3a9dcc328fa648d38ed6b0a31c863e903ead57dd184b2e5f86790140"}, {file = "pycairo-1.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:026afd53b75291917a7412d9fe46dcfbaa0c028febd46ff1132d44a53ac2c8b6"}, + {file = "pycairo-1.28.0-cp314-cp314-win32.whl", hash = "sha256:d0ab30585f536101ad6f09052fc3895e2a437ba57531ea07223d0e076248025d"}, + {file = "pycairo-1.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:94f2ed204999ab95a0671a0fa948ffbb9f3d6fb8731fe787917f6d022d9c1c0f"}, {file = "pycairo-1.28.0-cp39-cp39-win32.whl", hash = "sha256:3ed16d48b8a79cc584cb1cb0ad62dfb265f2dda6d6a19ef5aab181693e19c83c"}, {file = "pycairo-1.28.0-cp39-cp39-win_amd64.whl", hash = "sha256:da0d1e6d4842eed4d52779222c6e43d254244a486ca9fdab14e30042fd5bdf28"}, {file = "pycairo-1.28.0-cp39-cp39-win_arm64.whl", hash = "sha256:458877513eb2125513122e8aa9c938630e94bb0574f94f4fb5ab55eb23d6e9ac"}, @@ -2821,6 +2823,13 @@ description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" files = [ + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, @@ -3896,6 +3905,19 @@ files = [ {file = "threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e"}, ] +[[package]] +name = "titlecase" +version = "2.4.1" +description = "Python Port of John Gruber's titlecase.pl" +optional = false +python-versions = ">=3.7" +files = [ + {file = "titlecase-2.4.1.tar.gz", hash = "sha256:7d83a277ccbbda11a2944e78a63e5ccaf3d32f828c594312e4862f9a07f635f5"}, +] + +[package.extras] +regex = ["regex (>=2020.4.4)"] + [[package]] name = "toml" version = "0.10.2" @@ -4161,9 +4183,10 @@ replaygain = ["PyGObject"] scrub = ["mutagen"] sonosupdate = ["soco"] thumbnails = ["Pillow", "pyxdg"] +titlecase = ["titlecase"] web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "10a60daf371ba5d2c3d62ab0da7be81af40890517f9f60ed4a2cee1835eea6ae" +content-hash = "9e154214b2f404415ef17df83f926a326ffb62a83b3901a404946110354d4067" diff --git a/pyproject.toml b/pyproject.toml index e4b69b7f3..8b33e9fcb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,6 +93,7 @@ pydata-sphinx-theme = { version = "*", optional = true } sphinx = { version = "*", optional = true } sphinx-design = { version = ">=0.6.1", optional = true } sphinx-copybutton = { version = ">=0.5.2", optional = true } +titlecase = {version = "^2.4.1", optional = true} [tool.poetry.group.test.dependencies] beautifulsoup4 = "*" @@ -112,6 +113,7 @@ rarfile = "*" requests-mock = ">=1.12.1" requests_oauthlib = "*" responses = ">=0.3.0" +titlecase = "^2.4.1" [tool.poetry.group.lint.dependencies] docstrfmt = ">=1.11.1" @@ -172,6 +174,7 @@ replaygain = [ ] # python-gi and GStreamer 1.0+ or mp3gain/aacgain or Python Audio Tools or ffmpeg scrub = ["mutagen"] sonosupdate = ["soco"] +titlecase = ["titlecase"] thumbnails = ["Pillow", "pyxdg"] web = ["flask", "flask-cors"] diff --git a/test/plugins/test_inline.py b/test/plugins/test_inline.py new file mode 100644 index 000000000..79118bd06 --- /dev/null +++ b/test/plugins/test_inline.py @@ -0,0 +1,62 @@ +# This file is part of beets. +# Copyright 2025, Gabe Push. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +from beets import config, plugins +from beets.test.helper import PluginTestCase +from beetsplug.inline import InlinePlugin + + +class TestInlineRecursion(PluginTestCase): + def test_no_recursion_when_inline_shadows_fixed_field(self): + config["plugins"] = ["inline"] + + config["item_fields"] = { + "track_no": ( + "f'{disc:02d}-{track:02d}' if disctotal > 1 else f'{track:02d}'" + ) + } + + plugins._instances.clear() + plugins.load_plugins() + + item = self.add_item_fixture( + artist="Artist", + album="Album", + title="Title", + track=1, + disc=1, + disctotal=1, + ) + + out = item.evaluate_template("$track_no") + + assert out == "01" + + def test_inline_function_body_item_field(self): + plugin = InlinePlugin() + func = plugin.compile_inline( + "return track + 1", album=False, field_name="next_track" + ) + + item = self.add_item_fixture(track=3) + assert func(item) == 4 + + def test_inline_album_expression_uses_items(self): + plugin = InlinePlugin() + func = plugin.compile_inline( + "len(items)", album=True, field_name="item_count" + ) + + album = self.add_album_fixture() + assert func(album) == len(list(album.items())) diff --git a/test/plugins/test_titlecase.py b/test/plugins/test_titlecase.py new file mode 100644 index 000000000..44058780c --- /dev/null +++ b/test/plugins/test_titlecase.py @@ -0,0 +1,400 @@ +# This file is part of beets. +# Copyright 2025, Henry Oberholtzer +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# 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""" + +from unittest.mock import patch + +from beets.autotag.hooks import AlbumInfo, TrackInfo +from beets.importer import ImportSession, ImportTask +from beets.library import Item +from beets.test.helper import PluginTestCase +from beetsplug.titlecase import TitlecasePlugin + +titlecase_fields_testcases = [ + ( + { + "fields": [ + "artist", + "albumartist", + "title", + "album", + "mb_albumd", + "year", + ], + "force_lowercase": True, + }, + Item( + artist="OPHIDIAN", + albumartist="ophiDIAN", + format="CD", + year=2003, + album="BLACKBOX", + title="KhAmElEoN", + ), + Item( + artist="Ophidian", + albumartist="Ophidian", + format="CD", + year=2003, + album="Blackbox", + title="Khameleon", + ), + ), +] + + +class TestTitlecasePlugin(PluginTestCase): + plugin = "titlecase" + preload_plugin = False + + def test_auto(self): + """Ensure automatic processing gets assigned""" + with self.configure_plugin({"auto": True, "after_choice": True}): + assert callable(TitlecasePlugin().import_stages[0]) + with self.configure_plugin({"auto": False, "after_choice": False}): + assert len(TitlecasePlugin().import_stages) == 0 + with self.configure_plugin({"auto": False, "after_choice": True}): + assert len(TitlecasePlugin().import_stages) == 0 + + def test_basic_titlecase(self): + """Check that default behavior is as expected.""" + testcases = [ + ("a", "A"), + ("PENDULUM", "Pendulum"), + ("Aaron-carl", "Aaron-Carl"), + ("LTJ bukem", "LTJ Bukem"), + ("(original mix)", "(Original Mix)"), + ("ALL CAPS TITLE", "All Caps Title"), + ] + for testcase in testcases: + given, expected = testcase + assert TitlecasePlugin().titlecase(given) == expected + + def test_small_first_last(self): + """Check the behavior for supporting small first last""" + testcases = [ + (True, "In a Silent Way", "In a Silent Way"), + (False, "In a Silent Way", "in a Silent Way"), + ] + for testcase in testcases: + sfl, given, expected = testcase + cfg = {"small_first_last": sfl} + with self.configure_plugin(cfg): + assert TitlecasePlugin().titlecase(given) == expected + + def test_preserve(self): + """Test using given strings to preserve case""" + preserve_list = [ + "easyFun", + "A.D.O.R", + "D'Angelo", + "ABBA", + "LaTeX", + "O.R.B", + "PinkPantheress", + "THE PSYCHIC ED RUSH", + "LTJ Bukem", + ] + for word in preserve_list: + with self.configure_plugin({"preserve": preserve_list}): + assert TitlecasePlugin().titlecase(word.upper()) == word + assert TitlecasePlugin().titlecase(word.lower()) == word + + def test_seperators(self): + testcases = [ + ([], "it / a / in / of / to / the", "It / a / in / of / to / The"), + (["/"], "it / the test", "It / The Test"), + ( + ["/"], + "it / a / in / of / to / the", + "It / A / In / Of / To / The", + ), + (["/"], "//it/a/in/of/to/the", "//It/A/In/Of/To/The"), + ( + ["/", ";", "|"], + "it ; a / in | of / to | the", + "It ; A / In | Of / To | The", + ), + ] + for testcase in testcases: + seperators, given, expected = testcase + with self.configure_plugin({"seperators": seperators}): + assert TitlecasePlugin().titlecase(given) == expected + + def test_received_info_handler(self): + testcases = [ + ( + TrackInfo( + album="test album", + artist_credit="test artist credit", + artists=["artist one", "artist two"], + ), + TrackInfo( + album="Test Album", + artist_credit="Test Artist Credit", + artists=["Artist One", "Artist Two"], + ), + ), + ( + AlbumInfo( + tracks=[ + TrackInfo( + album="test album", + artist_credit="test artist credit", + artists=["artist one", "artist two"], + ) + ], + album="test album", + artist_credit="test artist credit", + artists=["artist one", "artist two"], + ), + AlbumInfo( + tracks=[ + TrackInfo( + album="Test Album", + artist_credit="Test Artist Credit", + artists=["Artist One", "Artist Two"], + ) + ], + album="Test Album", + artist_credit="Test Artist Credit", + artists=["Artist One", "Artist Two"], + ), + ), + ] + cfg = {"fields": ["album", "artist_credit", "artists"]} + for testcase in testcases: + given, expected = testcase + with self.configure_plugin(cfg): + TitlecasePlugin().received_info_handler(given) + assert given == expected + + def test_titlecase_fields(self): + testcases = [ + # Test with preserve, replace, and mb_albumid + # Test with the_artist + ( + { + "preserve": ["D'Angelo"], + "replace": [("’", "'")], + "fields": ["artist", "albumartist", "mb_albumid"], + }, + Item( + artist="d’angelo and the vanguard", + mb_albumid="ab140e13-7b36-402a-a528-b69e3dee38a8", + albumartist="d’angelo", + format="CD", + album="the black messiah", + title="Till It's Done (Tutu)", + ), + Item( + artist="D'Angelo and The Vanguard", + mb_albumid="Ab140e13-7b36-402a-A528-B69e3dee38a8", + albumartist="D'Angelo", + format="CD", + album="the black messiah", + title="Till It's Done (Tutu)", + ), + ), + # Test with force_lowercase, preserve, and an incorrect field + ( + { + "force_lowercase": True, + "fields": [ + "artist", + "albumartist", + "format", + "title", + "year", + "label", + "format", + "INCORRECT_FIELD", + ], + "preserve": ["CD"], + }, + Item( + artist="OPHIDIAN", + albumartist="OphiDIAN", + format="cd", + year=2003, + album="BLACKBOX", + title="KhAmElEoN", + label="enzyme records", + ), + Item( + artist="Ophidian", + albumartist="Ophidian", + format="CD", + year=2003, + album="Blackbox", + title="Khameleon", + label="Enzyme Records", + ), + ), + # Test with no changes + ( + { + "fields": [ + "artist", + "artists", + "albumartist", + "format", + "title", + "year", + "label", + "format", + "INCORRECT_FIELD", + ], + "preserve": ["CD"], + }, + Item( + artist="Ophidian", + artists=["Ophidian"], + albumartist="Ophidian", + format="CD", + year=2003, + album="Blackbox", + title="Khameleon", + label="Enzyme Records", + ), + Item( + artist="Ophidian", + artists=["Ophidian"], + albumartist="Ophidian", + format="CD", + year=2003, + album="Blackbox", + title="Khameleon", + label="Enzyme Records", + ), + ), + # Test with the_artist disabled + ( + { + "the_artist": False, + "fields": [ + "artist", + "artists_sort", + ], + }, + Item( + artists_sort=["b-52s, the"], + artist="a day in the park", + ), + Item( + artists_sort=["B-52s, The"], + artist="A Day in the Park", + ), + ), + # Test to make sure preserve and the_artist + # dont target the middle of sentences + # show that The artist applies to any field + # with artist mentioned + ( + { + "preserve": ["PANTHER"], + "fields": ["artist", "artists", "artists_ids"], + }, + Item( + artist="pinkpantheress", + artists=["pinkpantheress", "artist_two"], + artists_ids=["the the", "the the"], + ), + Item( + artist="Pinkpantheress", + artists=["Pinkpantheress", "Artist_two"], + artists_ids=["The The", "The The"], + ), + ), + ] + for testcase in testcases: + cfg, given, expected = testcase + with self.configure_plugin(cfg): + TitlecasePlugin().titlecase_fields(given) + assert given.artist == expected.artist + assert given.artists == expected.artists + assert given.artists_sort == expected.artists_sort + assert given.albumartist == expected.albumartist + assert given.artists_ids == expected.artists_ids + assert given.format == expected.format + assert given.year == expected.year + assert given.title == expected.title + assert given.label == expected.label + + def test_cli_write(self): + given = Item( + album="retrodelica 2: back 2 the future", + artist="blue planet corporation", + title="generator", + ) + expected = Item( + album="Retrodelica 2: Back 2 the Future", + artist="Blue Planet Corporation", + title="Generator", + ) + cfg = {"fields": ["album", "artist", "title"]} + with self.configure_plugin(cfg): + given.add(self.lib) + self.run_command("titlecase") + assert self.lib.items().get().artist == expected.artist + assert self.lib.items().get().album == expected.album + assert self.lib.items().get().title == expected.title + self.lib.items().get().remove() + + def test_cli_no_write(self): + given = Item( + album="retrodelica 2: back 2 the future", + artist="blue planet corporation", + title="generator", + ) + expected = Item( + album="retrodelica 2: back 2 the future", + artist="blue planet corporation", + title="generator", + ) + cfg = {"fields": ["album", "artist", "title"]} + with self.configure_plugin(cfg): + given.add(self.lib) + self.run_command("-p", "titlecase") + assert self.lib.items().get().artist == expected.artist + assert self.lib.items().get().album == expected.album + assert self.lib.items().get().title == expected.title + self.lib.items().get().remove() + + def test_imported(self): + given = Item( + album="retrodelica 2: back 2 the future", + artist="blue planet corporation", + title="generator", + ) + expected = Item( + album="Retrodelica 2: Back 2 the Future", + artist="Blue Planet Corporation", + title="Generator", + ) + p = patch("beets.importer.ImportTask.imported_items", lambda x: [given]) + p.start() + with self.configure_plugin({"fields": ["album", "artist", "title"]}): + import_session = ImportSession( + self.lib, loghandler=None, paths=None, query=None + ) + import_task = ImportTask(toppath=None, paths=None, items=[given]) + TitlecasePlugin().imported(import_session, import_task) + import_task.add(self.lib) + item = self.lib.items().get() + assert item.artist == expected.artist + assert item.album == expected.album + assert item.title == expected.title + p.stop() diff --git a/test/test_files.py b/test/test_files.py index 631b56b72..d0d93987c 100644 --- a/test/test_files.py +++ b/test/test_files.py @@ -579,7 +579,7 @@ class SafeMoveCopyTest(FilePathTestCase): @NEEDS_REFLINK def test_successful_reflink(self): - util.reflink(self.path, self.dest) + util.reflink(str(self.path), str(self.dest)) assert self.dest.exists() assert self.path.exists() diff --git a/test/test_logging.py b/test/test_logging.py index 48f9cbfd8..5990fd4e1 100644 --- a/test/test_logging.py +++ b/test/test_logging.py @@ -67,6 +67,58 @@ class TestStrFormatLogger: assert str(caplog.records[0].msg) == expected +class TestLogSanitization: + """Log messages should have control characters removed from: + - String arguments + - Keyword argument values + - Bytes arguments (which get decoded first) + """ + + @pytest.mark.parametrize( + "msg, args, kwargs, expected", + [ + # Valid UTF-8 bytes are decoded and preserved + ( + "foo {} bar {bar}", + (b"oof \xc3\xa9",), + {"bar": b"baz \xc3\xa9"}, + "foo oof é bar baz é", + ), + # Invalid UTF-8 bytes are decoded with replacement characters + ( + "foo {} bar {bar}", + (b"oof \xff",), + {"bar": b"baz \xff"}, + "foo oof � bar baz �", + ), + # Control characters should be removed + ( + "foo {} bar {bar}", + ("oof \x9e",), + {"bar": "baz \x9e"}, + "foo oof � bar baz �", + ), + # Whitespace control characters should be preserved + ( + "foo {} bar {bar}", + ("foo\t\n",), + {"bar": "bar\r"}, + "foo foo\t\n bar bar\r", + ), + ], + ) + def test_sanitization(self, msg, args, kwargs, expected, caplog): + level = log.INFO + logger = blog.getLogger("test_logger") + logger.setLevel(level) + + with caplog.at_level(level, logger="test_logger"): + logger.log(level, msg, *args, **kwargs) + + assert caplog.records, "No log records were captured" + assert str(caplog.records[0].msg) == expected + + class DummyModule(ModuleType): class DummyPlugin(plugins.BeetsPlugin): def __init__(self): diff --git a/test/test_plugins.py b/test/test_plugins.py index 07bbf0966..6f7026718 100644 --- a/test/test_plugins.py +++ b/test/test_plugins.py @@ -41,7 +41,7 @@ from beets.test.helper import ( PluginTestCase, TerminalImportMixin, ) -from beets.util import displayable_path, syspath +from beets.util import PromptChoice, displayable_path, syspath class TestPluginRegistration(PluginTestCase): @@ -292,8 +292,8 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): def return_choices(self, session, task): return [ - ui.commands.PromptChoice("f", "Foo", None), - ui.commands.PromptChoice("r", "baR", None), + PromptChoice("f", "Foo", None), + PromptChoice("r", "baR", None), ] self.register_plugin(DummyPlugin) @@ -328,8 +328,8 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): def return_choices(self, session, task): return [ - ui.commands.PromptChoice("f", "Foo", None), - ui.commands.PromptChoice("r", "baR", None), + PromptChoice("f", "Foo", None), + PromptChoice("r", "baR", None), ] self.register_plugin(DummyPlugin) @@ -363,10 +363,10 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): def return_choices(self, session, task): return [ - ui.commands.PromptChoice("a", "A foo", None), # dupe - ui.commands.PromptChoice("z", "baZ", None), # ok - ui.commands.PromptChoice("z", "Zupe", None), # dupe - ui.commands.PromptChoice("z", "Zoo", None), + PromptChoice("a", "A foo", None), # dupe + PromptChoice("z", "baZ", None), # ok + PromptChoice("z", "Zupe", None), # dupe + PromptChoice("z", "Zoo", None), ] # dupe self.register_plugin(DummyPlugin) @@ -399,7 +399,7 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): ) def return_choices(self, session, task): - return [ui.commands.PromptChoice("f", "Foo", self.foo)] + return [PromptChoice("f", "Foo", self.foo)] def foo(self, session, task): pass @@ -441,7 +441,7 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): ) def return_choices(self, session, task): - return [ui.commands.PromptChoice("f", "Foo", self.foo)] + return [PromptChoice("f", "Foo", self.foo)] def foo(self, session, task): return Action.SKIP @@ -543,3 +543,39 @@ class TestDeprecationCopy: assert hasattr(LegacyMetadataPlugin, "data_source_mismatch_penalty") assert hasattr(LegacyMetadataPlugin, "_extract_id") assert hasattr(LegacyMetadataPlugin, "get_artist") + + +class TestMusicBrainzPluginLoading: + @pytest.fixture(autouse=True) + def config(self): + _config = config + _config.sources = [] + _config.read(user=False, defaults=True) + return _config + + def test_default(self): + assert "musicbrainz" in plugins.get_plugin_names() + + def test_other_plugin_enabled(self, config): + config["plugins"] = ["anything"] + + assert "musicbrainz" not in plugins.get_plugin_names() + + def test_deprecated_enabled(self, config, caplog): + config["plugins"] = ["anything"] + config["musicbrainz"]["enabled"] = True + + assert "musicbrainz" in plugins.get_plugin_names() + assert ( + "musicbrainz.enabled' configuration option is deprecated" + in caplog.text + ) + + def test_deprecated_disabled(self, config, caplog): + config["musicbrainz"]["enabled"] = False + + assert "musicbrainz" not in plugins.get_plugin_names() + assert ( + "musicbrainz.enabled' configuration option is deprecated" + in caplog.text + )